Skip to content

Commit 5dc1f23

Browse files
committed
feat(lint,spec): AI surface-affinity rule, ADR-0063 doc completion, ADR-0109 draft (#3820)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4
1 parent 87aca93 commit 5dc1f23

10 files changed

Lines changed: 357 additions & 20 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# ADR-0109: The AI tool authoring model — third-party tools are bindings to executable primitives
2+
3+
**Status**: Proposed (2026-07-28) — the "D0" decision of issue #3820; blocks the `skill.tools` branch of the R7 reference-integrity rule.
4+
**Deciders**: ObjectStack Protocol Architects
5+
**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)
6+
**Consumers**: `@objectstack/spec` (`ai/tool.zod.ts`, `stack.zod.ts`), `@objectstack/lint` (the R7 rule), `../cloud/service-ai` (ToolRegistry), `../cloud/service-ai-studio`
7+
8+
---
9+
10+
## TL;DR
11+
12+
`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).
13+
14+
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.
15+
16+
## Decision
17+
18+
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_<name>` 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.
19+
2. **Platform tool names become facts, not guesses.** Spec exports a curated `PLATFORM_PROVIDED_TOOL_NAMES: ReadonlySet<string>` (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.
20+
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.
21+
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).
22+
23+
## Rejected alternative
24+
25+
**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.
26+
27+
## Consequences
28+
29+
- "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.
30+
- 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).
31+
- 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.
32+
33+
## Follow-up
34+
35+
- [ ] `binding` on `ToolSchema` + `'tools'` into `CONCAT_ARRAY_FIELDS` (spec).
36+
- [ ] `PLATFORM_PROVIDED_TOOL_NAMES` + owning-package conformance tests (spec, cloud).
37+
- [ ] Materialise bound tools in the ToolRegistry; provenance-guard the boot mirror (cloud).
38+
- [ ] R7 tool branch: `validate-ai-references` skill→tool resolution, warning-first (lint; #3820).

packages/lint/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@ export type {
221221
TranslationRefSeverity,
222222
} from './validate-translation-references.js';
223223

224+
export {
225+
validateAiSurfaceAffinity,
226+
AI_SKILL_SURFACE_MISMATCH,
227+
} from './validate-ai-surface-affinity.js';
228+
export type {
229+
AiSurfaceAffinityFinding,
230+
AiSurfaceAffinitySeverity,
231+
} from './validate-ai-surface-affinity.js';
232+
224233
// One entry point for the reference-resolution rules above (#3583 §5 D5).
225234
// Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint`
226235
// and `compile` at once — the CLI call sites do not change.

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('reference-integrity suite — membership', () => {
2121
'validateChartBindings',
2222
'validateNavAccess',
2323
'validateTranslationReferences',
24+
'validateAiSurfaceAffinity',
2425
]);
2526
});
2627

@@ -106,6 +107,10 @@ describe('reference-integrity suite — every member actually runs', () => {
106107
// validateTranslationReferences: a field the object does not declare.
107108
{ en: { objects: { crm_lead: { label: 'Lead', fields: { assigned_to: { label: 'Owner' } } } } } },
108109
],
110+
// validateAiSurfaceAffinity: an 'ask' agent binding a 'build' skill — the
111+
// runtime throws on this at chat time (ADR-0064 §3).
112+
agents: [{ name: 'helper', surface: 'ask', skills: ['metadata_authoring'] }],
113+
skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }],
109114
};
110115

111116
it('reports at least one finding from every member', () => {
@@ -118,6 +123,7 @@ describe('reference-integrity suite — every member actually runs', () => {
118123
expect(rules).toContain('chart-measure-unknown');
119124
expect(rules).toContain('nav-object-ungranted');
120125
expect(rules).toContain('translation-target-unknown');
126+
expect(rules).toContain('ai-skill-surface-mismatch');
121127
});
122128

123129
it('concatenates in list order and carries the common finding shape', () => {
@@ -131,9 +137,9 @@ describe('reference-integrity suite — every member actually runs', () => {
131137
expect(typeof f.message).toBe('string');
132138
expect(typeof f.hint).toBe('string');
133139
}
134-
// Object references run first, translations last.
140+
// Object references run first, AI surface affinity last.
135141
expect(findings[0].rule).toBe('object-reference-unknown');
136-
expect(findings[findings.length - 1].rule).toBe('translation-target-unknown');
142+
expect(findings[findings.length - 1].rule).toBe('ai-skill-surface-mismatch');
137143
});
138144

139145
it('returns nothing for an empty stack', () => {

packages/lint/src/reference-integrity-suite.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { validatePageFieldBindings } from './validate-page-field-bindings.js';
4343
import { validateChartBindings } from './validate-chart-bindings.js';
4444
import { validateNavAccess } from './validate-nav-access.js';
4545
import { validateTranslationReferences } from './validate-translation-references.js';
46+
import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
4647

4748
export type ReferenceIntegritySeverity = 'error' | 'warning';
4849

@@ -84,6 +85,7 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
8485
{ name: 'validateChartBindings', run: validateChartBindings },
8586
{ name: 'validateNavAccess', run: validateNavAccess },
8687
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
88+
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
8789
];
8890

8991
/**
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
validateAiSurfaceAffinity,
6+
AI_SKILL_SURFACE_MISMATCH,
7+
} from './validate-ai-surface-affinity.js';
8+
9+
describe('validate-ai-surface-affinity', () => {
10+
// ── The false-positive floor, read per branch (#3806 lesson) ──────────────
11+
// `examples/` declares zero agents/skills, so it proves nothing about this
12+
// rule. The floor is instead the real shipped corpus the issue measured:
13+
// HotCRM's two agents × six skills, none of which declares a `surface`
14+
// (raw config — no Zod defaults applied). Every binding must pass.
15+
it('is clean on the HotCRM-shaped corpus (no surface fields anywhere)', () => {
16+
const stack = {
17+
agents: [
18+
{
19+
name: 'sales_copilot',
20+
skills: [
21+
'live_data',
22+
'lead_qualification',
23+
'email_drafting',
24+
'revenue_forecasting',
25+
'customer_360',
26+
],
27+
},
28+
{
29+
name: 'service_copilot',
30+
skills: ['case_triage', 'customer_360', 'email_drafting'],
31+
},
32+
],
33+
skills: [
34+
{ name: 'live_data', tools: ['describe_object'] },
35+
{ name: 'lead_qualification', tools: ['analyze_lead'] },
36+
{ name: 'email_drafting', tools: ['generate_email'] },
37+
{ name: 'revenue_forecasting', tools: ['forecast_revenue'] },
38+
{ name: 'customer_360', tools: ['search_knowledge'] },
39+
{ name: 'case_triage', tools: ['triage_case'] },
40+
],
41+
};
42+
expect(validateAiSurfaceAffinity(stack)).toEqual([]);
43+
});
44+
45+
it("flags an 'ask' agent referencing a 'build' skill", () => {
46+
const stack = {
47+
agents: [{ name: 'helper', surface: 'ask', skills: ['metadata_authoring'] }],
48+
skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }],
49+
};
50+
const findings = validateAiSurfaceAffinity(stack);
51+
expect(findings).toHaveLength(1);
52+
expect(findings[0]).toMatchObject({
53+
severity: 'error',
54+
rule: AI_SKILL_SURFACE_MISMATCH,
55+
where: 'agent "helper" · skills',
56+
path: 'agents[0].skills[0]',
57+
});
58+
expect(findings[0].message).toContain("'ask'");
59+
expect(findings[0].message).toContain("'build'");
60+
});
61+
62+
it("flags a 'build' agent referencing an 'ask' skill (symmetric)", () => {
63+
const stack = {
64+
agents: [{ name: 'builder', surface: 'build', skills: ['data_explorer'] }],
65+
skills: [{ name: 'data_explorer', surface: 'ask', tools: [] }],
66+
};
67+
const findings = validateAiSurfaceAffinity(stack);
68+
expect(findings).toHaveLength(1);
69+
expect(findings[0].path).toBe('agents[0].skills[0]');
70+
});
71+
72+
it("accepts surface: 'both' on either agent surface", () => {
73+
const stack = {
74+
agents: [
75+
{ name: 'asker', surface: 'ask', skills: ['schema_tools'] },
76+
{ name: 'builder', surface: 'build', skills: ['schema_tools'] },
77+
],
78+
skills: [{ name: 'schema_tools', surface: 'both', tools: [] }],
79+
};
80+
expect(validateAiSurfaceAffinity(stack)).toEqual([]);
81+
});
82+
83+
it("defaults an absent surface to 'ask' on BOTH sides (mirrors the runtime)", () => {
84+
// Agent without surface (→ 'ask') referencing a build skill: must flag.
85+
const stack = {
86+
agents: [{ name: 'helper', skills: ['metadata_authoring'] }],
87+
skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }],
88+
};
89+
expect(validateAiSurfaceAffinity(stack)).toHaveLength(1);
90+
91+
// Skill without surface (→ 'ask') referenced by a build agent: must flag.
92+
const inverse = {
93+
agents: [{ name: 'builder', surface: 'build', skills: ['plain'] }],
94+
skills: [{ name: 'plain', tools: [] }],
95+
};
96+
expect(validateAiSurfaceAffinity(inverse)).toHaveLength(1);
97+
});
98+
99+
it('skips references that do not resolve in-stack (kernel/runtime-registered skills)', () => {
100+
// `ask`/`build` kernel skills (schema_reader, data_explorer, …) register at
101+
// boot and are statically invisible — resolving them against `stack.skills`
102+
// alone is #3820 D0/D2 territory, not this rule's.
103+
const stack = {
104+
agents: [{ name: 'helper', surface: 'ask', skills: ['schema_reader', 'data_explorer'] }],
105+
skills: [],
106+
};
107+
expect(validateAiSurfaceAffinity(stack)).toEqual([]);
108+
});
109+
110+
it('reports every mismatched binding, with stable paths', () => {
111+
const stack = {
112+
agents: [
113+
{
114+
name: 'helper',
115+
surface: 'ask',
116+
skills: ['ok_skill', 'authoring', 'unresolved', 'design'],
117+
},
118+
],
119+
skills: [
120+
{ name: 'ok_skill', surface: 'ask', tools: [] },
121+
{ name: 'authoring', surface: 'build', tools: [] },
122+
{ name: 'design', surface: 'build', tools: [] },
123+
],
124+
};
125+
const findings = validateAiSurfaceAffinity(stack);
126+
expect(findings.map((f) => f.path)).toEqual(['agents[0].skills[1]', 'agents[0].skills[3]']);
127+
});
128+
129+
it('tolerates junk shapes without throwing', () => {
130+
expect(validateAiSurfaceAffinity({})).toEqual([]);
131+
expect(validateAiSurfaceAffinity({ agents: 'nope', skills: 42 } as never)).toEqual([]);
132+
expect(
133+
validateAiSurfaceAffinity({
134+
agents: [null, { name: 'x', skills: [null, 7, 'y'] }],
135+
skills: [null, { surface: 'build' }, { name: 'y', surface: 'build' }],
136+
} as never),
137+
).toHaveLength(1);
138+
});
139+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0064 §3] Skill ↔ agent surface affinity (issue #3820).
5+
*
6+
* An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a
7+
* skill declares which surface it belongs to (`'ask'` | `'build'` | `'both'`,
8+
* ADR-0063 §3). A skill may only attach to an agent whose surface it matches —
9+
* `'both'` attaches to either. The runtime treats a violation as a FAST LOAD
10+
* ERROR: `resolveActiveSkills()` throws on the first incompatible binding, so
11+
* an agent shipping one mismatched skill reference fails at **chat time** with
12+
* a 500 — after parse, after validate, after deploy.
13+
*
14+
* Both sides of the check are declared in the same stack, so the contradiction
15+
* is statically provable and this rule carries severity **error** with zero
16+
* false positives by construction. Both sides default to `'ask'` when the
17+
* `surface` field is absent (mirroring the runtime's defaults), so the rule is
18+
* safe on the raw/normalized config the `lint` path carries as well as the
19+
* schema-parsed stack.
20+
*
21+
* Scope note: this rule deliberately does NOT check that `agent.skills[]`
22+
* names resolve at all. Kernel skills (`schema_reader`, the `ask`/`build`
23+
* bundles) are runtime-registered and statically invisible, and whether
24+
* app-stack tool/skill namespaces get a platform-name registry is an open
25+
* decision (#3820 D0/D2) — resolving names against `stack.skills` alone would
26+
* flag every kernel-skill reference. An unresolved name is therefore skipped
27+
* here; only a reference that resolves in-stack AND contradicts the affinity
28+
* contract is reported.
29+
*/
30+
31+
export const AI_SKILL_SURFACE_MISMATCH = 'ai-skill-surface-mismatch';
32+
33+
export type AiSurfaceAffinitySeverity = 'error' | 'warning';
34+
35+
export interface AiSurfaceAffinityFinding {
36+
/** Always `error` — the runtime throws on this binding at chat time. */
37+
severity: AiSurfaceAffinitySeverity;
38+
/** Diagnostic rule id. */
39+
rule: string;
40+
/** Human-readable location, e.g. `agent "sales_copilot" · skills`. */
41+
where: string;
42+
/** Config path, e.g. `agents[0].skills[2]`. */
43+
path: string;
44+
/** What is wrong. */
45+
message: string;
46+
/** How to fix it. */
47+
hint: string;
48+
}
49+
50+
type AnyRec = Record<string, unknown>;
51+
52+
function asArray(v: unknown): AnyRec[] {
53+
if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object');
54+
if (v && typeof v === 'object') {
55+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
56+
}
57+
return [];
58+
}
59+
60+
function strName(v: unknown): string | undefined {
61+
return typeof v === 'string' && v.length > 0 ? v : undefined;
62+
}
63+
64+
/** The runtime defaults an absent `surface` to `'ask'` on both sides. */
65+
function surfaceOf(v: unknown): string {
66+
return typeof v === 'string' && v.length > 0 ? v : 'ask';
67+
}
68+
69+
/**
70+
* Validate every in-stack agent→skill binding against the ADR-0064 §3 surface
71+
* affinity contract. Returns findings (empty = clean).
72+
*/
73+
export function validateAiSurfaceAffinity(stack: AnyRec): AiSurfaceAffinityFinding[] {
74+
const findings: AiSurfaceAffinityFinding[] = [];
75+
if (!stack || typeof stack !== 'object') return findings;
76+
77+
const skillsByName = new Map<string, AnyRec>();
78+
for (const skill of asArray(stack.skills)) {
79+
const n = strName(skill.name);
80+
if (n) skillsByName.set(n, skill);
81+
}
82+
83+
const agents = asArray(stack.agents);
84+
for (let ai = 0; ai < agents.length; ai++) {
85+
const agent = agents[ai];
86+
const agentName = strName(agent.name) ?? `#${ai}`;
87+
const agentSurface = surfaceOf(agent.surface);
88+
const skillRefs = Array.isArray(agent.skills) ? agent.skills : [];
89+
90+
for (let si = 0; si < skillRefs.length; si++) {
91+
const ref = strName(skillRefs[si]);
92+
if (!ref) continue;
93+
const skill = skillsByName.get(ref);
94+
// Unresolved in-stack → runtime-registered kernel skill or another
95+
// package's — out of this rule's scope (see header; #3820 D0/D2).
96+
if (!skill) continue;
97+
98+
const skillSurface = surfaceOf(skill.surface);
99+
if (skillSurface === 'both' || skillSurface === agentSurface) continue;
100+
101+
findings.push({
102+
severity: 'error',
103+
rule: AI_SKILL_SURFACE_MISMATCH,
104+
where: `agent "${agentName}" · skills`,
105+
path: `agents[${ai}].skills[${si}]`,
106+
message:
107+
`Agent "${agentName}" (surface: '${agentSurface}') references skill "${ref}" ` +
108+
`(surface: '${skillSurface}') — incompatible affinity (ADR-0064 §3). The runtime ` +
109+
`refuses this binding with a load error, so chatting with this agent fails at ` +
110+
`request time even though the stack parses and validates cleanly.`,
111+
hint:
112+
`A skill may only attach to an agent whose surface it matches. Move "${ref}" to a ` +
113+
`'${skillSurface}'-surface agent, change its \`surface\` to '${agentSurface}', or — ` +
114+
`only if it is a genuinely shared, read-only capability — declare \`surface: 'both'\`.`,
115+
});
116+
}
117+
}
118+
119+
return findings;
120+
}

packages/spec/src/ai/skill.zod.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ export type SkillTriggerCondition = z.infer<typeof SkillTriggerConditionSchema>;
4242
* Aligned with Salesforce Agentforce Topics, Microsoft Copilot Studio Topics,
4343
* and ServiceNow Skill metadata patterns.
4444
*
45+
* NOTE — there is deliberately NO per-skill `permissions` field. Access to AI
46+
* capability is gated at the AGENT level (`agent.access` / `agent.permissions`,
47+
* both enforced at the chat route), and each tool enforces its own authz when
48+
* invoked. A `permissions` key authored on a skill is unknown to this schema
49+
* and silently stripped at parse time — it grants and restricts nothing
50+
* (ADR-0049: no unenforced security-shaped fields). Do not author one.
51+
*
4552
* @example
4653
* ```ts
4754
* const skill = defineSkill({

0 commit comments

Comments
 (0)