Skip to content

Commit 33f5e23

Browse files
os-zhuangclaude
andauthored
feat(lint,spec): AI surface-affinity rule, ADR-0063 doc completion, ADR-0109 draft (#3820) (#3871)
* 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 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 840ee4b commit 33f5e23

12 files changed

Lines changed: 391 additions & 21 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(lint): `validate-ai-surface-affinity` — skill ↔ agent surface affinity is now linted (#3820)
7+
8+
An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a skill
9+
declares which surface it belongs to (`'ask'` | `'build'` | `'both'`, §3). The
10+
runtime refuses an incompatible binding with a **load error at chat time**
11+
after parse, validate, and deploy all passed cleanly. The new rule reports that
12+
contradiction statically, and joins `REFERENCE_INTEGRITY_RULES`, so
13+
`objectstack validate`, `lint`, and `compile` all pick it up with no CLI
14+
changes.
15+
16+
Scope is deliberately narrow (zero false positives by construction): only
17+
bindings where **both** the agent and the skill are declared in the same stack
18+
are checked. `agent.skills[]` names that don't resolve in-stack (kernel skills
19+
are runtime-registered and statically invisible) are skipped — resolving those
20+
namespaces is #3820 D0/D2, decided by ADR-0109 (Proposed).
21+
22+
The spec side is doc-truth only, no schema shape changes:
23+
24+
- `stack.agents` is documented as **platform-internal** (ADR-0063 §2 — the
25+
kernel ships exactly two agents; third parties extend via skills), replacing
26+
prose that still described the withdrawn ADR-0040 per-app-copilot model.
27+
- `stack.tools` is documented as declaration-only pending the ADR-0109 tool
28+
authoring model.
29+
- `app.defaultAgent` is re-documented as a surface-binding knob (`'ask'`
30+
implicit / `'build'` for authoring surfaces), not a custom-agent slot.
31+
- `SkillSchema` now states that a per-skill `permissions` field deliberately
32+
does not exist (ADR-0049) — authoring one is silently stripped; access is
33+
gated by `agent.access` / `agent.permissions` and per-tool authz.

content/docs/references/ui/app.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ const result = ActionNavItem.parse(data);
9090
| **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration |
9191
| **embed** | `{ enabled?: boolean; allowedOrigins?: string[]; width?: string; height?: string; … }` | optional | Iframe embedding configuration |
9292
| **mobileNavigation** | `{ mode?: Enum<'drawer' \| 'bottom_nav' \| 'hamburger'>; bottomNavItems?: string[] }` | optional | Mobile-specific navigation configuration |
93-
| **defaultAgent** | `string` | optional | Name of the default AI agent for this app (used by the ambient chat endpoint) |
93+
| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 |
9494
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the application |
9595
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. |
9696
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
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
@@ -222,6 +222,15 @@ export type {
222222
TranslationRefSeverity,
223223
} from './validate-translation-references.js';
224224

225+
export {
226+
validateAiSurfaceAffinity,
227+
AI_SKILL_SURFACE_MISMATCH,
228+
} from './validate-ai-surface-affinity.js';
229+
export type {
230+
AiSurfaceAffinityFinding,
231+
AiSurfaceAffinitySeverity,
232+
} from './validate-ai-surface-affinity.js';
233+
225234
// One entry point for the reference-resolution rules above (#3583 §5 D5).
226235
// Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint`
227236
// 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
@@ -22,6 +22,7 @@ describe('reference-integrity suite — membership', () => {
2222
'validateNavAccess',
2323
'validateTranslationReferences',
2424
'validateFlowTemplatePaths',
25+
'validateAiSurfaceAffinity',
2526
]);
2627
});
2728

@@ -107,6 +108,10 @@ describe('reference-integrity suite — every member actually runs', () => {
107108
// validateTranslationReferences: a field the object does not declare.
108109
{ en: { objects: { crm_lead: { label: 'Lead', fields: { assigned_to: { label: 'Owner' } } } } } },
109110
],
111+
// validateAiSurfaceAffinity: an 'ask' agent binding a 'build' skill — the
112+
// runtime throws on this at chat time (ADR-0064 §3).
113+
agents: [{ name: 'helper', surface: 'ask', skills: ['metadata_authoring'] }],
114+
skills: [{ name: 'metadata_authoring', surface: 'build', tools: [] }],
110115
flows: [
111116
{
112117
name: 'lead_followup',
@@ -139,6 +144,7 @@ describe('reference-integrity suite — every member actually runs', () => {
139144
expect(rules).toContain('nav-object-ungranted');
140145
expect(rules).toContain('translation-target-unknown');
141146
expect(rules).toContain('flow-template-unknown-field');
147+
expect(rules).toContain('ai-skill-surface-mismatch');
142148
});
143149

144150
it('carries a gating flow-template finding through the suite (#3810)', () => {
@@ -160,9 +166,9 @@ describe('reference-integrity suite — every member actually runs', () => {
160166
expect(typeof f.message).toBe('string');
161167
expect(typeof f.hint).toBe('string');
162168
}
163-
// Object references run first, flow template paths last.
169+
// Object references run first, AI surface affinity last.
164170
expect(findings[0].rule).toBe('object-reference-unknown');
165-
expect(findings[findings.length - 1].rule).toBe('flow-template-unknown-field');
171+
expect(findings[findings.length - 1].rule).toBe('ai-skill-surface-mismatch');
166172
});
167173

168174
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
@@ -52,6 +52,7 @@ import { validateChartBindings } from './validate-chart-bindings.js';
5252
import { validateNavAccess } from './validate-nav-access.js';
5353
import { validateTranslationReferences } from './validate-translation-references.js';
5454
import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
55+
import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
5556

5657
export type ReferenceIntegritySeverity = 'error' | 'warning';
5758

@@ -94,6 +95,7 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
9495
{ name: 'validateNavAccess', run: validateNavAccess },
9596
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
9697
{ name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths },
98+
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
9799
];
98100

99101
/**
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+
});

0 commit comments

Comments
 (0)