diff --git a/plugins/tools/gmail-blade-mcp.json b/plugins/tools/gmail-blade-mcp.json index b159c31..b2d3181 100644 --- a/plugins/tools/gmail-blade-mcp.json +++ b/plugins/tools/gmail-blade-mcp.json @@ -62,6 +62,10 @@ "runtime": "uv", "package": "gmail-blade-mcp" }, + "non_conformance_rationale": { + "reason": "A mailbox binding does not resolve the user-defined domains selected by Gmail's per-record pattern mapper, and responses do not provide canonical _meta.domain_attribution.", + "domain_scope_unspecified": ["gmail_search", "gmail_read", "gmail_snippets", "gmail_thread", "gmail_mailboxes", "gmail_info", "gmail_state", "gmail_changes", "gmail_identities", "gmail_filters", "gmail_send", "gmail_reply", "gmail_draft", "gmail_flag", "gmail_move", "gmail_bulk", "gmail_delete", "gmail_filter_create", "gmail_filter_delete", "gmail_classify", "gmail_summarise"] + }, "tools": [ { "name": "gmail_search", diff --git a/plugins/tools/home-assistant-blade-mcp.json b/plugins/tools/home-assistant-blade-mcp.json index bc39e65..58e4946 100644 --- a/plugins/tools/home-assistant-blade-mcp.json +++ b/plugins/tools/home-assistant-blade-mcp.json @@ -63,6 +63,10 @@ "runtime": "uv", "package": "home-assistant-blade-mcp" }, + "non_conformance_rationale": { + "reason": "Optional instance selection can fan reads into multiple configured sites or choose a first provider, and responses do not provide canonical _meta.domain_attribution.", + "domain_scope_unspecified": ["ha_info", "ha_config", "ha_error_log", "ha_areas", "ha_devices", "ha_entities", "ha_floors", "ha_labels", "ha_search", "ha_services_list", "ha_state", "ha_states", "ha_states_by_domain", "ha_history", "ha_logbook", "ha_statistics", "ha_statistics_list", "ha_calendar_events", "ha_automations", "ha_automation_get", "ha_template", "ha_camera_snapshot", "ha_events"] + }, "tools": [ { "name": "ha_info", @@ -270,7 +274,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -281,7 +286,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -292,7 +298,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -303,7 +310,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -314,7 +322,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -325,7 +334,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -358,7 +368,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -369,7 +380,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -380,7 +392,8 @@ "scope_filtering": "none", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -391,7 +404,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -402,7 +416,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -435,7 +450,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } }, { @@ -457,7 +473,8 @@ "scope_filtering": "server-side", "field_projection": "none", "deterministic_ordering": "stable", - "audit_surface": "minimal" + "audit_surface": "minimal", + "domain_scope": "single" } } ], diff --git a/plugins/tools/mastodon-blade-mcp.json b/plugins/tools/mastodon-blade-mcp.json index d5bd67b..c9f6f77 100644 --- a/plugins/tools/mastodon-blade-mcp.json +++ b/plugins/tools/mastodon-blade-mcp.json @@ -18,6 +18,10 @@ "runtime": "uv", "package": "mastodon-blade-mcp" }, + "non_conformance_rationale": { + "reason": "There is no authoritative service binding/domain contract for social-v1, and responses do not provide canonical _meta.domain_attribution.", + "domain_scope_unspecified": ["mastodon_info", "mastodon_verify", "mastodon_timeline_home", "mastodon_timeline_public", "mastodon_timeline_local", "mastodon_timeline_hashtag", "mastodon_timeline_list", "mastodon_status", "mastodon_context", "mastodon_search", "mastodon_account", "mastodon_account_statuses", "mastodon_relationships", "mastodon_followers", "mastodon_following", "mastodon_notifications", "mastodon_trending_tags", "mastodon_trending_statuses", "mastodon_trending_links", "mastodon_bookmarks", "mastodon_favourites", "mastodon_lists", "mastodon_list_accounts", "mastodon_conversations", "mastodon_filters", "mastodon_post", "mastodon_reply", "mastodon_edit", "mastodon_delete", "mastodon_favourite", "mastodon_unfavourite", "mastodon_boost", "mastodon_unboost", "mastodon_bookmark", "mastodon_unbookmark", "mastodon_pin", "mastodon_unpin", "mastodon_follow", "mastodon_unfollow", "mastodon_block", "mastodon_unblock", "mastodon_mute", "mastodon_unmute", "mastodon_dismiss_notification", "mastodon_media_upload"] + }, "tools": [ { "name": "mastodon_info", diff --git a/plugins/tools/syncthing-blade-mcp.json b/plugins/tools/syncthing-blade-mcp.json index 75be7d7..15aae28 100644 --- a/plugins/tools/syncthing-blade-mcp.json +++ b/plugins/tools/syncthing-blade-mcp.json @@ -25,6 +25,10 @@ "runtime": "uv", "package": "syncthing-blade-mcp" }, + "non_conformance_rationale": { + "reason": "There is no authoritative service binding/domain contract for sync-v1, and multi-instance and pattern-based responses do not provide canonical _meta.domain_attribution.", + "domain_scope_unspecified": ["syncthing_list_devices", "syncthing_device_completion", "syncthing_connections", "syncthing_device_stats", "syncthing_pending_devices", "syncthing_pending_folders", "syncthing_accept_device", "syncthing_reject_device", "syncthing_accept_folder", "syncthing_reject_folder", "syncthing_get_ignores", "syncthing_set_ignores", "syncthing_get_default_ignores", "syncthing_set_default_ignores", "syncthing_folder_status", "syncthing_folder_completion", "syncthing_replication_report", "syncthing_pause_folder", "syncthing_resume_folder", "syncthing_scan_folder", "syncthing_folder_errors", "syncthing_browse_folder", "syncthing_file_info", "syncthing_folder_need", "syncthing_remote_need", "syncthing_override_folder", "syncthing_revert_folder", "syncthing_list_instances", "syncthing_list_folders", "syncthing_system_status", "syncthing_system_errors", "syncthing_clear_errors", "syncthing_system_log", "syncthing_recent_changes", "syncthing_restart_required", "syncthing_restart", "syncthing_check_upgrade", "syncthing_health_summary"] + }, "tools": [ { "name": "syncthing_list_devices", diff --git a/plugins/tools/tailscale-blade-mcp.json b/plugins/tools/tailscale-blade-mcp.json index d6b90ca..1e3318f 100644 --- a/plugins/tools/tailscale-blade-mcp.json +++ b/plugins/tools/tailscale-blade-mcp.json @@ -63,6 +63,10 @@ "runtime": "uv", "package": "tailscale-blade-mcp" }, + "non_conformance_rationale": { + "reason": "There is no authoritative service binding/domain contract for overlay-network-v1, and responses do not provide canonical _meta.domain_attribution.", + "domain_scope_unspecified": ["ts_info", "ts_devices", "ts_device", "ts_device_routes", "ts_dns", "ts_acl", "ts_acl_validate", "ts_keys", "ts_users", "ts_webhooks", "ts_audit_log", "ts_authorize_device", "ts_set_tags", "ts_expire_device", "ts_approve_routes", "ts_create_key", "ts_delete_key", "ts_delete_device"] + }, "tools": [ { "name": "ts_info", diff --git a/schemas/catalog-entry.schema.json b/schemas/catalog-entry.schema.json index e5e814c..5b315d6 100644 --- a/schemas/catalog-entry.schema.json +++ b/schemas/catalog-entry.schema.json @@ -156,7 +156,21 @@ "type": "object", "description": "DD-333 F.1 (pack-spec 4.2.0) — explicit non-conformance declaration. When a plugin's tools[] entry lacks a `granularity:` block AND the tool name appears in `affected_tools` here, the plugin is accepted-with-rationale rather than rejected. build-catalog.js derives `granularity.scope_filtering: \"non-conforming-explicit\"` (plus worst-case row on the other three dimensions) for each listed tool. Downstream UI ([[DD-189]] amendment), memory ([[DD-301]] amendment), and assembler verdict ([[DD-333]] F.2) consume the per-tool non-conformance flag. See stallari-pack-spec/docs/non-conformance-rationale.md.", "additionalProperties": false, - "required": ["reason", "scope_filtering_off", "contamination_risks", "affected_tools"], + "required": ["reason"], + "allOf": [ + { + "anyOf": [ + { "required": ["scope_filtering_off", "contamination_risks", "affected_tools"] }, + { "required": ["domain_scope_unspecified"] } + ] + }, + { + "if": { "required": ["affected_tools"] }, + "then": { + "required": ["scope_filtering_off", "contamination_risks", "affected_tools"] + } + } + ], "properties": { "reason": { "type": "string", @@ -191,6 +205,7 @@ }, "domain_scope_unspecified": { "type": "array", + "minItems": 1, "uniqueItems": true, "items": { "type": "string" }, "description": "DD-333 F.4 — additive non-conformance escape hatch for the 5th granularity dimension (domain_scope). Optional list of tool names from this entry's tools[].name that legitimately cannot yet declare domain_scope (upstream provider doesn't expose per-domain partitioning, blade pre-dates DD-341 substrate, etc.). build-catalog.js derives `granularity.domain_scope: \"non-conforming-explicit\"` for each listed tool (sister to existing scope_filtering derivation). Cross-field invariant (every entry must cross-reference an actual tools[].name) is enforced procedurally in build-catalog.js. Promoted to mandatory backfill at F.4.b after A.2.dom blade declarations land." diff --git a/scripts/build-catalog.js b/scripts/build-catalog.js index 8e37b22..884ae10 100644 --- a/scripts/build-catalog.js +++ b/scripts/build-catalog.js @@ -445,6 +445,9 @@ function pluginToCatalogEntry(raw) { // in enforceNonConformanceRationale(); consumers reading the per-tool // shape see the derived row. non_conformance_rationale: raw.non_conformance_rationale || null, + // Preserve post-derivation tool rows so catalog consumers receive every + // granularity axis, including domain_scope. + tools: Array.isArray(raw.tools) ? raw.tools : null, not_supported: Array.isArray(raw.not_supported) && raw.not_supported.length > 0 ? raw.not_supported : null, runtime: raw.install?.runtime || raw.runtime || null, // DD-265: surface library-form-factor metadata so the harness marketplace diff --git a/scripts/catalog-entry.schema.test.js b/scripts/catalog-entry.schema.test.js index ecb9e38..4254b04 100644 --- a/scripts/catalog-entry.schema.test.js +++ b/scripts/catalog-entry.schema.test.js @@ -104,6 +104,56 @@ describe("DD-333 catalog-entry schema — granularity fixtures", () => { }); describe("DD-333 F.1 catalog-entry schema — non_conformance_rationale fixtures", () => { + it("accepts a domain-only non_conformance_rationale block", async () => { + const entry = validPluginEntry({ + tools: [{ name: "legacy_domain_tool" }], + non_conformance_rationale: { + reason: "The upstream service has no authoritative domain contract or canonical attribution.", + domain_scope_unspecified: ["legacy_domain_tool"], + }, + }); + const verdict = await validateCatalogEntry(entry); + assert.equal(verdict.valid, true, verdict.errorsText); + }); + + it("rejects an empty domain-only unspecified list", async () => { + const entry = validPluginEntry({ + tools: [{ name: "legacy_domain_tool" }], + non_conformance_rationale: { + reason: "The upstream service has no authoritative domain contract or canonical attribution.", + domain_scope_unspecified: [], + }, + }); + const verdict = await validateCatalogEntry(entry); + assert.equal(verdict.valid, false); + assert.ok(verdict.errors.some( + (e) => e.instancePath === "/non_conformance_rationale/domain_scope_unspecified" && e.keyword === "minItems", + ), verdict.errorsText); + }); + + it("rejects a rationale with only reason", async () => { + const verdict = await validateCatalogEntry(validPluginEntry({ + non_conformance_rationale: { reason: "Incomplete rationale." }, + })); + assert.equal(verdict.valid, false); + }); + + it("requires the complete scope-filtering quartet when affected_tools is present", async () => { + const entry = validPluginEntry({ + tools: [{ name: "legacy_scope_tool" }, { name: "legacy_domain_tool" }], + non_conformance_rationale: { + reason: "Both contracts are incomplete.", + affected_tools: ["legacy_scope_tool"], + domain_scope_unspecified: ["legacy_domain_tool"], + }, + }); + const verdict = await validateCatalogEntry(entry); + assert.equal(verdict.valid, false); + assert.ok(verdict.errors.some( + (e) => e.params && e.params.missingProperty === "scope_filtering_off", + ), verdict.errorsText); + }); + it("accepts entry with valid non_conformance_rationale block", async () => { const entry = loadFixture("tool-non-conformance-rationale-valid.json"); const verdict = await validateCatalogEntry(entry); diff --git a/scripts/domain-scope.test.js b/scripts/domain-scope.test.js index b9458e0..13e912c 100644 --- a/scripts/domain-scope.test.js +++ b/scripts/domain-scope.test.js @@ -17,13 +17,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { validateCatalogEntry, enforceDomainScope, + pluginToCatalogEntry, findDomainScopeArg, DOMAIN_SCOPE_ARG_PATTERN, } from "./build-catalog.js"; @@ -31,6 +32,31 @@ import { const ROOT = resolve(import.meta.dirname, ".."); const FIXTURE_DIR = join(ROOT, "schemas", "fixtures", "catalog-entries"); const BUILD_SCRIPT = join(ROOT, "scripts", "build-catalog.js"); +const TARGET_FILES = [ + "gmail-blade-mcp.json", + "home-assistant-blade-mcp.json", + "mastodon-blade-mcp.json", + "tailscale-blade-mcp.json", + "syncthing-blade-mcp.json", +]; +const HOME_SINGLE = new Set([ + "ha_call_service", "ha_light", "ha_climate", "ha_scene", "ha_lock", "ha_alarm", + "ha_automation_trigger", "ha_automation_toggle", "ha_automation_create", + "ha_automation_delete", "ha_script_run", "ha_webhook", "ha_notify", +]); + +function loadToolEntry(file) { + return JSON.parse(readFileSync(join(ROOT, "plugins", "tools", file), "utf-8")); +} + +function effectiveTools(entry) { + const verdict = validateCatalogEntry(entry); + return Promise.resolve(verdict).then((result) => { + assert.equal(result.valid, true, result.errorsText); + enforceDomainScope(entry, entry.name); + return entry.tools; + }); +} function loadFixture(name) { return JSON.parse(readFileSync(join(FIXTURE_DIR, name), "utf-8")); @@ -161,11 +187,91 @@ describe("DD-333 F.4 — procedural cross-field gate (enforceDomainScope)", () = }); }); -describe("DD-333 F.4 — real plugins/tools/ corpus remains clean", () => { - // Smoke: invoking the real build script over the real plugins/tools/ - // directory MUST emit zero S-DOM-002 warnings (no plugin entry today - // declares domain_scope). Any drift surfaces here. - it("zero S-DOM-002 warnings on real corpus", () => { +describe("DD-333 F.4 — exact domain-scope corpus and generated projection", () => { + it("derives the exact 13 single / 145 non-conforming distribution across the five source entries", async () => { + const entries = TARGET_FILES.map(loadToolEntry); + const protectedFields = new Map(entries.flatMap((entry) => entry.tools.map((tool) => [ + `${entry.name}/${tool.name}`, + { + risk_class: tool.risk_class, + scope_filtering: tool.granularity.scope_filtering, + field_projection: tool.granularity.field_projection, + deterministic_ordering: tool.granularity.deterministic_ordering, + audit_surface: tool.granularity.audit_surface, + }, + ]))); + assert.equal( + entries.flatMap((entry) => entry.tools) + .filter((tool) => tool.granularity.domain_scope === "non-conforming-explicit").length, + 0, + "domain-only rationale, not authors, must derive non-conforming-explicit", + ); + const tools = (await Promise.all(entries.map(effectiveTools))).flat(); + assert.equal(tools.length, 158); + + const counts = tools.reduce((result, tool) => { + result[tool.granularity.domain_scope] = (result[tool.granularity.domain_scope] || 0) + 1; + return result; + }, {}); + assert.deepEqual(counts, { single: 13, "non-conforming-explicit": 145 }); + + const gmail = entries[0]; + assert.deepEqual( + new Set(gmail.tools.map((tool) => tool.name)), + new Set(gmail.non_conformance_rationale.domain_scope_unspecified), + ); + assert.ok(gmail.tools.every((tool) => tool.granularity.domain_scope === "non-conforming-explicit")); + + const home = entries[1]; + const homeSingles = new Set(home.tools + .filter((tool) => tool.granularity.domain_scope === "single") + .map((tool) => tool.name)); + assert.deepEqual(homeSingles, HOME_SINGLE); + const homeUnspecified = new Set(home.non_conformance_rationale.domain_scope_unspecified); + assert.deepEqual( + homeUnspecified, + new Set(home.tools.map((tool) => tool.name).filter((name) => !HOME_SINGLE.has(name))), + ); + + for (const entry of entries) { + const rationale = entry.non_conformance_rationale; + assert.equal(rationale.scope_filtering_off, undefined); + assert.equal(rationale.contamination_risks, undefined); + assert.equal(rationale.affected_tools, undefined); + const unspecified = rationale.domain_scope_unspecified; + assert.equal(new Set(unspecified).size, unspecified.length); + const named = new Set(entry.tools.map((tool) => tool.name)); + assert.ok(unspecified.every((name) => named.has(name))); + assert.ok(entry.tools.every((tool) => + tool.granularity.domain_scope === "single" || + tool.granularity.domain_scope === "non-conforming-explicit")); + for (const tool of entry.tools) { + assert.deepEqual( + { + risk_class: tool.risk_class, + scope_filtering: tool.granularity.scope_filtering, + field_projection: tool.granularity.field_projection, + deterministic_ordering: tool.granularity.deterministic_ordering, + audit_surface: tool.granularity.audit_surface, + }, + protectedFields.get(`${entry.name}/${tool.name}`), + ); + } + } + for (const entry of entries.slice(2)) { + assert.ok(entry.tools.every((tool) => tool.granularity.domain_scope === "non-conforming-explicit")); + } + }); + + it("projects post-derivation tools into a catalog entry", async () => { + const entry = loadToolEntry("gmail-blade-mcp.json"); + await effectiveTools(entry); + const projected = pluginToCatalogEntry(entry); + assert.equal(projected.tools, entry.tools); + assert.ok(projected.tools.every((tool) => tool.granularity.domain_scope === "non-conforming-explicit")); + }); + + it("builds effective tools without S-DOM-002 warnings and preserves source tool counts", () => { const result = spawnSync(process.execPath, [BUILD_SCRIPT], { cwd: ROOT, encoding: "utf-8", @@ -183,5 +289,25 @@ describe("DD-333 F.4 — real plugins/tools/ corpus remains clean", () => { /S-DOM-002 — \d+ warning/, `Expected zero S-DOM-002 warnings; got stdout:\n${result.stdout}`, ); + const generated = JSON.parse(readFileSync(join(ROOT, "dist", "catalog.json"), "utf-8")).data; + const sourceEntries = readdirSync(join(ROOT, "plugins", "tools")) + .filter((file) => file.endsWith(".json")) + .map(loadToolEntry); + for (const source of sourceEntries) { + if (!Array.isArray(source.tools)) continue; + const generatedEntry = generated.find((entry) => entry.name === source.name); + assert.ok(generatedEntry, `missing generated entry for ${source.name}`); + assert.equal(generatedEntry.tools.length, source.tools.length, source.name); + } + const selected = generated.filter((entry) => TARGET_FILES + .map((file) => file.replace(/\.json$/, "")) + .includes(entry.name)); + const generatedTools = selected.flatMap((entry) => entry.tools); + assert.equal(generatedTools.length, 158); + const generatedCounts = generatedTools.reduce((result, tool) => { + result[tool.granularity.domain_scope] = (result[tool.granularity.domain_scope] || 0) + 1; + return result; + }, {}); + assert.deepEqual(generatedCounts, { single: 13, "non-conforming-explicit": 145 }); }); });