feat(ai-workspace): support SEO codex provider bridge

This commit is contained in:
Codex 2026-07-03 08:43:37 +03:00
parent 1aceabee44
commit a7c6c34c82
26 changed files with 1649 additions and 36 deletions

View File

@ -27,6 +27,18 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica
- The deployed AI Hub is allowed only as transport. Launcher, Engine, Ops, Authentik, task manager, and downstream app calls must stay local.
- The remote worker must not call product apps directly. Assistant actions must route back through the local AI Workspace Assistant.
`local-seo/deployed-hub-control-plane`:
- SEO frontend: `http://127.0.0.1:5177`
- SEO backend: `http://127.0.0.1:4100`
- SEO AI Workspace profile: `deployed-hub`
- SEO backend Assistant-compatible control-plane URL: `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru`
- SEO backend control-plane token: `SEO_AI_WORKSPACE_CONTROL_TOKEN`, accepted by the public Hub server-side API.
- Worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
- Public Hub exposes only token-gated Assistant control-plane proxy endpoints required by SEO model-provider setup, probe, thread dispatch, and message polling.
- Public Hub must proxy those calls to internal Platform Assistant with server-side internal token. Browser clients and remote workers must not receive the Hub internal token or Assistant token.
- This profile is for local SEO development against the deployed AI Workspace service plane. It is not the same as `local`, and it must not silently fall back to localhost or product app URLs.
`synology/prod-public`:
- Browser Launcher: `https://hub.nodedc.ru`
@ -57,6 +69,23 @@ New apps must join AI Workspace through app manifests/config/adapters:
Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow.
### NDC SEO Mode
`seo-mode` is a first-class AI Workspace surface, not `global`.
Local SEO testing should keep SEO app/backend on fixed local ports while using the configured AI Workspace transport/profile:
- SEO frontend: `http://127.0.0.1:5177`
- SEO backend: `http://127.0.0.1:4100`
- SEO model-provider surface: `seo-mode`
- SEO model task mode: `seo-model-task`
- Worker-facing transport: public AI Hub relay when selected by profile, normally `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
- SEO task payload: sanitized `seo-model-task` contract only
The SEO backend must select profiles through env/config, not code edits. Local and deploy-host may use different Assistant URLs, executor ids, setup commands, model defaults, and app grants, but the request contract stays the same.
Remote Codex for SEO must not receive Wordstat/Yandex credentials, product app tokens, unrestricted repo access, rewrite/apply actions, or destructive actions. Wordstat/SERP collection remains SEO-backend-owned.
## Verification
Before changing worker, Assistant, Hub, or app adapter routing, run:

View File

@ -73,6 +73,8 @@ AI_WORKSPACE_HUB_FALLBACK_URLS=
Server-side Hub API calls, including executor status checks, use `AI_WORKSPACE_HUB_INTERNAL_URL` and require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services.
Local NDC SEO Mode development uses this same server-side Hub class through `SEO_AI_WORKSPACE_PROFILE=deployed-hub`, `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru`, and a Hub-accepted `SEO_AI_WORKSPACE_CONTROL_TOKEN`. The Hub only exposes an Assistant-compatible allowlist proxy for setup/probe/thread dispatch calls behind bearer-token auth; it does not expose unrestricted Assistant, product apps, Authentik, Engine, Ops, or Tasker to the remote Codex worker.
AI Workspace Assistant also calls product entitlement adapters before every Codex run. Ops uses the Agent Gateway internal endpoint; the token must match `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN` from the Ops Agent Gateway deployment:
```env

View File

@ -1257,6 +1257,8 @@ function usesJsonOutput(args) {
return Array.isArray(args) && args.includes('--json')
}
const CODEX_ASSISTANT_TEXT_MAX = 250000
function truncateText(value, max = 1400) {
const text = String(value || '').trim()
return text.length > max ? `${text.slice(0, max).trim()}...` : text
@ -1504,7 +1506,7 @@ function createActiveMirror(command, payload = {}, meta = {}) {
if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000)
if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000)
if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000)
if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000)
if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, CODEX_ASSISTANT_TEXT_MAX)
if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed'
if (kind === 'timeout') state.status = 'timeout'
if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed'
@ -1520,7 +1522,7 @@ function createActiveMirror(command, payload = {}, meta = {}) {
async complete(status, assistantText = '') {
state.status = status
state.finishedAt = new Date().toISOString()
if (assistantText) state.assistantText = markdownText(assistantText, 12000)
if (assistantText) state.assistantText = markdownText(assistantText, CODEX_ASSISTANT_TEXT_MAX)
await write({
at: state.finishedAt,
kind: `mirror_${status}`,
@ -1656,7 +1658,7 @@ function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) {
const text = truncateText(reasoningTextFromItem(item))
return text ? { kind: 'codex_reasoning', message: text } : null
}
if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') }
if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: markdownText(item.text || '', CODEX_ASSISTANT_TEXT_MAX) }
if (itemType === 'command_execution') {
const status = item.status ? ` - ${item.status}` : ''
return {

View File

@ -638,7 +638,7 @@ async function installAutostart(startScriptPath, installRoot) {
async function installWindowsTask(startScriptPath) {
const taskRun = `"${process.execPath}" "${startScriptPath}"`;
runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], {});
runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { shell: false });
await runCommand("schtasks", [
"/Create",
"/TN",
@ -650,7 +650,7 @@ async function installWindowsTask(startScriptPath) {
"/RL",
"HIGHEST",
"/F",
], {});
], { shell: false });
}
async function installLaunchAgent(startScriptPath) {
@ -788,7 +788,7 @@ function runCommandQuiet(command, args, options = {}) {
const result = spawnSync(command, args, {
env: commandEnv(options),
encoding: "utf8",
shell: process.platform === "win32",
shell: options.shell ?? process.platform === "win32",
windowsHide: true,
});
return {
@ -807,7 +807,7 @@ function runCommand(command, args, options = {}) {
const child = spawn(command, args, {
env: commandEnv(options),
stdio: options.stdio || "inherit",
shell: process.platform === "win32",
shell: options.shell ?? process.platform === "win32",
windowsHide: true,
});
child.on("error", reject);

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ai-workspace-bridge",
"version": "0.1.0",
"version": "0.1.1",
"description": "Install NODE.DC AI Workspace Bridge worker.",
"type": "module",
"bin": {

View File

@ -9,7 +9,7 @@ import { Pool } from "pg";
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs";
import { loadCatalog } from "../../ontology-core/src/catalog.mjs";
const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]);
const SUPPORTED_SURFACES = new Set(["engine", "ops", "seo-mode", "global"]);
const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]);
const SUPPORTED_CONNECTION_MODES = new Set(["hub", "direct"]);
const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "checking", "error"]);
@ -100,6 +100,24 @@ const APP_ROUTING_CATALOG = [
requiredScopes: ["ops:project:read"],
deniedText: ACCESS_DENIED_TEXT,
},
{
appId: "seo-mode",
appTitle: "NDC SEO Mode",
surface: "seo-mode",
skillId: "seo-mode-context",
whenToUse: [
"SEO model-provider tasks",
"keyword cleaning proposals",
"SERP interpretation proposals",
"strategy synthesis proposals",
"strategy quality review",
],
actionNamespaces: ["seo.*"],
actionIdPrefixes: ["seo."],
mcpServerNames: [],
requiredScopes: ["seo:model-provider:run"],
deniedText: ACCESS_DENIED_TEXT,
},
];
const ASSISTANT_ACTION_CACHE_TTL_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_CACHE_TTL_MS || 30_000);
const ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25_000);
@ -422,7 +440,8 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma
port: installerPort,
appMcpServers,
});
const command = buildBridgeSetupCommand(setupCode.code);
const bridgePackageSpec = bridgePackageSpecForSetupCode(setupCode.code);
const command = buildBridgeSetupCommand(setupCode.code, bridgePackageSpec);
res.status(201).json({
ok: true,
owner: publicOwner(owner),
@ -430,7 +449,7 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma
install: {
command,
packageName: config.bridgePackageName,
packageSpec: config.bridgePackageSpec,
packageSpec: bridgePackageSpec,
gatewayUrl: config.setupGatewayUrl,
expiresAt: setupCode.expiresAt,
},
@ -980,12 +999,12 @@ function bridgeSetupPayload(executor, options = {}) {
};
}
function buildBridgeSetupCommand(code) {
function buildBridgeSetupCommand(code, packageSpec = config.bridgePackageSpec) {
const parts = [
"npx",
"--yes",
"--package",
config.bridgePackageSpec,
packageSpec,
AI_WORKSPACE_BRIDGE_PACKAGE_BIN,
"setup",
code,
@ -995,6 +1014,13 @@ function buildBridgeSetupCommand(code) {
return parts.join(" ");
}
function bridgePackageSpecForSetupCode(code) {
const packageSpec = String(config.bridgePackageSpec || "");
if (!/^https?:\/\//i.test(packageSpec)) return packageSpec;
const separator = packageSpec.includes("?") ? "&" : "?";
return `${packageSpec}${separator}setup=${encodeURIComponent(setupCodeSuffix(code))}`;
}
async function getBridgePackageTarball() {
if (cachedBridgePackageTarball) return cachedBridgePackageTarball;
@ -1217,7 +1243,7 @@ function mergeOwnerActiveContext(current, incoming) {
};
}
if ((surface === "engine" || surface === "ops") && !isPlainObject(incomingContexts[surface])) {
if ((surface === "engine" || surface === "ops" || surface === "seo-mode") && !isPlainObject(incomingContexts[surface])) {
const { contexts: _contexts, ...surfaceContext } = incomingContext;
nextContexts[surface] = {
...(isPlainObject(nextContexts[surface]) ? nextContexts[surface] : {}),
@ -2770,15 +2796,17 @@ function mergeSurfaceContexts(...contexts) {
}
const surface = normalizeKey(context.surface);
const mode = normalizeKey(context.modeId);
const inferredSurface = surface === "engine" || surface === "ops"
const inferredSurface = surface === "engine" || surface === "ops" || surface === "seo-mode"
? surface
: mode === "ndc-agent-core"
? "engine"
: mode === "ops"
? "ops"
: mode === "seo-model-task"
? "seo-mode"
: "";
if (inferredSurface) {
const { contexts: _contexts, engineContext: _engineContext, opsContext: _opsContext, ...plain } = context;
const { contexts: _contexts, engineContext: _engineContext, opsContext: _opsContext, seoModeContext: _seoModeContext, ...plain } = context;
out[inferredSurface] = mergeBridgeContextObject(out[inferredSurface], plain);
}
}
@ -3373,7 +3401,7 @@ async function migrate() {
constraint ai_workspace_threads_owner_check
check (owner_user_id is not null or owner_email is not null),
constraint ai_workspace_threads_surface_check
check (origin_surface in ('engine', 'ops', 'global')),
check (origin_surface in ('engine', 'ops', 'seo-mode', 'global')),
constraint ai_workspace_threads_lifecycle_check
check (lifecycle_state in ('active', 'archived'))
)
@ -3420,13 +3448,27 @@ async function migrate() {
constraint ai_workspace_runs_owner_check
check (owner_user_id is not null or owner_email is not null),
constraint ai_workspace_runs_surface_check
check (origin_surface in ('engine', 'ops', 'global')),
check (origin_surface in ('engine', 'ops', 'seo-mode', 'global')),
constraint ai_workspace_runs_status_check
check (status in ('running', 'completed', 'failed', 'timeout')),
unique (owner_key, thread_id, request_id)
)
`);
await pool.query(`
alter table ai_workspace_threads
drop constraint if exists ai_workspace_threads_surface_check,
add constraint ai_workspace_threads_surface_check
check (origin_surface in ('engine', 'ops', 'seo-mode', 'global'))
`);
await pool.query(`
alter table ai_workspace_runs
drop constraint if exists ai_workspace_runs_surface_check,
add constraint ai_workspace_runs_surface_check
check (origin_surface in ('engine', 'ops', 'seo-mode', 'global'))
`);
await pool.query(`
create table if not exists ai_workspace_run_events (
id uuid primary key,
@ -4203,6 +4245,11 @@ function parseEntitlementAdapters() {
token: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN,
required: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED,
});
collectEntitlementAdapter(adapters, "seo-mode", {
url: process.env.AI_WORKSPACE_SEO_MODE_ENTITLEMENT_URL || process.env.NDC_AI_WORKSPACE_SEO_MODE_ENTITLEMENT_URL,
token: process.env.AI_WORKSPACE_SEO_MODE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_SEO_MODE_ENTITLEMENT_TOKEN,
required: process.env.AI_WORKSPACE_SEO_MODE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_SEO_MODE_ENTITLEMENT_REQUIRED,
});
collectEntitlementAdapter(adapters, "launcher", {
url:
process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL ||
@ -4228,6 +4275,7 @@ function parseEntitlementAdapters() {
const order = new Map([
["ops", 10],
["engine", 20],
["seo-mode", 30],
["launcher", 90],
]);
return Array.from(byAppId.values())

View File

@ -1962,6 +1962,8 @@ function usesJsonOutput(args) {
return Array.isArray(args) && args.includes('--json')
}
const CODEX_ASSISTANT_TEXT_MAX = 250000
function truncateText(value, max = 1400) {
const text = String(value || '').trim()
return text.length > max ? `${text.slice(0, max).trim()}...` : text
@ -2209,7 +2211,7 @@ function createActiveMirror(command, payload = {}, meta = {}) {
if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000)
if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000)
if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000)
if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000)
if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, CODEX_ASSISTANT_TEXT_MAX)
if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed'
if (kind === 'timeout') state.status = 'timeout'
if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed'
@ -2225,7 +2227,7 @@ function createActiveMirror(command, payload = {}, meta = {}) {
async complete(status, assistantText = '') {
state.status = status
state.finishedAt = new Date().toISOString()
if (assistantText) state.assistantText = markdownText(assistantText, 12000)
if (assistantText) state.assistantText = markdownText(assistantText, CODEX_ASSISTANT_TEXT_MAX)
await write({
at: state.finishedAt,
kind: `mirror_${status}`,
@ -2361,7 +2363,7 @@ function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) {
const text = truncateText(reasoningTextFromItem(item))
return text ? { kind: 'codex_reasoning', message: text } : null
}
if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') }
if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: markdownText(item.text || '', CODEX_ASSISTANT_TEXT_MAX) }
if (itemType === 'command_execution') {
const status = item.status ? ` - ${item.status}` : ''
return {

View File

@ -8,6 +8,8 @@ const MAX_EVENTS_PER_AGENT = 5000;
const MAX_REQUEST_META_PER_AGENT = 1000;
const HEARTBEAT_INTERVAL_MS = 30000;
const AGENT_STALE_MS = 75000;
const DEFAULT_AGENT_EVENT_TEXT_MAX = Number(process.env.AI_WORKSPACE_AGENT_EVENT_TEXT_MAX || 12000);
const CODEX_MESSAGE_EVENT_TEXT_MAX = Number(process.env.AI_WORKSPACE_CODEX_MESSAGE_EVENT_TEXT_MAX || 250000);
const ASSISTANT_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25000);
const ASSISTANT_RELAY_ACTION_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ACTION_TIMEOUT_MS || 45000);
const ASSISTANT_RELAY_MAX_PENDING = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_PENDING || 200);
@ -67,6 +69,14 @@ app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInterna
});
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions));
app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-command", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/check", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.get("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/assistant/v1/threads/:threadId/dispatch", requireInternalApi, asyncRoute(proxyAssistantControlPlane));
app.post("/api/ai-workspace/setup-codes/redeem", asyncRoute(proxySetupCodeRedeem));
app.get("/api/ai-workspace/bridge-package.tgz", asyncRoute(proxyBridgePackage));
@ -302,6 +312,33 @@ async function proxyAssistantActions(req, res) {
res.send(text);
}
async function proxyAssistantControlPlane(req, res) {
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
res.status(503).json({ ok: false, error: "assistant_control_plane_proxy_not_configured" });
return;
}
const method = String(req.method || "GET").toUpperCase();
const hasBody = !["GET", "HEAD"].includes(method);
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}${String(req.originalUrl || req.url || "")}`;
const upstream = await fetch(targetUrl, {
method,
redirect: "manual",
headers: {
Accept: "application/json",
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
...forwardOwnerHeaders(req),
...(hasBody ? { "Content-Type": "application/json" } : {}),
},
...(hasBody ? { body: JSON.stringify(req.body || {}) } : {}),
});
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
const text = await upstream.text();
res.status(upstream.status);
res.setHeader("content-type", contentType);
res.send(text);
}
async function proxySetupCodeRedeem(req, res) {
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
res.status(503).json({ ok: false, error: "assistant_setup_proxy_not_configured" });
@ -572,6 +609,8 @@ function pushAgentEvent(agent, requestId, rawEvent = {}) {
const now = new Date().toISOString();
const cleanRequestId = String(requestId || rawEvent.requestId || "");
const meta = cleanRequestId ? agent.requestMeta?.get(cleanRequestId) : null;
const kind = cleanString(rawEvent.kind || rawEvent.type || "event", 40);
const eventTextMax = kind === "codex_message" ? CODEX_MESSAGE_EVENT_TEXT_MAX : DEFAULT_AGENT_EVENT_TEXT_MAX;
const event = {
id: ++eventSeq,
pairingCode: agent.pairingCode,
@ -581,10 +620,10 @@ function pushAgentEvent(agent, requestId, rawEvent = {}) {
threadTitle: rawEvent.threadTitle == null ? cleanString(meta?.threadTitle, 240) : cleanString(rawEvent.threadTitle, 240),
userMessage: rawEvent.userMessage == null ? cleanString(meta?.userMessage, 12000) : cleanString(rawEvent.userMessage, 12000),
remoteControlMode: rawEvent.remoteControlMode == null ? cleanString(meta?.remoteControlMode, 40) : cleanString(rawEvent.remoteControlMode, 40),
kind: cleanString(rawEvent.kind || rawEvent.type || "event", 40),
kind,
stream: rawEvent.stream ? cleanString(rawEvent.stream, 40) : "",
text: rawEvent.text == null ? "" : cleanString(rawEvent.text, 12000),
message: rawEvent.message == null ? "" : cleanString(rawEvent.message, 12000),
text: rawEvent.text == null ? "" : cleanString(rawEvent.text, eventTextMax),
message: rawEvent.message == null ? "" : cleanString(rawEvent.message, eventTextMax),
command: rawEvent.command == null ? "" : cleanString(rawEvent.command, 1000),
cwd: rawEvent.cwd == null ? "" : cleanString(rawEvent.cwd, 500),
currentFile: rawEvent.currentFile == null ? "" : cleanString(rawEvent.currentFile, 1000),

View File

@ -36,6 +36,7 @@ catalog/context-bindings.json
catalog/assistant-access-policy.json
catalog/assistant-actions.json
catalog/assistant-risk-policy.json
catalog/domain-packages/*/*.json
examples/*.json
docs/baseline/*.md
docs/CONTEXT_BINDINGS.md
@ -43,6 +44,8 @@ docs/ASSISTANT_ACCESS_POLICY.md
docs/ASSISTANT_ACTION_REGISTRY.md
docs/ASSISTANT_CALLER_CONTRACT.md
docs/ASSISTANT_EXECUTION_GATEWAY.md
docs/SEO_DOMAIN_ONTOLOGY.md
docs/PROJECT_ONTOLOGY_INSTANCE_SCHEMA.md
src/catalog.mjs
src/registry.mjs
src/validate.mjs
@ -144,6 +147,22 @@ The resolver returns:
- existing context bindings;
- missing binding types required for the selected route.
## Domain Packages
Ontology Core can load domain packages from:
```text
catalog/domain-packages/*
```
Each package may contribute entities, relations, aliases, guardrails, evidence, resolver rules, context binding types, assistant access policy entries, assistant actions, and risk policy entries.
Current package:
- `seo` - NDC SEO mod domain ontology for site scans, project ontology instances, scope contracts, semantic analysis, market evidence, optimization planning, validation, changesets, and future app-owned SEO assistant actions.
The package loader merges domain packages into the base catalog before validation. Domain packages extend core meanings; they do not own app data or execute mutations.
## First Implementation Target
The first useful resolver flows are:

View File

@ -0,0 +1,58 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"aliases": [
{ "alias": "SEO mod", "canonicalId": "seo.project" },
{ "alias": "NDC SEO mod", "canonicalId": "seo.project" },
{ "alias": "seo project", "canonicalId": "seo.project" },
{ "alias": "site", "canonicalId": "seo.site" },
{ "alias": "сайт", "canonicalId": "seo.site" },
{ "alias": "source", "canonicalId": "seo.source" },
{ "alias": "scan", "canonicalId": "seo.scan_version" },
{ "alias": "scope", "canonicalId": "seo.scope_contract" },
{ "alias": "scope contract", "canonicalId": "seo.scope_contract" },
{ "alias": "page", "canonicalId": "seo.page" },
{ "alias": "страница", "canonicalId": "seo.page" },
{ "alias": "section", "canonicalId": "seo.section" },
{ "alias": "block", "canonicalId": "seo.section" },
{ "alias": "секция", "canonicalId": "seo.section" },
{ "alias": "блок", "canonicalId": "seo.section" },
{ "alias": "ProjectOntology", "canonicalId": "seo.project_ontology" },
{ "alias": "project ontology", "canonicalId": "seo.project_ontology" },
{ "alias": "semantic profile", "canonicalId": "seo.semantic_profile" },
{ "alias": "intent cluster", "canonicalId": "seo.intent_cluster" },
{ "alias": "demand universe", "canonicalId": "seo.demand_universe" },
{ "alias": "semantic anchor", "canonicalId": "seo.semantic_anchor" },
{ "alias": "смысловой якорь", "canonicalId": "seo.semantic_anchor" },
{ "alias": "anchor review", "canonicalId": "seo.anchor_review" },
{ "alias": "фиксация ключей", "canonicalId": "seo.anchor_review" },
{ "alias": "wordstat queue", "canonicalId": "seo.wordstat_queue" },
{ "alias": "очередь Wordstat", "canonicalId": "seo.wordstat_queue" },
{ "alias": "seed", "canonicalId": "seo.seed_query" },
{ "alias": "seed query", "canonicalId": "seo.seed_query" },
{ "alias": "keyword", "canonicalId": "seo.keyword" },
{ "alias": "ключ", "canonicalId": "seo.keyword" },
{ "alias": "ключевик", "canonicalId": "seo.keyword" },
{ "alias": "Wordstat signal", "canonicalId": "seo.market_signal" },
{ "alias": "market evidence", "canonicalId": "seo.market_signal" },
{ "alias": "keyword curation board", "canonicalId": "seo.keyword_curation_board" },
{ "alias": "карта распределения ключей", "canonicalId": "seo.keyword_curation_board" },
{ "alias": "keyword role", "canonicalId": "seo.keyword_role_decision" },
{ "alias": "роль ключа", "canonicalId": "seo.keyword_role_decision" },
{ "alias": "keyword map", "canonicalId": "seo.keyword_map" },
{ "alias": "landing section mapping", "canonicalId": "seo.landing_section_mapping" },
{ "alias": "маппинг посадочных", "canonicalId": "seo.landing_section_mapping" },
{ "alias": "future landing gap", "canonicalId": "seo.future_landing_gap" },
{ "alias": "будущая посадочная", "canonicalId": "seo.future_landing_gap" },
{ "alias": "optimization plan", "canonicalId": "seo.optimization_plan" },
{ "alias": "rewrite eligibility", "canonicalId": "seo.rewrite_eligibility" },
{ "alias": "semantic block", "canonicalId": "seo.semantic_block" },
{ "alias": "семантический блок", "canonicalId": "seo.semantic_block" },
{ "alias": "rewrite variant", "canonicalId": "seo.rewrite_variant" },
{ "alias": "validation result", "canonicalId": "seo.validation_result" },
{ "alias": "change set", "canonicalId": "seo.change_set" },
{ "alias": "changeset", "canonicalId": "seo.change_set" },
{ "alias": "apply result", "canonicalId": "seo.apply_result" },
{ "alias": "coverage expansion", "canonicalId": "seo.expansion_opportunity" }
]
}

View File

@ -0,0 +1,62 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"capabilities": [
{ "id": "assistant.seo_read", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can read SEO project context visible to the actor." },
{ "id": "assistant.seo_run_analysis", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can request non-destructive SEO scan, ontology extraction, semantic analysis, and evidence collection in an authorized project." },
{ "id": "assistant.seo_plan_changes", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can request anchor review, keyword curation, keyword map, optimization plan, rewrite variants, and validation previews." },
{ "id": "assistant.seo_apply_changes", "entityId": "assistant.capability", "level": "member", "summary": "Assistant can request an SEO changeset apply only through the SEO-owned adapter, confirmation, validation, and Gateway/app scopes." }
],
"roleMatrix": [
{
"roleId": "assistant_member",
"allowedCapabilities": [
"assistant.seo_read",
"assistant.seo_run_analysis",
"assistant.seo_plan_changes",
"assistant.seo_apply_changes"
],
"summary": "SEO actions are app-owned and remain bounded by SEO project permissions and Gateway scopes."
},
{
"roleId": "assistant_admin",
"allowedCapabilities": [
"assistant.seo_read",
"assistant.seo_run_analysis",
"assistant.seo_plan_changes",
"assistant.seo_apply_changes"
],
"summary": "Assistant admin does not bypass SEO project permissions; it only has the same SEO capabilities plus existing admin capabilities."
}
],
"operations": [
{
"id": "assistant.op.seo_read_project",
"capabilityId": "assistant.seo_read",
"sourceEndpoints": ["GET /seo/projects/:projectId"],
"requiredGuards": ["requireSeoProjectRead"],
"summary": "Read SEO project context through SEO-owned API."
},
{
"id": "assistant.op.seo_run_analysis",
"capabilityId": "assistant.seo_run_analysis",
"sourceEndpoints": ["POST /seo/projects/:projectId/scan", "POST /seo/projects/:projectId/analysis/semantic", "POST /seo/projects/:projectId/market/collect"],
"requiredGuards": ["requireSeoProjectWrite"],
"summary": "Run SEO scan and analysis through SEO-owned API."
},
{
"id": "assistant.op.seo_plan_changes",
"capabilityId": "assistant.seo_plan_changes",
"sourceEndpoints": ["POST /seo/projects/:projectId/anchor-review", "POST /seo/projects/:projectId/keyword-map", "POST /seo/projects/:projectId/optimization-plan", "POST /seo/projects/:projectId/rewrite-variants"],
"requiredGuards": ["requireSeoProjectWrite"],
"summary": "Prepare anchor review, curated keyword map, SEO plan and rewrite variants without mutating target source."
},
{
"id": "assistant.op.seo_apply_changes",
"capabilityId": "assistant.seo_apply_changes",
"sourceEndpoints": ["POST /seo/projects/:projectId/changesets/:changesetId/apply"],
"requiredGuards": ["requireSeoProjectApply", "requireSeoFinalValidation", "requireConfirmationToken", "requireIdempotencyKey"],
"summary": "Apply approved SEO changeset through SEO-owned adapter and safety gates."
}
]
}

View File

@ -0,0 +1,223 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"apps": [
{
"id": "seo",
"name": "NDC SEO mod",
"authorityEntityIds": ["seo.project", "seo.site", "seo.scope_contract", "seo.change_set"],
"adapterId": "adapter.seo.module_api",
"summary": "Owns SEO project source/crawl, project ontology extraction, semantic analysis, market evidence, planning, validation, changesets, and apply/export."
}
],
"actions": [
{
"id": "seo.project.read_context",
"app": "seo",
"domain": "projects",
"intentAliases": ["show seo project", "show site seo context", "покажи seo проект", "покажи сео проект", "покажи контекст сайта"],
"entityIds": ["seo.project", "seo.site", "seo.scope_contract", "seo.project_ontology"],
"capabilityIds": ["assistant.seo_read"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Read SEO project context visible to the actor."
},
{
"id": "seo.project.scan",
"app": "seo",
"domain": "scan",
"intentAliases": ["scan seo project", "scan site", "run seo scan", "просканируй сайт", "запусти seo scan", "запусти скан сайта"],
"entityIds": ["seo.project", "seo.source", "seo.scan_version"],
"capabilityIds": ["assistant.seo_run_analysis"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_run_scan"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Run SEO project scan and create a new scan_version artifact."
},
{
"id": "seo.ontology.extract",
"app": "seo",
"domain": "ontology",
"intentAliases": ["extract project ontology", "build site ontology", "определи онтологию сайта", "собери онтологию проекта"],
"entityIds": ["seo.project", "seo.project_ontology", "seo.semantic_profile"],
"capabilityIds": ["assistant.seo_run_analysis"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_run_analysis"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Extract per-project site ontology from approved source/scope evidence."
},
{
"id": "seo.semantic.analyze",
"app": "seo",
"domain": "semantic",
"intentAliases": ["run semantic analysis", "analyze seo semantics", "проанализируй семантику", "запусти semantic analysis"],
"entityIds": ["seo.semantic_profile", "seo.intent_cluster", "seo.demand_universe", "seo.semantic_anchor"],
"capabilityIds": ["assistant.seo_run_analysis"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_run_analysis"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Run semantic analysis bounded by ProjectOntologyInstance and scope contract."
},
{
"id": "seo.anchor_review.approve",
"app": "seo",
"domain": "market",
"intentAliases": ["approve seo anchors", "approve wordstat anchors", "зафиксируй ключи", "подтверди якоря", "зафиксируй смысловые якоря"],
"entityIds": ["seo.semantic_anchor", "seo.anchor_review", "seo.wordstat_queue"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_collect_market_evidence"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Create or update the human-approved anchor review and Wordstat queue before provider collection."
},
{
"id": "seo.market.collect_wordstat",
"app": "seo",
"domain": "market",
"intentAliases": ["collect wordstat", "run wordstat", "собери wordstat", "собери частотности", "запусти вордстат"],
"entityIds": ["seo.anchor_review", "seo.wordstat_queue", "seo.seed_query", "seo.market_signal", "seo.keyword"],
"capabilityIds": ["assistant.seo_run_analysis"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_collect_market_evidence"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Collect Wordstat market evidence only for the human-approved Wordstat queue."
},
{
"id": "seo.keyword.curate",
"app": "seo",
"domain": "keywords",
"intentAliases": ["curate keywords", "sort keyword board", "распредели вес ключей", "разложи ключи", "отсортируй ключи"],
"entityIds": ["seo.keyword", "seo.keyword_curation_board", "seo.keyword_role_decision"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_plan_changes"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Pre-sort external keyword candidates into unresolved, landing core, supporting core, differentiators, or long tail lanes for human review."
},
{
"id": "seo.keyword.map",
"app": "seo",
"domain": "keywords",
"intentAliases": ["build keyword map", "map keywords", "собери keyword map", "распредели ключи", "составь карту ключей"],
"entityIds": ["seo.keyword", "seo.keyword_curation_board", "seo.keyword_role_decision", "seo.keyword_map", "seo.scope_item"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_plan_changes"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Build keyword map from curated keyword role decisions and approved scope items."
},
{
"id": "seo.mapping.approve",
"app": "seo",
"domain": "plan",
"intentAliases": ["approve landing mapping", "approve section mapping", "утверди посадочные", "утверди маппинг секций"],
"entityIds": ["seo.keyword_map", "seo.landing_section_mapping", "seo.future_landing_gap", "seo.scope_item"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_plan_changes"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Approve keyword landing/section mapping and keep future landing gaps non-blocking until explicit promotion."
},
{
"id": "seo.plan.build",
"app": "seo",
"domain": "plan",
"intentAliases": ["build optimization plan", "prepare seo plan", "собери план оптимизации", "подготовь seo план"],
"entityIds": ["seo.keyword_map", "seo.landing_section_mapping", "seo.future_landing_gap", "seo.optimization_plan", "seo.scope_contract"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_plan_changes"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Build SEO optimization plan before rewrite."
},
{
"id": "seo.rewrite.propose",
"app": "seo",
"domain": "rewrite",
"intentAliases": ["propose rewrite variants", "generate seo variants", "предложи варианты текста", "сгенерируй rewrite variants"],
"entityIds": ["seo.optimization_plan", "seo.rewrite_eligibility", "seo.semantic_block", "seo.rewrite_variant"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_plan_changes"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Generate rewrite variants into working draft only."
},
{
"id": "seo.validation.run",
"app": "seo",
"domain": "validation",
"intentAliases": ["run seo validation", "validate seo changes", "проверь seo изменения", "запусти финальную проверку"],
"entityIds": ["seo.validation_result", "seo.change_set", "seo.rewrite_variant"],
"capabilityIds": ["assistant.seo_plan_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_validate_changes"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Run final SEO validation before changeset apply/export."
},
{
"id": "seo.change_set.preview",
"app": "seo",
"domain": "changesets",
"intentAliases": ["preview seo changeset", "show seo diff", "покажи seo diff", "покажи changeset"],
"entityIds": ["seo.change_set", "seo.validation_result"],
"capabilityIds": ["assistant.seo_read"],
"riskLevel": "read",
"confirmationMode": "none",
"selfActionPolicy": "allowed",
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Preview SEO changeset and validation state without applying changes."
},
{
"id": "seo.change_set.apply",
"app": "seo",
"domain": "changesets",
"intentAliases": ["apply seo changes", "apply seo changeset", "примени seo изменения", "примени changeset"],
"entityIds": ["seo.change_set", "seo.validation_result", "seo.apply_result"],
"capabilityIds": ["assistant.seo_apply_changes"],
"riskLevel": "write",
"confirmationMode": "explicit",
"selfActionPolicy": "allowed",
"requiredScopes": ["seo.project.can_apply_change_set"],
"adapterId": "adapter.seo.module_api",
"adapterStatus": "future",
"summary": "Apply an approved SEO changeset through SEO-owned adapter after validation, backup, idempotency, and explicit confirmation."
}
]
}

View File

@ -0,0 +1,35 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-28",
"contexts": [],
"bindingTypes": [
{
"id": "binding_type.seo_project_to_ops_context",
"relationId": "ontology.resolves_seo_project_to_ops_context",
"fromEntityIds": ["seo.project"],
"toEntityIds": ["ops.project", "ops.card"],
"requiredFromRefs": ["seo_project_id"],
"requiredToRefs": ["workspace_slug", "project_id"],
"summary": "Binds an SEO project to its OPS Product project/card context."
},
{
"id": "binding_type.ops_context_to_seo_project",
"relationId": "ontology.resolves_ops_context_to_seo_project",
"fromEntityIds": ["ops.project", "ops.card"],
"toEntityIds": ["seo.project"],
"requiredFromRefs": ["workspace_slug", "project_id"],
"requiredToRefs": ["seo_project_id"],
"summary": "Binds OPS Product context to an SEO project."
},
{
"id": "binding_type.hub_app_to_seo_project",
"relationId": "ontology.resolves_hub_app_to_seo_project",
"fromEntityIds": ["hub.application"],
"toEntityIds": ["seo.project"],
"requiredFromRefs": ["application_id"],
"requiredToRefs": ["seo_project_id"],
"summary": "Binds a HUB/Launcher app entry to SEO module project context."
}
],
"bindings": []
}

View File

@ -0,0 +1,38 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"entities": [
{ "id": "seo.project", "name": "SEO Project", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "SEO module project boundary for one connected site/source and its analysis history." },
{ "id": "seo.site", "name": "SEO Site", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Analyzed website as seen through source import and/or live crawl." },
{ "id": "seo.source", "name": "SEO Source", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Input source: local folder, uploaded package, browser-folder import, repository, or live URL crawl." },
{ "id": "seo.scan_version", "name": "SEO Scan Version", "surface": "seo", "status": ["source-evidenced"], "authority": "NDC SEO mod", "summary": "Immutable scan boundary for project index, pages, media, and derived scope." },
{ "id": "seo.scope_contract", "name": "SEO Scope Contract", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "User-approved working scope consumed consistently by analysis, planning, rewrite, validation, and apply." },
{ "id": "seo.scope_item", "name": "SEO Scope Item", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "One selectable work zone: URL page, generated section, template block, admin page, technical page, content source, or hidden section." },
{ "id": "seo.page", "name": "SEO Page", "surface": "seo", "status": ["source-evidenced"], "authority": "NDC SEO mod", "summary": "Logical page or route-level SEO target." },
{ "id": "seo.section", "name": "SEO Section", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Stable section/block identity under a page or template, not a fragile DOM selector." },
{ "id": "seo.media_asset", "name": "SEO Media Asset", "surface": "seo", "status": ["source-evidenced"], "authority": "NDC SEO mod", "summary": "Image, video, poster, picture/srcset source, CSS background, or other media target with SEO metadata." },
{ "id": "seo.project_ontology", "name": "SEO Project Ontology Instance", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + Ontology Core", "summary": "Versioned per-project site ontology extracted from evidence and aligned with Ontology Core." },
{ "id": "seo.semantic_profile", "name": "SEO Semantic Profile", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Structured meaning, tone, entities, intents, gaps, and constraints extracted from selected scope." },
{ "id": "seo.intent_cluster", "name": "SEO Intent Cluster", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Search/user intent cluster grounded in project ontology and site evidence." },
{ "id": "seo.demand_universe", "name": "SEO Demand Universe", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + model provider", "summary": "Broad pre-Wordstat meaning and query hypothesis set derived from project context before external evidence collection." },
{ "id": "seo.semantic_anchor", "name": "SEO Semantic Anchor", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + model provider", "summary": "Human-reviewable meaning anchor extracted from project context and used as the only allowed route into Wordstat/SERP queues." },
{ "id": "seo.anchor_review", "name": "SEO Anchor Review", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Human approval gate that decides which semantic anchors may be sent to external market evidence providers." },
{ "id": "seo.wordstat_queue", "name": "SEO Wordstat Queue", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Explicit queue of human-approved anchor phrases sent to Wordstat evidence collection." },
{ "id": "seo.seed_query", "name": "SEO Seed Query", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Candidate phrase sent to market evidence providers after user/model review." },
{ "id": "seo.keyword", "name": "SEO Keyword", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Cleaned and classified search phrase with project-level decision state." },
{ "id": "seo.market_signal", "name": "SEO Market Signal", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + external providers", "summary": "Wordstat, SERP, Webmaster, Metrica, or model evidence attached to project-level decisions." },
{ "id": "seo.keyword_curation_board", "name": "SEO Keyword Curation Board", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Human-in-the-loop board where external keyword candidates are sorted into SEO roles before strategy." },
{ "id": "seo.keyword_role_decision", "name": "SEO Keyword Role Decision", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "User/model-assisted role assignment for a keyword: unresolved, landing core, supporting core, differentiator, or long tail." },
{ "id": "seo.keyword_map", "name": "SEO Keyword Map", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Approved mapping from keywords to pages/sections with roles and limits." },
{ "id": "seo.landing_section_mapping", "name": "SEO Landing Section Mapping", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Human-approved mapping from curated keyword groups to existing pages, sections, or future landing gaps." },
{ "id": "seo.future_landing_gap", "name": "SEO Future Landing Gap", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Non-blocking strategy gap for a potential future landing that must not be materialized or rewritten without explicit promotion." },
{ "id": "seo.optimization_plan", "name": "SEO Optimization Plan", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Approved plan of text/meta/media/schema tasks before rewrite." },
{ "id": "seo.rewrite_eligibility", "name": "SEO Rewrite Eligibility", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Gate proving that a rewrite target is an approved existing page/section/content-field binding rather than a future gap." },
{ "id": "seo.semantic_block", "name": "SEO Semantic Block", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Persisted semantic grouping of page content targets used for block-level rewrite proposals." },
{ "id": "seo.rewrite_variant", "name": "SEO Rewrite Variant", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod + model provider", "summary": "Generated optimization variant over working text, not a direct source mutation." },
{ "id": "seo.validation_result", "name": "SEO Validation Result", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Final validation output: deterministic checks, over-SEO, language quality, media checks, and hard/soft issues." },
{ "id": "seo.change_set", "name": "SEO Change Set", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Explicit diff/apply unit with dry-run, backup, rollback, and export boundaries." },
{ "id": "seo.apply_result", "name": "SEO Apply Result", "surface": "seo", "status": ["product-required"], "authority": "NDC SEO mod", "summary": "Result of app-owned apply/export action after confirmation and validation." },
{ "id": "seo.expansion_opportunity", "name": "SEO Expansion Opportunity", "surface": "seo", "status": ["future-concept"], "authority": "NDC SEO mod", "summary": "Optional future coverage/growth suggestion after core analysis and market evidence, not part of the current ontology slice." }
]
}

View File

@ -0,0 +1,39 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"sourceRoots": [
{
"surface": "seo",
"path": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SEO/seo_mode/seo_mode",
"mode": "read-only architecture/source inspection"
}
],
"ledgers": [
{
"id": "ledger.seo_domain_v0",
"path": "docs/SEO_DOMAIN_ONTOLOGY.md",
"entityIds": [
"seo.project",
"seo.scope_contract",
"seo.project_ontology",
"seo.semantic_profile",
"seo.semantic_anchor",
"seo.anchor_review",
"seo.wordstat_queue",
"seo.market_signal",
"seo.keyword_curation_board",
"seo.landing_section_mapping",
"seo.future_landing_gap",
"seo.change_set"
]
}
],
"baselineDocs": [
"docs/SEO_DOMAIN_ONTOLOGY.md"
],
"restrictions": [
"Do not promote per-project SEO keywords or market rows into global ontology truth.",
"Do not let Ontology Core execute SEO source mutations.",
"Do not implement optional business expansion before core SEO pipeline evidence is proven."
]
}

View File

@ -0,0 +1,77 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"rules": [
{
"id": "guardrail.seo.project_ontology_not_global_truth",
"severity": "error",
"summary": "Per-project SEO ontology is versioned project evidence; it must not be silently promoted into global Ontology Core truth.",
"entityIds": ["seo.project_ontology", "future.domain_package"]
},
{
"id": "guardrail.seo.keywords_are_project_evidence",
"severity": "error",
"summary": "Keywords, Wordstat rows, SERP rows, and forecasts are project evidence, not reusable ontology roots.",
"entityIds": ["seo.keyword", "seo.market_signal", "seo.project_ontology"]
},
{
"id": "guardrail.seo.scope_contract_required",
"severity": "error",
"summary": "Semantic analysis, keyword map, rewrite, validation, and changeset must be bounded by an approved scope contract.",
"entityIds": ["seo.scope_contract", "seo.scope_item", "seo.semantic_profile", "seo.change_set"]
},
{
"id": "guardrail.seo.wordstat_requires_anchor_review",
"severity": "error",
"summary": "Wordstat/SERP collection must use only human-approved semantic anchors from anchor review, never raw site text or model auto proposals.",
"entityIds": ["seo.semantic_anchor", "seo.anchor_review", "seo.wordstat_queue", "seo.market_signal"]
},
{
"id": "guardrail.seo.strategy_requires_curated_keywords",
"severity": "error",
"summary": "Strategy and optimization plan must consume curated keyword role decisions, not raw provider rows or unreviewed normalization output.",
"entityIds": ["seo.keyword_curation_board", "seo.keyword_role_decision", "seo.keyword_map", "seo.optimization_plan"]
},
{
"id": "guardrail.seo.future_landing_gap_non_blocking",
"severity": "error",
"summary": "Future landing gaps are strategy context only; they must not force materialization, rewrite, or random existing page selection without explicit promotion.",
"entityIds": ["seo.future_landing_gap", "seo.landing_section_mapping", "seo.rewrite_eligibility", "seo.rewrite_variant"]
},
{
"id": "guardrail.seo.rewrite_requires_existing_binding",
"severity": "error",
"summary": "Rewrite variants require approved existing page/section/content-field bindings and must not be generated for unresolved future gaps.",
"entityIds": ["seo.rewrite_eligibility", "seo.semantic_block", "seo.section", "seo.rewrite_variant"]
},
{
"id": "guardrail.seo.apply_requires_validation",
"severity": "error",
"summary": "SEO apply/export requires explicit changeset, final validation, dry-run/backup boundary, and app-owned adapter execution.",
"entityIds": ["seo.change_set", "seo.validation_result", "seo.apply_result", "assistant.action"]
},
{
"id": "guardrail.seo.ontology_core_does_not_apply",
"severity": "error",
"summary": "Ontology Core may resolve SEO action context but must not mutate site sources or execute SEO apply.",
"entityIds": ["seo.change_set", "assistant.action", "agent.scope", "agent.audit_event"]
},
{
"id": "guardrail.seo.expansion_is_future_optional_branch",
"severity": "warning",
"summary": "Expansion opportunities are a future optional branch after core analysis and market evidence; they must not replace current site context by default.",
"entityIds": ["seo.expansion_opportunity", "seo.market_signal", "seo.optimization_plan"]
}
],
"blockedConflations": [
["seo.project_ontology", "future.domain_package"],
["seo.keyword", "future.domain_package"],
["seo.semantic_anchor", "seo.market_signal"],
["seo.anchor_review", "seo.market_signal"],
["seo.market_signal", "seo.intent_cluster"],
["seo.keyword_curation_board", "seo.keyword_map"],
["seo.future_landing_gap", "seo.page"],
["seo.expansion_opportunity", "seo.optimization_plan"],
["seo.change_set", "assistant.action"]
]
}

View File

@ -0,0 +1,7 @@
{
"id": "seo",
"version": "0.1.0",
"updatedAt": "2026-06-28",
"status": "product-required/pending-source-pass",
"summary": "SEO module domain ontology package for site scans, project ontology, scope contracts, semantic analysis, market evidence, optimization plans, validation, and changesets."
}

View File

@ -0,0 +1,200 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://nodedc.local/ontology/seo/project-ontology.schema.json",
"title": "SEO Project Ontology Instance",
"type": "object",
"required": [
"schemaVersion",
"projectId",
"siteId",
"coreOntologyVersion",
"seoDomainPackageVersion",
"source",
"scope",
"brand",
"pages",
"sections",
"intentClusters",
"tone",
"constraints",
"evidence",
"approvals"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "seo-project-ontology.v1" },
"projectId": { "type": "string", "minLength": 1 },
"siteId": { "type": "string", "minLength": 1 },
"coreOntologyVersion": { "type": "string", "minLength": 1 },
"seoDomainPackageVersion": { "type": "string", "minLength": 1 },
"source": {
"type": "object",
"required": ["sourceType", "scanVersionId"],
"additionalProperties": false,
"properties": {
"sourceType": {
"type": "string",
"enum": ["local_folder", "browser_folder", "uploaded_package", "repository", "live_crawl"]
},
"scanVersionId": { "type": "string", "minLength": 1 },
"rootUrl": { "type": ["string", "null"] },
"sourcePath": { "type": ["string", "null"] }
}
},
"scope": {
"type": "object",
"required": ["scopeContractId", "scopeItemIds"],
"additionalProperties": false,
"properties": {
"scopeContractId": { "type": "string", "minLength": 1 },
"scopeItemIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
}
},
"brand": {
"type": "object",
"required": ["name", "aliases", "description"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1 },
"aliases": { "type": "array", "items": { "type": "string" } },
"description": { "type": "string" }
}
},
"offers": {
"type": "array",
"items": { "$ref": "#/$defs/namedEvidenceItem" }
},
"audiences": {
"type": "array",
"items": { "$ref": "#/$defs/namedEvidenceItem" }
},
"pages": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "url", "title", "evidenceIds"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"url": { "type": "string" },
"title": { "type": "string" },
"evidenceIds": { "$ref": "#/$defs/evidenceIds" }
}
}
},
"sections": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "pageId", "title", "meaning", "evidenceIds"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"pageId": { "type": "string", "minLength": 1 },
"title": { "type": "string" },
"meaning": { "type": "string" },
"evidenceIds": { "$ref": "#/$defs/evidenceIds" }
}
}
},
"intentClusters": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "title", "priority", "targetIntent", "seedQueries", "evidenceIds"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"priority": { "type": "string", "enum": ["high", "medium", "low"] },
"targetIntent": { "type": "string" },
"seedQueries": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"evidenceIds": { "$ref": "#/$defs/evidenceIds" }
}
}
},
"tone": {
"type": "object",
"required": ["language", "signals", "forbiddenShifts"],
"additionalProperties": false,
"properties": {
"language": { "type": "string" },
"signals": { "type": "array", "items": { "type": "string" } },
"forbiddenShifts": { "type": "array", "items": { "type": "string" } }
}
},
"constraints": {
"type": "object",
"required": ["forbiddenTopics", "forbiddenClaims", "mustKeep"],
"additionalProperties": false,
"properties": {
"forbiddenTopics": { "type": "array", "items": { "type": "string" } },
"forbiddenClaims": { "type": "array", "items": { "type": "string" } },
"mustKeep": { "type": "array", "items": { "type": "string" } }
}
},
"evidence": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "sourceType", "ref", "summary"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"sourceType": { "type": "string" },
"ref": { "type": "string" },
"summary": { "type": "string" }
}
}
},
"approvals": {
"type": "object",
"required": ["status", "approvedBy", "approvedAt"],
"additionalProperties": false,
"properties": {
"status": { "type": "string", "enum": ["draft", "approved", "rejected", "needs_review"] },
"approvedBy": { "type": ["string", "null"] },
"approvedAt": { "type": ["string", "null"] }
}
},
"ontologyDeltaProposals": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "target", "summary", "status"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"target": { "type": "string", "enum": ["core", "seo_domain", "project_only"] },
"summary": { "type": "string" },
"status": { "type": "string", "enum": ["proposed", "accepted", "rejected", "deferred"] }
}
}
}
},
"$defs": {
"evidenceIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"namedEvidenceItem": {
"type": "object",
"required": ["id", "name", "summary", "evidenceIds"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"summary": { "type": "string" },
"evidenceIds": { "$ref": "#/$defs/evidenceIds" }
}
}
}
}

View File

@ -0,0 +1,47 @@
{
"version": "0.1.0",
"updatedAt": "2026-07-02",
"relations": [
{ "id": "seo.project.has_site", "from": ["seo.project"], "to": ["seo.site"], "status": "product-required", "summary": "SEO project analyzes one or more site contexts." },
{ "id": "seo.project.uses_source", "from": ["seo.project"], "to": ["seo.source"], "status": "product-required", "summary": "SEO project is connected to import/crawl source." },
{ "id": "seo.project.has_project_ontology", "from": ["seo.project"], "to": ["seo.project_ontology"], "status": "product-required", "summary": "Per-project ontology profile belongs to SEO project." },
{ "id": "seo.source.produces_scan_version", "from": ["seo.source"], "to": ["seo.scan_version"], "status": "product-required", "summary": "Source import/crawl creates immutable scan version." },
{ "id": "seo.scan_version.produces_scope_contract", "from": ["seo.scan_version"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Scan and user configuration produce approved scope contract." },
{ "id": "seo.scope_contract.contains_scope_item", "from": ["seo.scope_contract"], "to": ["seo.scope_item"], "status": "product-required", "summary": "Scope contract is composed of selected work zones." },
{ "id": "seo.scope_item.targets_page", "from": ["seo.scope_item"], "to": ["seo.page"], "status": "product-required", "summary": "Scope item can target a logical page." },
{ "id": "seo.scope_item.targets_section", "from": ["seo.scope_item"], "to": ["seo.section"], "status": "product-required", "summary": "Scope item can target a stable section/block." },
{ "id": "seo.site.has_page", "from": ["seo.site"], "to": ["seo.page"], "status": "product-required", "summary": "Site contains logical pages/routes." },
{ "id": "seo.page.has_section", "from": ["seo.page"], "to": ["seo.section"], "status": "product-required", "summary": "Page can contain stable sections/blocks." },
{ "id": "seo.section.contains_media", "from": ["seo.section"], "to": ["seo.media_asset"], "status": "product-required", "summary": "Media is attached to a section when context can be resolved." },
{ "id": "seo.semantic_profile.uses_project_ontology", "from": ["seo.semantic_profile"], "to": ["seo.project_ontology"], "status": "product-required", "summary": "Semantic analysis is grounded in per-project ontology." },
{ "id": "seo.semantic_profile.describes_scope_contract", "from": ["seo.semantic_profile"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Semantic profile describes selected work scope, not arbitrary site fantasy." },
{ "id": "seo.semantic_profile.produces_demand_universe", "from": ["seo.semantic_profile"], "to": ["seo.demand_universe"], "status": "product-required", "summary": "Selected project context produces a broad demand universe before provider calls." },
{ "id": "seo.demand_universe.contains_semantic_anchor", "from": ["seo.demand_universe"], "to": ["seo.semantic_anchor"], "status": "product-required", "summary": "Demand universe contains semantic anchors that can be reviewed for market routing." },
{ "id": "seo.semantic_anchor.requires_anchor_review", "from": ["seo.semantic_anchor"], "to": ["seo.anchor_review"], "status": "product-required", "summary": "A semantic anchor cannot enter Wordstat/SERP routing before human anchor review." },
{ "id": "seo.anchor_review.approves_wordstat_queue", "from": ["seo.anchor_review"], "to": ["seo.wordstat_queue"], "status": "product-required", "summary": "Anchor review creates the explicit Wordstat queue from approved anchors." },
{ "id": "seo.intent_cluster.suggests_seed_query", "from": ["seo.intent_cluster"], "to": ["seo.seed_query"], "status": "product-required", "summary": "Intent cluster can suggest seed queries for evidence collection." },
{ "id": "seo.wordstat_queue.contains_seed_query", "from": ["seo.wordstat_queue"], "to": ["seo.seed_query"], "status": "product-required", "summary": "Only human-approved Wordstat queue items become executable seed queries." },
{ "id": "seo.wordstat_queue.produces_market_signal", "from": ["seo.wordstat_queue"], "to": ["seo.market_signal"], "status": "product-required", "summary": "Wordstat collection stores provider results as market signals tied to the approved queue." },
{ "id": "seo.seed_query.expands_to_keyword", "from": ["seo.seed_query"], "to": ["seo.keyword"], "status": "product-required", "summary": "Approved seed query can produce cleaned keyword candidates." },
{ "id": "seo.keyword.validated_by_market_signal", "from": ["seo.keyword"], "to": ["seo.market_signal"], "status": "product-required", "summary": "Keyword decisions require market evidence when available." },
{ "id": "seo.keyword_curation_board.classifies_keyword", "from": ["seo.keyword_curation_board"], "to": ["seo.keyword_role_decision", "seo.keyword"], "status": "product-required", "summary": "Keyword curation board assigns human-reviewed roles to keyword candidates." },
{ "id": "seo.keyword_role_decision.produces_keyword_map", "from": ["seo.keyword_role_decision"], "to": ["seo.keyword_map"], "status": "product-required", "summary": "Only curated keyword role decisions can feed the keyword map used by strategy." },
{ "id": "seo.keyword_map.assigns_keyword", "from": ["seo.keyword_map"], "to": ["seo.keyword", "seo.scope_item"], "status": "product-required", "summary": "Keyword map assigns keywords to approved scope items." },
{ "id": "seo.keyword_map.requires_landing_section_mapping", "from": ["seo.keyword_map"], "to": ["seo.landing_section_mapping"], "status": "product-required", "summary": "Keyword map must be resolved to approved existing pages/sections or explicit future gaps before rewrite." },
{ "id": "seo.landing_section_mapping.maps_keyword_to_scope_item", "from": ["seo.landing_section_mapping"], "to": ["seo.keyword", "seo.scope_item"], "status": "product-required", "summary": "Landing/section mapping binds curated keywords to existing approved scope items." },
{ "id": "seo.landing_section_mapping.identifies_future_gap", "from": ["seo.landing_section_mapping"], "to": ["seo.future_landing_gap"], "status": "product-required", "summary": "A mapping can explicitly mark a keyword group as a non-blocking future landing gap." },
{ "id": "seo.optimization_plan.uses_keyword_map", "from": ["seo.optimization_plan"], "to": ["seo.keyword_map"], "status": "product-required", "summary": "Optimization plan is based on approved keyword map." },
{ "id": "seo.optimization_plan.targets_scope_contract", "from": ["seo.optimization_plan"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Optimization plan targets approved scope contract." },
{ "id": "seo.rewrite_eligibility.requires_landing_section_mapping", "from": ["seo.rewrite_eligibility"], "to": ["seo.landing_section_mapping"], "status": "product-required", "summary": "Rewrite eligibility requires an approved mapping to an existing source-backed target." },
{ "id": "seo.semantic_block.groups_section", "from": ["seo.semantic_block"], "to": ["seo.section", "seo.scope_item"], "status": "product-required", "summary": "Semantic blocks group source-backed sections/scope items for block-level rewrite proposals." },
{ "id": "seo.rewrite_variant.requires_rewrite_eligibility", "from": ["seo.rewrite_variant"], "to": ["seo.rewrite_eligibility", "seo.semantic_block"], "status": "product-required", "summary": "Rewrite variants can be proposed only for eligible existing bindings, optionally grouped by semantic block." },
{ "id": "seo.rewrite_variant.generated_from_plan", "from": ["seo.rewrite_variant"], "to": ["seo.optimization_plan"], "status": "product-required", "summary": "Rewrite variant is generated from approved optimization plan." },
{ "id": "seo.validation_result.validates_change_set", "from": ["seo.validation_result"], "to": ["seo.change_set"], "status": "product-required", "summary": "Change set must be validated before apply." },
{ "id": "seo.change_set.applies_to_scope_contract", "from": ["seo.change_set"], "to": ["seo.scope_contract"], "status": "product-required", "summary": "Change set is bounded by approved scope contract." },
{ "id": "seo.change_set.produces_apply_result", "from": ["seo.change_set"], "to": ["seo.apply_result"], "status": "product-required", "summary": "Apply/export produces a traceable result." },
{ "id": "seo.expansion_opportunity.requires_market_signal", "from": ["seo.expansion_opportunity"], "to": ["seo.market_signal"], "status": "future-concept", "summary": "Future expansion proposals must be grounded in market evidence." },
{ "id": "ontology.resolves_seo_project_to_ops_context", "from": ["seo.project"], "to": ["ops.project", "ops.card"], "status": "product-required", "summary": "Assistant can route SEO project work to the correct OPS Product context." },
{ "id": "ontology.resolves_ops_context_to_seo_project", "from": ["ops.project", "ops.card"], "to": ["seo.project"], "status": "product-required", "summary": "Assistant can route OPS Product context into SEO module project context when bound." },
{ "id": "ontology.resolves_hub_app_to_seo_project", "from": ["hub.application"], "to": ["seo.project"], "status": "product-required", "summary": "Launcher/HUB application context can suggest SEO module project context." }
]
}

View File

@ -0,0 +1,42 @@
{
"version": "0.1.0",
"updatedAt": "2026-06-28",
"rules": [
{
"id": "resolver.seo_to_ops.project_context",
"relationId": "ontology.resolves_seo_project_to_ops_context",
"fromSurface": "seo",
"inputEntityIds": ["seo.project", "seo.site", "seo.scope_contract"],
"intentHints": ["ops", "card", "task", "issue", "project", "report", "карточ", "задач", "отчет"],
"outputSurface": "ops",
"outputEntityIds": ["ops.workspace", "ops.project", "ops.card", "ops.card_type"],
"requiredBindings": ["seo.project -> ops.project|ops.card context"],
"status": "product-required",
"summary": "Route SEO module project context into OPS Product work context."
},
{
"id": "resolver.ops_to_seo.project_context",
"relationId": "ontology.resolves_ops_context_to_seo_project",
"fromSurface": "ops",
"inputEntityIds": ["ops.project", "ops.card"],
"intentHints": ["seo", "site", "semantic", "wordstat", "keyword", "rewrite", "validation", "сео", "сайт", "семантик"],
"outputSurface": "seo",
"outputEntityIds": ["seo.project", "seo.site", "seo.scope_contract", "seo.project_ontology"],
"requiredBindings": ["ops.project|ops.card -> seo.project context"],
"status": "product-required",
"summary": "Route OPS Product context into SEO module project context."
},
{
"id": "resolver.hub_app_to_seo.project_context",
"relationId": "ontology.resolves_hub_app_to_seo_project",
"fromSurface": "hub",
"inputEntityIds": ["hub.application"],
"intentHints": ["seo", "site", "project", "audit", "сео", "сайт", "аудит"],
"outputSurface": "seo",
"outputEntityIds": ["seo.project", "seo.site"],
"requiredBindings": ["hub.application -> seo.project context"],
"status": "product-required",
"summary": "Route Launcher app context into SEO module project context."
}
]
}

View File

@ -0,0 +1,60 @@
# Project Ontology Instance Schema
Status: first contract
Date: 2026-06-28
Owner: NDC SEO mod
## Intent
`ProjectOntologyInstance` is the per-project/site ontology profile consumed by NDC SEO mod.
It is not the global ontology.
It gives the model enough grounded context to analyze and rewrite the current site without
injecting unrelated offers, markets, or product claims.
## Required Guarantees
- Every extracted entity, intent, tone rule, and constraint must have evidence or explicit user approval.
- Unknown entities are allowed but must stay local to the project until promoted.
- Market evidence is linked as project evidence, not ontology truth.
- Rewrite and apply stages must only consume approved scope items.
- Optional expansion proposals are not part of the default optimization path.
## JSON Fields
Top level:
- `schemaVersion`
- `projectId`
- `siteId`
- `coreOntologyVersion`
- `seoDomainPackageVersion`
- `source`
- `scope`
- `brand`
- `offers`
- `audiences`
- `pages`
- `sections`
- `intentClusters`
- `tone`
- `constraints`
- `evidence`
- `approvals`
- `ontologyDeltaProposals`
## Lifecycle
1. Source import or live crawl creates `scan_version`.
2. User-selected graph/scope creates `scope_contract`.
3. Extractor creates a draft `ProjectOntologyInstance`.
4. Dev Mode shows evidence, confidence, and unresolved questions.
5. User/model approvals produce an approved version.
6. Semantic analysis consumes approved ontology plus scope contract.
7. Market evidence, keyword map, plan, rewrite, validation, and changeset link back to ontology version.
## NODE.DC Seed
The NODE.DC project ontology seed should be kept as a fixture and regression target.
It must not stay embedded inside generic semantic analysis code.

View File

@ -0,0 +1,202 @@
# SEO Domain Ontology
Status: Stage 5 architecture slice
Date: 2026-07-02
Package: `catalog/domain-packages/seo`
## Purpose
SEO Domain Ontology defines the reusable platform vocabulary for NDC SEO mod.
It must not replace per-project evidence and must not execute SEO mutations.
The package exists so the SEO module can participate in NodeDC-wide assistant routing
without hardcoding NODE.DC-specific product clusters into the generic SEO engine.
## Ownership Boundaries
Ontology Core owns:
- canonical SEO entity/action names;
- aliases;
- relations;
- guardrails;
- assistant action cards;
- resolver rules and binding types.
NDC SEO mod owns:
- project source import and live crawl;
- scan versions;
- project ontology extraction;
- semantic analysis;
- Wordstat/SERP/Webmaster/Metrica evidence collection;
- keyword decisions;
- optimization plans;
- rewrite variants;
- final validation;
- changeset/apply/export execution.
OPS Gateway and app-owned adapters own:
- permission enforcement;
- scopes/grants;
- confirmation;
- idempotency;
- audit.
## Three-Layer Model
`CoreOntologySnapshot`:
- stable platform meanings from Ontology Core;
- HUB, OPS Product, OPS Gateway, ENGINE, Assistant, and Future Interface Layer concepts;
- SEO package-level concepts after validation.
`SeoDomainOntology`:
- reusable `seo.*` entity/action/guardrail catalog;
- not tied to one site;
- ships with Ontology Core as a domain package.
`ProjectOntologyInstance`:
- versioned project/site evidence;
- extracted from source/crawl/content and user approvals;
- contains brand, offers, audiences, pages, sections, tone, constraints, entities, intents, and evidence refs;
- can propose ontology deltas but cannot silently mutate Ontology Core.
## Current SEO Entities
The first package slice defines:
- `seo.project`
- `seo.site`
- `seo.source`
- `seo.scan_version`
- `seo.scope_contract`
- `seo.scope_item`
- `seo.page`
- `seo.section`
- `seo.media_asset`
- `seo.project_ontology`
- `seo.semantic_profile`
- `seo.intent_cluster`
- `seo.demand_universe`
- `seo.semantic_anchor`
- `seo.anchor_review`
- `seo.wordstat_queue`
- `seo.seed_query`
- `seo.keyword`
- `seo.market_signal`
- `seo.keyword_curation_board`
- `seo.keyword_role_decision`
- `seo.keyword_map`
- `seo.landing_section_mapping`
- `seo.future_landing_gap`
- `seo.optimization_plan`
- `seo.rewrite_eligibility`
- `seo.semantic_block`
- `seo.rewrite_variant`
- `seo.validation_result`
- `seo.change_set`
- `seo.apply_result`
- `seo.expansion_opportunity`
`seo.expansion_opportunity` is deliberately marked `future-concept`.
It exists only to reserve the boundary for a later optional business/coverage branch.
## Assistant Actions
SEO assistant actions are registered as declarative future app-owned actions:
- `seo.project.read_context`
- `seo.project.scan`
- `seo.ontology.extract`
- `seo.semantic.analyze`
- `seo.anchor_review.approve`
- `seo.market.collect_wordstat`
- `seo.keyword.curate`
- `seo.keyword.map`
- `seo.mapping.approve`
- `seo.plan.build`
- `seo.rewrite.propose`
- `seo.validation.run`
- `seo.change_set.preview`
- `seo.change_set.apply`
All actions route to `adapter.seo.module_api` and currently use `adapterStatus: future`.
This is intentional: Ontology Core can resolve and classify intent before the SEO adapter exists,
but it must not pretend that live execution is ready.
## Guardrails
Hard rules:
- per-project SEO ontology is evidence, not global truth;
- keywords, Wordstat rows, SERP rows, and forecasts stay project evidence;
- semantic analysis, keyword map, rewrite, validation, and changeset must be bounded by scope contract;
- Wordstat/SERP collection must use only human-approved anchors from `seo.anchor_review`, never raw site text or automatic model proposals;
- strategy and optimization plan must consume curated keyword role decisions, not raw provider rows;
- future landing gaps are non-blocking strategy context and must not force materialization, rewrite, or random existing page selection;
- rewrite variants require approved existing page/section/content-field bindings and may be grouped by persisted semantic blocks;
- apply requires changeset, validation, dry-run/backup boundary, confirmation, and app-owned adapter;
- Ontology Core does not execute SEO source mutations.
## Stage 5 Keyword Curation
The model must pre-sort keyword candidates before strategy. The board has exactly five lanes:
- unresolved / `Неразобранные`;
- landing core / `Посадочное ядро`;
- supporting core / `Поддерживающее ядро`;
- differentiators / `Уточняющие / УТП`;
- long tail / `Длинный хвост`.
The user can move cards between lanes. Final strategy input includes only landing core,
supporting core, differentiators, and long tail. Unresolved keywords do not participate in
strategy, mapping, rewrite, or materialization.
Optional future rule:
- expansion opportunities are a separate future branch after core SEO results and market evidence.
## Current Runtime Gap
The SEO module has moved NODE.DC-specific clusters behind an explicit project ontology seed fixture.
Generic extraction must not inject NODE.DC aliases, `/modules/ops`, or AI-platform fixture phrases.
This is covered by:
```text
/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SEO/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts
```
The remaining gap is execution routing: Ontology Core declares SEO actions and guardrails, but the live
`adapter.seo.module_api` is still marked `future`. Assistant/MCP execution must follow the SEO-owned API
contract before it can simulate the full route:
```text
semantic profile -> demand universe -> anchor review -> Wordstat queue -> market signal -> keyword curation -> landing/section mapping -> strategy/rewrite eligibility
```
## Package Loading
Ontology Core now loads domain packages from:
```text
catalog/domain-packages/*
```
Each package may contribute:
- `entities.json`
- `relations.json`
- `aliases.json`
- `guardrails.json`
- `evidence.json`
- `resolver-rules.json`
- `context-bindings.json`
- `assistant-access-policy.json`
- `assistant-actions.json`
- `assistant-risk-policy.json`
The loader merges package catalogs into the base catalog before validation.

View File

@ -0,0 +1,150 @@
{
"schemaVersion": "seo-project-ontology.v1",
"projectId": "nodedc-site-seo",
"siteId": "nodedc-site",
"coreOntologyVersion": "0.4-pre",
"seoDomainPackageVersion": "0.1.0",
"source": {
"sourceType": "local_folder",
"scanVersionId": "scan_fixture_nodedc_site",
"rootUrl": null,
"sourcePath": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SITE"
},
"scope": {
"scopeContractId": "scope_fixture_nodedc_site",
"scopeItemIds": ["page.home", "section.hero", "section.modules", "section.ops", "section.engine"]
},
"brand": {
"name": "NODE.DC",
"aliases": ["NodeDC", "NDC", "NODEDC"],
"description": "Enterprise platform for process automation, AI assistants, workflow, operational control, and applied modules."
},
"offers": [
{
"id": "offer.platform",
"name": "AI platform for business automation",
"summary": "Platform layer combining Hub, Engine, Ops, Assistant, integrations, and domain modules.",
"evidenceIds": ["e.site.copy"]
},
{
"id": "offer.ops",
"name": "Operational control and project workspace",
"summary": "Operational card/project/task context for teams and AI-assisted execution.",
"evidenceIds": ["e.ops.card"]
},
{
"id": "offer.engine",
"name": "Workflow and automation engine",
"summary": "Workflow graph and automation runtime context connected to platform apps.",
"evidenceIds": ["e.ontology.core"]
}
],
"audiences": [
{
"id": "audience.enterprise",
"name": "Enterprise teams",
"summary": "Teams needing controlled automation, access boundaries, workflow, and operational visibility.",
"evidenceIds": ["e.site.copy"]
}
],
"pages": [
{
"id": "page.home",
"url": "/",
"title": "NODE.DC landing",
"evidenceIds": ["e.site.copy"]
}
],
"sections": [
{
"id": "section.hero",
"pageId": "page.home",
"title": "Hero",
"meaning": "Core platform positioning and primary offer.",
"evidenceIds": ["e.site.copy"]
},
{
"id": "section.modules",
"pageId": "page.home",
"title": "Modules",
"meaning": "Platform modules and application catalog.",
"evidenceIds": ["e.site.copy"]
},
{
"id": "section.ops",
"pageId": "page.home",
"title": "Ops",
"meaning": "Operational work management, projects, cards, statuses, and assistant routing.",
"evidenceIds": ["e.ops.card"]
},
{
"id": "section.engine",
"pageId": "page.home",
"title": "Engine",
"meaning": "Workflow graph, automation nodes, runtime context, and integrations.",
"evidenceIds": ["e.ontology.core"]
}
],
"intentClusters": [
{
"id": "intent.ai_platform_development",
"title": "AI platform for development and automation",
"priority": "high",
"targetIntent": "Commercial platform, automation, AI, product development, enterprise workflow.",
"seedQueries": ["ai платформа для бизнеса", "платформа автоматизации бизнеса ai"],
"evidenceIds": ["e.site.copy"]
},
{
"id": "intent.business_process_automation",
"title": "Business process automation",
"priority": "high",
"targetIntent": "BPM/workflow/process automation and operational execution.",
"seedQueries": ["автоматизация бизнес процессов", "workflow система для бизнеса"],
"evidenceIds": ["e.site.copy"]
},
{
"id": "intent.agentic_automation",
"title": "Agentic automation and AI assistants",
"priority": "high",
"targetIntent": "AI agents, assistants, autonomous checks, and task execution.",
"seedQueries": ["ai агенты для бизнеса", "агентная автоматизация процессов"],
"evidenceIds": ["e.site.copy"]
}
],
"tone": {
"language": "ru",
"signals": ["engineering product tone", "B2B operational language", "AI-first vocabulary"],
"forbiddenShifts": ["consumer entertainment tone", "unsupported market claims", "global business pivot without approval"]
},
"constraints": {
"forbiddenTopics": ["old Webflow context", "SS-script legacy context", "federat legacy context"],
"forbiddenClaims": ["guaranteed traffic growth without market evidence", "unsupported integrations"],
"mustKeep": ["NODE.DC brand", "platform modularity", "source read-only before apply"]
},
"evidence": [
{
"id": "e.site.copy",
"sourceType": "site_source",
"ref": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SITE",
"summary": "Current validation target for SEO module development."
},
{
"id": "e.ops.card",
"sourceType": "ops_card",
"ref": "c2e5e655-693f-4da5-a83e-438e98a2f6df",
"summary": "Operational SEO architecture card."
},
{
"id": "e.ontology.core",
"sourceType": "ontology_core",
"ref": "platform/services/ontology-core",
"summary": "Platform ontology service/module baseline."
}
],
"approvals": {
"status": "draft",
"approvedBy": null,
"approvedAt": null
},
"ontologyDeltaProposals": []
}

View File

@ -1,11 +1,12 @@
import { readJson } from './catalog.mjs'
import { loadCatalog, 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')
const catalog = await loadCatalog()
return catalog.assistantAccessPolicy
}
function normalizeString(value) {

View File

@ -6,6 +6,7 @@ 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 const domainPackagesRoot = path.join(catalogRoot, 'domain-packages')
export async function readJson(relativePath) {
const fullPath = path.join(serviceRoot, relativePath)
@ -13,6 +14,229 @@ export async function readJson(relativePath) {
return JSON.parse(raw)
}
async function readJsonAt(fullPath) {
const raw = await fs.readFile(fullPath, 'utf8')
return JSON.parse(raw)
}
async function readOptionalJsonAt(fullPath, fallback) {
try {
return await readJsonAt(fullPath)
} catch (error) {
if (error?.code === 'ENOENT') return fallback
throw error
}
}
async function listDomainPackageDirs() {
try {
const entries = await fs.readdir(domainPackagesRoot, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(domainPackagesRoot, entry.name))
.sort()
} catch (error) {
if (error?.code === 'ENOENT') return []
throw error
}
}
function uniqueByValue(values) {
return [...new Set(values)]
}
function mergeRoleMatrix(baseMatrix = [], patchMatrix = []) {
const byRole = new Map(baseMatrix.map((entry) => [entry.roleId, { ...entry }]))
for (const patch of patchMatrix || []) {
const current = byRole.get(patch.roleId) || { roleId: patch.roleId }
byRole.set(patch.roleId, {
...current,
...patch,
allowedCapabilities: uniqueByValue([
...(current.allowedCapabilities || []),
...(patch.allowedCapabilities || []),
]),
deniedCapabilities: uniqueByValue([
...(current.deniedCapabilities || []),
...(patch.deniedCapabilities || []),
]),
deniedWhenNoAdminScope: uniqueByValue([
...(current.deniedWhenNoAdminScope || []),
...(patch.deniedWhenNoAdminScope || []),
]),
conditionalCapabilities: [
...(current.conditionalCapabilities || []),
...(patch.conditionalCapabilities || []),
],
})
}
return [...byRole.values()]
}
function mergeAssistantAccessPolicy(base, patch = {}) {
return {
...base,
roleVocabulary: [...(base.roleVocabulary || []), ...(patch.roleVocabulary || [])],
roleAliases: [...(base.roleAliases || []), ...(patch.roleAliases || [])],
sourceAuthorities: [...(base.sourceAuthorities || []), ...(patch.sourceAuthorities || [])],
capabilities: [...(base.capabilities || []), ...(patch.capabilities || [])],
roleMatrix: mergeRoleMatrix(base.roleMatrix, patch.roleMatrix),
operations: [...(base.operations || []), ...(patch.operations || [])],
hardDenies: [...(base.hardDenies || []), ...(patch.hardDenies || [])],
}
}
function mergeCatalog(base, extension) {
const next = {
...base,
entities: {
...base.entities,
statusVocabulary: uniqueByValue([
...(base.entities.statusVocabulary || []),
...(extension.entities.statusVocabulary || []),
]),
entities: [...(base.entities.entities || []), ...(extension.entities.entities || [])],
},
relations: {
...base.relations,
relations: [...(base.relations.relations || []), ...(extension.relations.relations || [])],
},
aliases: {
...base.aliases,
aliases: [...(base.aliases.aliases || []), ...(extension.aliases.aliases || [])],
},
guardrails: {
...base.guardrails,
rules: [...(base.guardrails.rules || []), ...(extension.guardrails.rules || [])],
blockedConflations: [
...(base.guardrails.blockedConflations || []),
...(extension.guardrails.blockedConflations || []),
],
},
evidence: {
...base.evidence,
sourceRoots: [...(base.evidence.sourceRoots || []), ...(extension.evidence.sourceRoots || [])],
ledgers: [...(base.evidence.ledgers || []), ...(extension.evidence.ledgers || [])],
baselineDocs: [...(base.evidence.baselineDocs || []), ...(extension.evidence.baselineDocs || [])],
restrictions: [...(base.evidence.restrictions || []), ...(extension.evidence.restrictions || [])],
},
resolverRules: {
...base.resolverRules,
rules: [...(base.resolverRules.rules || []), ...(extension.resolverRules.rules || [])],
},
assistantAccessPolicy: mergeAssistantAccessPolicy(
base.assistantAccessPolicy,
extension.assistantAccessPolicy,
),
assistantActions: {
...base.assistantActions,
apps: [...(base.assistantActions.apps || []), ...(extension.assistantActions.apps || [])],
adapterStatuses: uniqueByValue([
...(base.assistantActions.adapterStatuses || []),
...(extension.assistantActions.adapterStatuses || []),
]),
actions: [...(base.assistantActions.actions || []), ...(extension.assistantActions.actions || [])],
},
assistantRiskPolicy: {
...base.assistantRiskPolicy,
riskLevels: [...(base.assistantRiskPolicy.riskLevels || []), ...(extension.assistantRiskPolicy.riskLevels || [])],
confirmationModes: [
...(base.assistantRiskPolicy.confirmationModes || []),
...(extension.assistantRiskPolicy.confirmationModes || []),
],
selfActionPolicies: [
...(base.assistantRiskPolicy.selfActionPolicies || []),
...(extension.assistantRiskPolicy.selfActionPolicies || []),
],
hardDenies: [...(base.assistantRiskPolicy.hardDenies || []), ...(extension.assistantRiskPolicy.hardDenies || [])],
globalRules: [
...(base.assistantRiskPolicy.globalRules || []),
...(extension.assistantRiskPolicy.globalRules || []),
],
},
contextBindings: {
...base.contextBindings,
contextStatuses: uniqueByValue([
...(base.contextBindings.contextStatuses || []),
...(extension.contextBindings.contextStatuses || []),
]),
bindingStatuses: uniqueByValue([
...(base.contextBindings.bindingStatuses || []),
...(extension.contextBindings.bindingStatuses || []),
]),
contexts: [...(base.contextBindings.contexts || []), ...(extension.contextBindings.contexts || [])],
bindingTypes: [
...(base.contextBindings.bindingTypes || []),
...(extension.contextBindings.bindingTypes || []),
],
bindings: [...(base.contextBindings.bindings || []), ...(extension.contextBindings.bindings || [])],
},
domainPackages: [...(base.domainPackages || []), ...(extension.domainPackages || [])],
}
return next
}
async function loadDomainPackage(packageDir) {
const relativeDir = path.relative(serviceRoot, packageDir)
const metadata = await readOptionalJsonAt(path.join(packageDir, 'package.json'), {})
return {
entities: await readOptionalJsonAt(path.join(packageDir, 'entities.json'), { statusVocabulary: [], entities: [] }),
relations: await readOptionalJsonAt(path.join(packageDir, 'relations.json'), { relations: [] }),
aliases: await readOptionalJsonAt(path.join(packageDir, 'aliases.json'), { aliases: [] }),
guardrails: await readOptionalJsonAt(path.join(packageDir, 'guardrails.json'), { rules: [], blockedConflations: [] }),
evidence: await readOptionalJsonAt(path.join(packageDir, 'evidence.json'), {
sourceRoots: [],
ledgers: [],
baselineDocs: [],
restrictions: [],
}),
resolverRules: await readOptionalJsonAt(path.join(packageDir, 'resolver-rules.json'), { rules: [] }),
assistantAccessPolicy: await readOptionalJsonAt(path.join(packageDir, 'assistant-access-policy.json'), {
roleVocabulary: [],
roleAliases: [],
sourceAuthorities: [],
capabilities: [],
roleMatrix: [],
operations: [],
hardDenies: [],
}),
assistantActions: await readOptionalJsonAt(path.join(packageDir, 'assistant-actions.json'), {
apps: [],
adapterStatuses: [],
actions: [],
}),
assistantRiskPolicy: await readOptionalJsonAt(path.join(packageDir, 'assistant-risk-policy.json'), {
riskLevels: [],
confirmationModes: [],
selfActionPolicies: [],
hardDenies: [],
globalRules: [],
}),
contextBindings: await readOptionalJsonAt(path.join(packageDir, 'context-bindings.json'), {
contextStatuses: [],
bindingStatuses: [],
contexts: [],
bindingTypes: [],
bindings: [],
}),
domainPackages: [{
id: metadata.id || path.basename(packageDir),
status: metadata.status || 'unknown',
relativePath: relativeDir,
summary: metadata.summary || '',
}],
}
}
async function loadDomainPackages() {
const packageDirs = await listDomainPackageDirs()
return Promise.all(packageDirs.map((packageDir) => loadDomainPackage(packageDir)))
}
export async function loadCatalog() {
const [
entities,
@ -37,15 +261,8 @@ export async function loadCatalog() {
])
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 {
const domainPackages = await loadDomainPackages()
const mergedCatalog = domainPackages.reduce((catalog, extension) => mergeCatalog(catalog, extension), {
entities,
relations,
aliases,
@ -56,6 +273,19 @@ export async function loadCatalog() {
assistantActions,
assistantRiskPolicy,
contextBindings,
domainPackages: [],
})
const entityById = new Map(mergedCatalog.entities.entities.map((entity) => [entity.id, entity]))
const relationById = new Map(mergedCatalog.relations.relations.map((relation) => [relation.id, relation]))
const contextById = new Map(mergedCatalog.contextBindings.contexts.map((context) => [context.id, context]))
const bindingTypeById = new Map(mergedCatalog.contextBindings.bindingTypes.map((bindingType) => [bindingType.id, bindingType]))
const aliasByKey = new Map(
mergedCatalog.aliases.aliases.map((alias) => [normalizeAlias(alias.alias), alias.canonicalId]),
)
return {
...mergedCatalog,
entityById,
relationById,
contextById,

View File

@ -349,6 +349,7 @@ export async function validateCatalog() {
contexts: contextBindings.contexts.length,
bindingTypes: contextBindings.bindingTypes.length,
bindings: contextBindings.bindings.length,
domainPackages: catalog.domainPackages.length,
},
}
}