From 5dc1f2346279a28d18fc2118100e35542c9c95a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:15:53 +0000 Subject: [PATCH 1/2] feat(lint,spec): AI surface-affinity rule, ADR-0063 doc completion, ADR-0109 draft (#3820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Positioning work ahead of R7 (issue #3820): the agent authoring surface is closed per ADR-0063, so the buildable AI rule is the one that checks a statically provable, runtime-fatal contradiction — skill ↔ agent surface affinity (ADR-0064 §3) — not agent.skills resolution. - lint: new `validate-ai-surface-affinity` rule + tests, appended to REFERENCE_INTEGRITY_RULES so `validate`/`lint`/`compile` all pick it up. Error severity: the runtime throws on this binding at chat time. Unresolved skill names are deliberately out of scope (kernel skills are runtime-registered; #3820 D0/D2). False-positive floor read per branch (#3806): clean on the HotCRM-shaped corpus, `examples/` proves nothing here. - spec: stack.zod.ts AI-slot prose no longer describes the withdrawn ADR-0040 model — agents marked platform-internal (ADR-0063 §2), tools marked declaration-only pending #3820 D0, skills named as THE extension primitive. app.defaultAgent re-documented as a surface-binding knob (its example was the withdrawn tenant-agent pattern). SkillSchema documents that per-skill `permissions` deliberately does not exist (ADR-0049) — the field HotCRM authored and Zod silently stripped. - docs: ADR-0109 (Proposed) — the #3820 D0 decision: third-party tools are bindings to actions/flows; platform tool names become a conformance-tested registry; unblocks the R7 skill.tools branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4 --- docs/adr/0109-ai-tool-authoring-model.md | 38 +++++ packages/lint/src/index.ts | 9 ++ .../src/reference-integrity-suite.test.ts | 10 +- .../lint/src/reference-integrity-suite.ts | 2 + .../src/validate-ai-surface-affinity.test.ts | 139 ++++++++++++++++++ .../lint/src/validate-ai-surface-affinity.ts | 120 +++++++++++++++ packages/spec/src/ai/skill.zod.ts | 7 + packages/spec/src/stack.zod.ts | 30 ++-- packages/spec/src/ui/app.form.ts | 2 +- packages/spec/src/ui/app.zod.ts | 20 ++- 10 files changed, 357 insertions(+), 20 deletions(-) create mode 100644 docs/adr/0109-ai-tool-authoring-model.md create mode 100644 packages/lint/src/validate-ai-surface-affinity.test.ts create mode 100644 packages/lint/src/validate-ai-surface-affinity.ts diff --git a/docs/adr/0109-ai-tool-authoring-model.md b/docs/adr/0109-ai-tool-authoring-model.md new file mode 100644 index 0000000000..34d3bc3551 --- /dev/null +++ b/docs/adr/0109-ai-tool-authoring-model.md @@ -0,0 +1,38 @@ +# ADR-0109: The AI tool authoring model — third-party tools are bindings to executable primitives + +**Status**: Proposed (2026-07-28) — the "D0" decision of issue #3820; blocks the `skill.tools` branch of the R7 reference-integrity rule. +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0063](./0063-two-kernel-agents-skills-are-the-extension-primitive.md) (skills + tools are the third-party extension primitive), [ADR-0064](./0064-tool-scoping-to-agent.md) (an agent's tools are its skills' tools), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata) +**Consumers**: `@objectstack/spec` (`ai/tool.zod.ts`, `stack.zod.ts`), `@objectstack/lint` (the R7 rule), `../cloud/service-ai` (ToolRegistry), `../cloud/service-ai-studio` + +--- + +## TL;DR + +`stack.tools` today is a **declaration with no executable half and no reader**: `ToolSchema` carries no handler binding, the metadata `tool` type is written once at boot (Studio visibility mirror, `service-ai-studio/plugin.ts`) and read by **nothing**, `'tools'` is missing from `composeStacks`' `CONCAT_ARRAY_FIELDS`, and the executable tool set lives exclusively in the runtime-registered in-process `ToolRegistry`. ADR-0063 §2 says third parties extend via "skills **and tools**" — but only the skills half is real. That is the ADR-0078 prohibited state, and it makes any reference-integrity rule over `skill.tools[]` unbuildable: there is no registry of facts to resolve against (16/16 of HotCRM's skill→tool references miss `stack.tools`; 6 of those 16 nonetheless resolve — against the runtime registry lint cannot see). + +Decision: **a third-party tool is a *binding* to an executable primitive the platform already has — an action or a flow — never a free-floating executable of its own.** Kernel/plugin tools stay runtime-registered, and their names become facts via a curated, conformance-tested registry in spec. + +## Decision + +1. **Third-party tools bind, they don't implement.** `ToolSchema` gains a required-for-app-packages `binding` (`{ type: 'action' | 'flow', name: string }`). The runtime materialises a callable from the bound primitive (the same mechanism that already materialises `action_` tools from object action lists); `parameters` may narrow, but never widen, what the bound primitive accepts. Handlers, authz, and audit stay where they already are — on the action/flow. A tool record without a `binding` is legal only for runtime-registered (kernel/plugin) tools, whose record is a visibility mirror, not a definition. +2. **Platform tool names become facts, not guesses.** Spec exports a curated `PLATFORM_PROVIDED_TOOL_NAMES: ReadonlySet` (the `PLATFORM_PROVIDED_OBJECT_NAMES` / #3657 precedent), kept honest by conformance tests in the *owning* packages (service-ai / service-ai-studio assert "every tool I register is listed; every listed name with my prefix is registered"). The lint → spec dependency direction is preserved. +3. **Reference integrity becomes decidable, and R7's tool branch unblocks.** `skill.tools[]` (wildcard-aware) resolves against: bound tools declared in-stack (→ checkable through to the action/flow, which existing rules already resolve) ∪ `PLATFORM_PROVIDED_TOOL_NAMES` ∪ wildcard families. Resolution ladder per ADR-0072: unknown name → **warning** at first (ADR-0078 ratchet), **error** once the registry has soaked one release. +4. **The inert surface is closed.** `'tools'` joins `CONCAT_ARRAY_FIELDS`; the boot-time mirror writes gain provenance so a stack-declared record cannot shadow a platform tool's mirror (today `if (exists) skip` lets it); the runtime's "silently dropped" posture for unresolved references stays (graceful degradation) but logs, because the *authoring-time* signal now comes from lint — loud at the producer, tolerant at the consumer (Prime Directive #12). + +## Rejected alternative + +**Tools as first-class executables** (a `handler` on ToolSchema): re-invents actions/flows inside the AI layer, splits authz/audit across two systems, and turns every tool into a second place to define business logic. The platform's ontology is already executable; the AI layer needs a *view* of it, not a rival. + +## Consequences + +- "Skills + tools" (ADR-0063 §2) becomes a shipped contract: a third party can author a skill whose tools are real, checkable, and governed by the same authz as the UI button that runs the same action. +- AI-authored metadata gets a closed vocabulary: a hallucinated tool name fails lint at draft time instead of shipping as a dead capability the copilot claims to have (the HotCRM failure, 10 fictional tools across 6 skills). +- Costs: a spec change (`binding`), the registry + conformance tests, a materialisation path in the runtime; the R7 tool-branch rule stays blocked until 1–2 land. + +## Follow-up + +- [ ] `binding` on `ToolSchema` + `'tools'` into `CONCAT_ARRAY_FIELDS` (spec). +- [ ] `PLATFORM_PROVIDED_TOOL_NAMES` + owning-package conformance tests (spec, cloud). +- [ ] Materialise bound tools in the ToolRegistry; provenance-guard the boot mirror (cloud). +- [ ] R7 tool branch: `validate-ai-references` skill→tool resolution, warning-first (lint; #3820). diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 4a16970fce..14f990ed47 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -221,6 +221,15 @@ export type { TranslationRefSeverity, } from './validate-translation-references.js'; +export { + validateAiSurfaceAffinity, + AI_SKILL_SURFACE_MISMATCH, +} from './validate-ai-surface-affinity.js'; +export type { + AiSurfaceAffinityFinding, + AiSurfaceAffinitySeverity, +} from './validate-ai-surface-affinity.js'; + // One entry point for the reference-resolution rules above (#3583 §5 D5). // Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint` // and `compile` at once — the CLI call sites do not change. diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index c9d42a78ac..11508b4cb0 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -21,6 +21,7 @@ describe('reference-integrity suite — membership', () => { 'validateChartBindings', 'validateNavAccess', 'validateTranslationReferences', + 'validateAiSurfaceAffinity', ]); }); @@ -106,6 +107,10 @@ describe('reference-integrity suite — every member actually runs', () => { // validateTranslationReferences: a field the object does not declare. { en: { objects: { crm_lead: { label: 'Lead', fields: { assigned_to: { label: 'Owner' } } } } } }, ], + // validateAiSurfaceAffinity: an 'ask' agent binding a 'build' skill — the + // runtime throws on this at chat time (ADR-0064 §3). + agents: [{ name: 'helper', surface: 'ask', skills: ['metadata_authoring'] }], + skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }], }; it('reports at least one finding from every member', () => { @@ -118,6 +123,7 @@ describe('reference-integrity suite — every member actually runs', () => { expect(rules).toContain('chart-measure-unknown'); expect(rules).toContain('nav-object-ungranted'); expect(rules).toContain('translation-target-unknown'); + expect(rules).toContain('ai-skill-surface-mismatch'); }); it('concatenates in list order and carries the common finding shape', () => { @@ -131,9 +137,9 @@ describe('reference-integrity suite — every member actually runs', () => { expect(typeof f.message).toBe('string'); expect(typeof f.hint).toBe('string'); } - // Object references run first, translations last. + // Object references run first, AI surface affinity last. expect(findings[0].rule).toBe('object-reference-unknown'); - expect(findings[findings.length - 1].rule).toBe('translation-target-unknown'); + expect(findings[findings.length - 1].rule).toBe('ai-skill-surface-mismatch'); }); it('returns nothing for an empty stack', () => { diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index d934fb884c..f5e02daf57 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -43,6 +43,7 @@ import { validatePageFieldBindings } from './validate-page-field-bindings.js'; import { validateChartBindings } from './validate-chart-bindings.js'; import { validateNavAccess } from './validate-nav-access.js'; import { validateTranslationReferences } from './validate-translation-references.js'; +import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js'; export type ReferenceIntegritySeverity = 'error' | 'warning'; @@ -84,6 +85,7 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateChartBindings', run: validateChartBindings }, { name: 'validateNavAccess', run: validateNavAccess }, { name: 'validateTranslationReferences', run: validateTranslationReferences }, + { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, ]; /** diff --git a/packages/lint/src/validate-ai-surface-affinity.test.ts b/packages/lint/src/validate-ai-surface-affinity.test.ts new file mode 100644 index 0000000000..a68677a463 --- /dev/null +++ b/packages/lint/src/validate-ai-surface-affinity.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateAiSurfaceAffinity, + AI_SKILL_SURFACE_MISMATCH, +} from './validate-ai-surface-affinity.js'; + +describe('validate-ai-surface-affinity', () => { + // ── The false-positive floor, read per branch (#3806 lesson) ────────────── + // `examples/` declares zero agents/skills, so it proves nothing about this + // rule. The floor is instead the real shipped corpus the issue measured: + // HotCRM's two agents × six skills, none of which declares a `surface` + // (raw config — no Zod defaults applied). Every binding must pass. + it('is clean on the HotCRM-shaped corpus (no surface fields anywhere)', () => { + const stack = { + agents: [ + { + name: 'sales_copilot', + skills: [ + 'live_data', + 'lead_qualification', + 'email_drafting', + 'revenue_forecasting', + 'customer_360', + ], + }, + { + name: 'service_copilot', + skills: ['case_triage', 'customer_360', 'email_drafting'], + }, + ], + skills: [ + { name: 'live_data', tools: ['describe_object'] }, + { name: 'lead_qualification', tools: ['analyze_lead'] }, + { name: 'email_drafting', tools: ['generate_email'] }, + { name: 'revenue_forecasting', tools: ['forecast_revenue'] }, + { name: 'customer_360', tools: ['search_knowledge'] }, + { name: 'case_triage', tools: ['triage_case'] }, + ], + }; + expect(validateAiSurfaceAffinity(stack)).toEqual([]); + }); + + it("flags an 'ask' agent referencing a 'build' skill", () => { + const stack = { + agents: [{ name: 'helper', surface: 'ask', skills: ['metadata_authoring'] }], + skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }], + }; + const findings = validateAiSurfaceAffinity(stack); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: AI_SKILL_SURFACE_MISMATCH, + where: 'agent "helper" · skills', + path: 'agents[0].skills[0]', + }); + expect(findings[0].message).toContain("'ask'"); + expect(findings[0].message).toContain("'build'"); + }); + + it("flags a 'build' agent referencing an 'ask' skill (symmetric)", () => { + const stack = { + agents: [{ name: 'builder', surface: 'build', skills: ['data_explorer'] }], + skills: [{ name: 'data_explorer', surface: 'ask', tools: [] }], + }; + const findings = validateAiSurfaceAffinity(stack); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('agents[0].skills[0]'); + }); + + it("accepts surface: 'both' on either agent surface", () => { + const stack = { + agents: [ + { name: 'asker', surface: 'ask', skills: ['schema_tools'] }, + { name: 'builder', surface: 'build', skills: ['schema_tools'] }, + ], + skills: [{ name: 'schema_tools', surface: 'both', tools: [] }], + }; + expect(validateAiSurfaceAffinity(stack)).toEqual([]); + }); + + it("defaults an absent surface to 'ask' on BOTH sides (mirrors the runtime)", () => { + // Agent without surface (→ 'ask') referencing a build skill: must flag. + const stack = { + agents: [{ name: 'helper', skills: ['metadata_authoring'] }], + skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }], + }; + expect(validateAiSurfaceAffinity(stack)).toHaveLength(1); + + // Skill without surface (→ 'ask') referenced by a build agent: must flag. + const inverse = { + agents: [{ name: 'builder', surface: 'build', skills: ['plain'] }], + skills: [{ name: 'plain', tools: [] }], + }; + expect(validateAiSurfaceAffinity(inverse)).toHaveLength(1); + }); + + it('skips references that do not resolve in-stack (kernel/runtime-registered skills)', () => { + // `ask`/`build` kernel skills (schema_reader, data_explorer, …) register at + // boot and are statically invisible — resolving them against `stack.skills` + // alone is #3820 D0/D2 territory, not this rule's. + const stack = { + agents: [{ name: 'helper', surface: 'ask', skills: ['schema_reader', 'data_explorer'] }], + skills: [], + }; + expect(validateAiSurfaceAffinity(stack)).toEqual([]); + }); + + it('reports every mismatched binding, with stable paths', () => { + const stack = { + agents: [ + { + name: 'helper', + surface: 'ask', + skills: ['ok_skill', 'authoring', 'unresolved', 'design'], + }, + ], + skills: [ + { name: 'ok_skill', surface: 'ask', tools: [] }, + { name: 'authoring', surface: 'build', tools: [] }, + { name: 'design', surface: 'build', tools: [] }, + ], + }; + const findings = validateAiSurfaceAffinity(stack); + expect(findings.map((f) => f.path)).toEqual(['agents[0].skills[1]', 'agents[0].skills[3]']); + }); + + it('tolerates junk shapes without throwing', () => { + expect(validateAiSurfaceAffinity({})).toEqual([]); + expect(validateAiSurfaceAffinity({ agents: 'nope', skills: 42 } as never)).toEqual([]); + expect( + validateAiSurfaceAffinity({ + agents: [null, { name: 'x', skills: [null, 7, 'y'] }], + skills: [null, { surface: 'build' }, { name: 'y', surface: 'build' }], + } as never), + ).toHaveLength(1); + }); +}); diff --git a/packages/lint/src/validate-ai-surface-affinity.ts b/packages/lint/src/validate-ai-surface-affinity.ts new file mode 100644 index 0000000000..b3d35e0637 --- /dev/null +++ b/packages/lint/src/validate-ai-surface-affinity.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0064 §3] Skill ↔ agent surface affinity (issue #3820). + * + * An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a + * skill declares which surface it belongs to (`'ask'` | `'build'` | `'both'`, + * ADR-0063 §3). A skill may only attach to an agent whose surface it matches — + * `'both'` attaches to either. The runtime treats a violation as a FAST LOAD + * ERROR: `resolveActiveSkills()` throws on the first incompatible binding, so + * an agent shipping one mismatched skill reference fails at **chat time** with + * a 500 — after parse, after validate, after deploy. + * + * Both sides of the check are declared in the same stack, so the contradiction + * is statically provable and this rule carries severity **error** with zero + * false positives by construction. Both sides default to `'ask'` when the + * `surface` field is absent (mirroring the runtime's defaults), so the rule is + * safe on the raw/normalized config the `lint` path carries as well as the + * schema-parsed stack. + * + * Scope note: this rule deliberately does NOT check that `agent.skills[]` + * names resolve at all. Kernel skills (`schema_reader`, the `ask`/`build` + * bundles) are runtime-registered and statically invisible, and whether + * app-stack tool/skill namespaces get a platform-name registry is an open + * decision (#3820 D0/D2) — resolving names against `stack.skills` alone would + * flag every kernel-skill reference. An unresolved name is therefore skipped + * here; only a reference that resolves in-stack AND contradicts the affinity + * contract is reported. + */ + +export const AI_SKILL_SURFACE_MISMATCH = 'ai-skill-surface-mismatch'; + +export type AiSurfaceAffinitySeverity = 'error' | 'warning'; + +export interface AiSurfaceAffinityFinding { + /** Always `error` — the runtime throws on this binding at chat time. */ + severity: AiSurfaceAffinitySeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `agent "sales_copilot" · skills`. */ + where: string; + /** Config path, e.g. `agents[0].skills[2]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object'); + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** The runtime defaults an absent `surface` to `'ask'` on both sides. */ +function surfaceOf(v: unknown): string { + return typeof v === 'string' && v.length > 0 ? v : 'ask'; +} + +/** + * Validate every in-stack agent→skill binding against the ADR-0064 §3 surface + * affinity contract. Returns findings (empty = clean). + */ +export function validateAiSurfaceAffinity(stack: AnyRec): AiSurfaceAffinityFinding[] { + const findings: AiSurfaceAffinityFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const skillsByName = new Map(); + for (const skill of asArray(stack.skills)) { + const n = strName(skill.name); + if (n) skillsByName.set(n, skill); + } + + const agents = asArray(stack.agents); + for (let ai = 0; ai < agents.length; ai++) { + const agent = agents[ai]; + const agentName = strName(agent.name) ?? `#${ai}`; + const agentSurface = surfaceOf(agent.surface); + const skillRefs = Array.isArray(agent.skills) ? agent.skills : []; + + for (let si = 0; si < skillRefs.length; si++) { + const ref = strName(skillRefs[si]); + if (!ref) continue; + const skill = skillsByName.get(ref); + // Unresolved in-stack → runtime-registered kernel skill or another + // package's — out of this rule's scope (see header; #3820 D0/D2). + if (!skill) continue; + + const skillSurface = surfaceOf(skill.surface); + if (skillSurface === 'both' || skillSurface === agentSurface) continue; + + findings.push({ + severity: 'error', + rule: AI_SKILL_SURFACE_MISMATCH, + where: `agent "${agentName}" · skills`, + path: `agents[${ai}].skills[${si}]`, + message: + `Agent "${agentName}" (surface: '${agentSurface}') references skill "${ref}" ` + + `(surface: '${skillSurface}') — incompatible affinity (ADR-0064 §3). The runtime ` + + `refuses this binding with a load error, so chatting with this agent fails at ` + + `request time even though the stack parses and validates cleanly.`, + hint: + `A skill may only attach to an agent whose surface it matches. Move "${ref}" to a ` + + `'${skillSurface}'-surface agent, change its \`surface\` to '${agentSurface}', or — ` + + `only if it is a genuinely shared, read-only capability — declare \`surface: 'both'\`.`, + }); + } + } + + return findings; +} diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index 00d434914f..246fc20186 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -42,6 +42,13 @@ export type SkillTriggerCondition = z.infer; * Aligned with Salesforce Agentforce Topics, Microsoft Copilot Studio Topics, * and ServiceNow Skill metadata patterns. * + * NOTE — there is deliberately NO per-skill `permissions` field. Access to AI + * capability is gated at the AGENT level (`agent.access` / `agent.permissions`, + * both enforced at the chat route), and each tool enforces its own authz when + * invoked. A `permissions` key authored on a skill is unknown to this schema + * and silently stripped at parse time — it grants and restricts nothing + * (ADR-0049: no unenforced security-shaped fields). Do not author one. + * * @example * ```ts * const skill = defineSkill({ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 400eb0fcba..e03fe99fd6 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -290,19 +290,29 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * * Three-tier composition (Agent → Skill → Tool) aligned with Salesforce * Agentforce Topics, Microsoft Copilot Studio Topics, and ServiceNow Now - * Assist Skills: + * Assist Skills. Per ADR-0063, **skills (+ tools / MCP) are the only + * third-party extension primitive**: * - * - **agents**: Persona-bearing copilots (1-3 per app). Each agent declares - * its base instructions, model, knowledge, and the set of skills it can - * draw on. Users typically don't pick an agent per message — the active - * app's `defaultAgent` is selected automatically. - * - **skills**: Reusable capability bundles ("topics" in Salesforce parlance). - * Each skill groups related tools, declares trigger phrases for + * - **agents**: PLATFORM-INTERNAL (ADR-0063 §2). The kernel ships exactly + * two agents — `ask` (data product) and `build` (authoring product) — + * bound by surface, never picked from a roster. Tenant/app-package custom + * agents were withdrawn (ADR-0040 §3 reversed): an agent declared here + * parses, but the runtime catalog filters non-platform agent records, so + * it is not a supported extension surface. Author skills instead. + * - **skills**: Reusable capability bundles ("topics" in Salesforce + * parlance) — THE extension primitive. Each skill groups related tools, + * declares its agent surface affinity (`'ask' | 'build' | 'both'`, + * ADR-0063 §3 — checked by lint, enforced at load), trigger phrases for * intent matching, and trigger conditions for context-aware activation. + * - **tools**: declaration-only metadata records today. The EXECUTABLE tool + * set is runtime-registered (kernel + plugins); a `stack.tools` entry has + * no handler binding and no runtime reader yet. The third-party tool + * authoring model is an open decision (#3820 D0) — until it lands, a + * skill's `tools[]` can only name runtime-registered tools. */ - agents: z.array(AgentSchema).optional().describe('AI Agents and Assistants'), - tools: z.array(ToolSchema).optional().describe('AI Tools (callable functions referenced by Skills/Agents)'), - skills: z.array(SkillSchema).optional().describe('AI Skills (reusable capability bundles referenced by Agents)'), + agents: z.array(AgentSchema).optional().describe('AI Agents — platform-internal (ADR-0063 §2): the kernel ships exactly two (ask/build); third parties extend via skills, not agents'), + tools: z.array(ToolSchema).optional().describe('AI Tool metadata records (declaration-only; the executable tool set is runtime-registered — see #3820 D0)'), + skills: z.array(SkillSchema).optional().describe('AI Skills (reusable capability bundles — the third-party AI extension primitive, ADR-0063)'), /** * ObjectQL: Data Extensions diff --git a/packages/spec/src/ui/app.form.ts b/packages/spec/src/ui/app.form.ts index 1535ce46e4..3acd4a0c7c 100644 --- a/packages/spec/src/ui/app.form.ts +++ b/packages/spec/src/ui/app.form.ts @@ -36,7 +36,7 @@ export const appForm = defineForm({ fields: [ { field: 'objects', widget: 'object-selector', multiple: true, helpText: 'Object names this app exposes' }, { field: 'apis', type: 'composite', helpText: 'API endpoint definitions' }, - { field: 'defaultAgent', type: 'text', helpText: 'AI agent for the ambient assistant button' }, + { field: 'defaultAgent', type: 'text', helpText: "Platform agent for the ambient assistant ('ask' by default; 'build' for authoring surfaces)" }, ], }, { diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index dbe2313f55..11f474bf17 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -657,23 +657,29 @@ export const AppSchema = lazySchema(() => z.object({ }).optional().describe('Mobile-specific navigation configuration'), /** - * Default AI Copilot for this app. + * Default agent for this app's ambient chat surface. * * When set, the ambient chat endpoint (`POST /api/v1/ai/chat` with * `context.appName`) auto-resolves to this agent without the user - * having to pick from a list. The agent's `skills[]` are loaded - * from the SkillRegistry and exposed to the LLM. + * having to pick from a list. * - * Mirrors the Salesforce Agentforce / ServiceNow Now Assist pattern - * where each application surface has one ambient copilot. + * ADR-0063 §1/§2 — this is a SURFACE-BINDING knob, not a custom-agent + * slot: the resolvable values are the two platform agents (`ask` for a + * data surface — the default everywhere, so most apps never set this — + * and `build` for an authoring surface such as Studio). Tenant/app-package + * custom agents were withdrawn (ADR-0040 §3 reversed); a name that is not + * a platform agent will not resolve at chat time. To give an app deeper + * AI capability, author skills — they attach to the platform agents by + * surface affinity. * * @example * ```ts - * defineApp({ name: 'crm', defaultAgent: 'sales_copilot', ... }) + * // An authoring surface pins the build agent; data apps just omit this. + * defineApp({ name: 'studio', defaultAgent: 'build', ... }) * ``` */ defaultAgent: SnakeCaseIdentifierSchema.optional() - .describe('Name of the default AI agent for this app (used by the ambient chat endpoint)'), + .describe("Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1"), /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the application'), From da774f99a9d84525af255793c99293dcd19e2895 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:19:35 +0000 Subject: [PATCH 2/2] chore: changeset + regenerate app reference for defaultAgent doc (#3820) Check Changeset and the generated-docs gate in TypeScript Type Check both flagged the previous commit: add the missing changeset (@objectstack/lint minor, @objectstack/spec patch) and the regenerated content/docs/references/ui/app.mdx row for the re-documented `app.defaultAgent`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4 --- .changeset/ai-surface-affinity-lint.md | 33 ++++++++++++++++++++++++++ content/docs/references/ui/app.mdx | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 .changeset/ai-surface-affinity-lint.md diff --git a/.changeset/ai-surface-affinity-lint.md b/.changeset/ai-surface-affinity-lint.md new file mode 100644 index 0000000000..3524152a01 --- /dev/null +++ b/.changeset/ai-surface-affinity-lint.md @@ -0,0 +1,33 @@ +--- +"@objectstack/lint": minor +"@objectstack/spec": patch +--- + +feat(lint): `validate-ai-surface-affinity` — skill ↔ agent surface affinity is now linted (#3820) + +An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a skill +declares which surface it belongs to (`'ask'` | `'build'` | `'both'`, §3). The +runtime refuses an incompatible binding with a **load error at chat time** — +after parse, validate, and deploy all passed cleanly. The new rule reports that +contradiction statically, and joins `REFERENCE_INTEGRITY_RULES`, so +`objectstack validate`, `lint`, and `compile` all pick it up with no CLI +changes. + +Scope is deliberately narrow (zero false positives by construction): only +bindings where **both** the agent and the skill are declared in the same stack +are checked. `agent.skills[]` names that don't resolve in-stack (kernel skills +are runtime-registered and statically invisible) are skipped — resolving those +namespaces is #3820 D0/D2, decided by ADR-0109 (Proposed). + +The spec side is doc-truth only, no schema shape changes: + +- `stack.agents` is documented as **platform-internal** (ADR-0063 §2 — the + kernel ships exactly two agents; third parties extend via skills), replacing + prose that still described the withdrawn ADR-0040 per-app-copilot model. +- `stack.tools` is documented as declaration-only pending the ADR-0109 tool + authoring model. +- `app.defaultAgent` is re-documented as a surface-binding knob (`'ask'` + implicit / `'build'` for authoring surfaces), not a custom-agent slot. +- `SkillSchema` now states that a per-skill `permissions` field deliberately + does not exist (ADR-0049) — authoring one is silently stripped; access is + gated by `agent.access` / `agent.permissions` and per-tool authz. diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 5528f864c4..f2705d35cf 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -90,7 +90,7 @@ const result = ActionNavItem.parse(data); | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration | | **embed** | `{ enabled?: boolean; allowedOrigins?: string[]; width?: string; height?: string; … }` | optional | Iframe embedding configuration | | **mobileNavigation** | `{ mode?: Enum<'drawer' \| 'bottom_nav' \| 'hamburger'>; bottomNavItems?: string[] }` | optional | Mobile-specific navigation configuration | -| **defaultAgent** | `string` | optional | Name of the default AI agent for this app (used by the ambient chat endpoint) | +| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the application | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |