Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/deprecated-alias-conflict-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": minor
---

feat(spec): the deprecated-alias warning now covers all three fold-and-drop aliases (#3743 follow-up)

#3838 introduced `lintDeprecatedAliases` — the pre-parse pass that reports an
alias the parse is about to consume — with one rule, for `action.execute`. The
issue that asked for it predicted the pass would earn its keep beyond that rule,
and it does: `execute` was never special. The spec has exactly **three**
transforms that fold an alias into its canonical key and then drop it from the
parsed output, and all three share the same failure mode — declare both slots
with different values and one of them is discarded with no signal, invisible to
every downstream check because the parse already erased it.

Two more rules, same shape, same advisory severity, same two surfaces
(`defineStack` at authoring time; `os build` / `os validate` for stacks that skip
strict `defineStack`):

- **`field-requiredwhen-conditionalrequired-conflict`** — `FieldSchema` folds
`conditionalRequired` into `requiredWhen` (#3754). The discarded predicate
never gates the field. Covers fields on objects *and* on object extensions.
Compares the predicate **text**, so a bare string and the
`{ dialect, source }` envelope it lowers into are recognised as the same
predicate and stay quiet.
- **`agent-knowledge-sources-topics-conflict`** — `AIKnowledgeSchema` folds
`knowledge.topics` into `knowledge.sources` (#1891). The discarded list names
RAG sources the agent never recruits from. Compares by **set**, so the same
sources in a different order stay quiet.

Neither fails the build; both name the two values and give the one-line fix.

Also corrects `content/docs/ai/agents.mdx`, which documented `knowledge` as
`{ topics, indexes }` and used `topics` in all three examples — teaching the
deprecated alias as if it were the canonical key, and disagreeing with
`skills/objectstack-ai/SKILL.md`, which already had it right. The examples now
use `sources`.
10 changes: 5 additions & 5 deletions content/docs/ai/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ own `ask` / `build` records use exactly these fields):
| `model` | Provider + model config (`provider`: `openai` \| `azure_openai` \| `anthropic` \| `local`) |
| `skills` | Skill names to attach (the primary Agent → Skill → Tool capability model) |
| `tools` | Direct tool **references** `{ type, name, description }` — `type` is `action` \| `flow` \| `query` \| `vector_search`; `name` points at an existing Action/Flow/query |
| `knowledge` | RAG access: `{ topics: string[], indexes: string[] }` |
| `knowledge` | RAG access: `{ sources: string[], indexes: string[] }`. `sources` is the canonical key the renderer reads; `topics` is a deprecated alias folded into it at parse time |

There is no `type` field and no fixed agent "type" taxonomy — behaviour comes from
persona, instructions, skills, and tools. There are no `triggers` / `schedule`
Expand Down Expand Up @@ -199,9 +199,9 @@ Always be professional and data-driven.`,
{ type: 'action', name: 'generate_email', description: 'Generate a personalized email template' },
],

// RAG access: topics to recruit knowledge from + vector store indexes.
// RAG access: sources to recruit knowledge from + vector store indexes.
knowledge: {
topics: ['sales-playbook', 'leads', 'opportunities'],
sources: ['sales-playbook', 'leads', 'opportunities'],
indexes: ['sales_docs'],
},
});
Expand Down Expand Up @@ -239,7 +239,7 @@ Always be empathetic and solution-focused.`,
],

knowledge: {
topics: ['support-kb', 'cases'],
sources: ['support-kb', 'cases'],
indexes: ['support_docs'],
},
});
Expand Down Expand Up @@ -313,7 +313,7 @@ Use the data tools to query records and aggregate metrics.`,
],

knowledge: {
topics: ['opportunities', 'pipeline'],
sources: ['opportunities', 'pipeline'],
indexes: ['sales_docs'],
},
});
Expand Down
8 changes: 7 additions & 1 deletion content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,13 @@ only guard. Use `requiredWhen` for new metadata; keep `conditionalRequired`
only for older packages that already emitted it. The alias is still accepted on
input, but it is lowered into `requiredWhen` at parse time and then removed from
the parsed metadata, so every consumer reads one canonical slot — if a field
declares both, `requiredWhen` wins and the alias is discarded.
declares both, `requiredWhen` wins and the alias is discarded. Declaring both
with *different* predicates means the `conditionalRequired` one never gates the
field, so it raises an advisory `field-requiredwhen-conditionalrequired-conflict`
warning naming both — from `defineStack` when it parses your config, and from
`os build` / `os validate` for stacks that skip strict `defineStack`. It never
fails the build; the fix is to delete `conditionalRequired`. The same predicate
in both slots is harmless duplication and stays quiet.

## Naming Conventions

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
".": [
"ACTION_TARGET_EXECUTE_CONFLICT (const)",
"ADMIN_FULL_ACCESS (const)",
"AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT (const)",
"ALL_CONVERSIONS (const)",
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
Expand Down Expand Up @@ -51,6 +52,7 @@
"ExpressionMetaSchema (const)",
"ExpressionSchema (const)",
"F (const)",
"FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT (const)",
"GUEST_POSITION (const)",
"MAP_SUPPORTED_FIELDS (const)",
"MEMBERSHIP_ROLE_ADMIN (const)",
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,13 @@ export type { MetadataCollectionInput, MapSupportedField, NormalizeStackInputOpt
// Pre-parse authoring lint (#3743) — the one window where a deprecated alias is
// still visible, since the parse itself resolves and drops it. `defineStack`
// warns from here; the CLI runs the same rules over stacks that skip it.
export { lintDeprecatedAliases, formatDeprecatedAliasFinding, ACTION_TARGET_EXECUTE_CONFLICT } from './shared/deprecated-aliases';
export {
lintDeprecatedAliases,
formatDeprecatedAliasFinding,
ACTION_TARGET_EXECUTE_CONFLICT,
FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT,
AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT,
} from './shared/deprecated-aliases';
export type { DeprecatedAliasFinding } from './shared/deprecated-aliases';

// Metadata conversion layer (ADR-0087 D2) — old-shape → canonical-shape transforms applied at load.
Expand Down
160 changes: 159 additions & 1 deletion packages/spec/src/shared/deprecated-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { lintDeprecatedAliases, ACTION_TARGET_EXECUTE_CONFLICT } from './deprecated-aliases';
import {
lintDeprecatedAliases,
ACTION_TARGET_EXECUTE_CONFLICT,
FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT,
AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT,
} from './deprecated-aliases';

// ── #3743: the discarded `execute` alias now has an author-facing warning ──
//
Expand Down Expand Up @@ -156,3 +161,156 @@ describe('lintDeprecatedAliases — action-target-execute-conflict', () => {
]);
});
});

// ── The two sibling fold-and-drop aliases (#3743 follow-up) ──────────────────
//
// `execute` was never special: `FieldSchema` folds `conditionalRequired` into
// `requiredWhen` and `AIKnowledgeSchema` folds `topics` into `sources`, both
// dropping the alias from the parsed output exactly the same way. That makes
// all three invisible to every post-parse check, so all three belong to this
// pre-parse pass — which is the reason the pass exists as a rule SET rather
// than one hard-coded rule.

describe('lintDeprecatedAliases — field-requiredwhen-conditionalrequired-conflict', () => {
const objectWith = (field: Record<string, unknown>) => ({
objects: [{
name: 'crm_task',
fields: { due_date: { type: 'date', label: 'Due', ...field } },
}],
});

it('flags a field declaring both predicates with different sources', () => {
const findings = lintDeprecatedAliases(objectWith({
requiredWhen: 'record.stage == "closed"',
conditionalRequired: 'record.amount > 0',
}));

expect(findings).toHaveLength(1);
const [f] = findings;
expect(f.rule).toBe(FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT);
expect(f.severity).toBe('warning');
expect(f.where).toBe(`field 'due_date' on object 'crm_task'`);
expect(f.message).toContain('record.stage == "closed"');
expect(f.message).toContain('record.amount > 0');
expect(f.message).toContain(`'requiredWhen' wins`);
expect(f.message).toContain('never gates the field');
expect(f.hint).toContain(`Delete 'conditionalRequired'`);
});

it('stays quiet when only one predicate slot is declared', () => {
expect(lintDeprecatedAliases(objectWith({ requiredWhen: 'record.paid' }))).toEqual([]);
expect(lintDeprecatedAliases(objectWith({ conditionalRequired: 'record.paid' }))).toEqual([]);
});

it('compares the predicate TEXT, not the wrapper', () => {
// A bare string and the `{ dialect, source }` envelope it lowers into are
// the same predicate written two ways — nothing is lost either way.
expect(lintDeprecatedAliases(objectWith({
requiredWhen: 'record.paid',
conditionalRequired: { dialect: 'cel', source: 'record.paid' },
}))).toEqual([]);
});

it('reads the envelope form on both slots', () => {
const [f] = lintDeprecatedAliases(objectWith({
requiredWhen: { dialect: 'cel', source: 'record.a' },
conditionalRequired: { dialect: 'cel', source: 'record.b' },
}));
expect(f.rule).toBe(FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT);
expect(f.message).toContain('record.a');
expect(f.message).toContain('record.b');
});

it('covers fields added by an object extension', () => {
// An extension adds/overrides fields on another package's object through
// the same `FieldSchema`, so it carries the same silent discard.
const [f] = lintDeprecatedAliases({
objectExtensions: [{
objectName: 'sys_user',
fields: { nickname: { type: 'text', requiredWhen: 'record.a', conditionalRequired: 'record.b' } },
}],
});
expect(f.where).toBe(`field 'nickname' on object extension 'sys_user'`);
});

it('tolerates an object with no fields', () => {
expect(lintDeprecatedAliases({ objects: [{ name: 'crm_task' }] })).toEqual([]);
});
});

describe('lintDeprecatedAliases — agent-knowledge-sources-topics-conflict', () => {
const agentWith = (knowledge: Record<string, unknown>) => ({
agents: [{ name: 'support_bot', label: 'Support', knowledge }],
});

it('flags an agent declaring both source lists with different members', () => {
const findings = lintDeprecatedAliases(agentWith({
sources: ['faq', 'policies'],
topics: ['legacy_kb'],
}));

expect(findings).toHaveLength(1);
const [f] = findings;
expect(f.rule).toBe(AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT);
expect(f.severity).toBe('warning');
expect(f.where).toBe(`agent 'support_bot' knowledge`);
expect(f.message).toContain(`'faq', 'policies'`);
expect(f.message).toContain(`'legacy_kb'`);
expect(f.message).toContain(`'sources' wins`);
expect(f.message).toContain('never recruited into RAG context');
expect(f.hint).toContain(`Delete 'topics'`);
});

it('stays quiet when only one list is declared', () => {
expect(lintDeprecatedAliases(agentWith({ sources: ['faq'] }))).toEqual([]);
expect(lintDeprecatedAliases(agentWith({ topics: ['faq'] }))).toEqual([]);
});

it('stays quiet when the two lists have the same members in any order', () => {
// Order and repetition are not meaningful for either slot, so the same set
// written twice loses nothing.
expect(lintDeprecatedAliases(agentWith({
sources: ['faq', 'policies'],
topics: ['policies', 'faq', 'faq'],
}))).toEqual([]);
});

it('treats an empty list as undeclared', () => {
expect(lintDeprecatedAliases(agentWith({ sources: [], topics: ['faq'] }))).toEqual([]);
});

it('tolerates an agent with no knowledge block', () => {
expect(lintDeprecatedAliases({ agents: [{ name: 'support_bot' }] })).toEqual([]);
});
});

describe('lintDeprecatedAliases — all three rules together', () => {
it('reports each rule independently in one pass', () => {
const stack = {
objects: [{
name: 'crm_task',
fields: { due_date: { type: 'date', requiredWhen: 'record.a', conditionalRequired: 'record.b' } },
actions: [{ name: 'convert', type: 'script', target: 'preferred', execute: 'legacy' }],
}],
agents: [{ name: 'support_bot', knowledge: { sources: ['faq'], topics: ['legacy_kb'] } }],
};

expect(lintDeprecatedAliases(stack).map((f) => f.rule).sort()).toEqual([
ACTION_TARGET_EXECUTE_CONFLICT,
AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT,
FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT,
].sort());
});

it('returns nothing for a stack that uses only canonical keys', () => {
const stack = {
objects: [{
name: 'crm_task',
fields: { due_date: { type: 'date', requiredWhen: 'record.a' } },
actions: [{ name: 'convert', type: 'script', target: 'preferred' }],
}],
agents: [{ name: 'support_bot', knowledge: { sources: ['faq'] } }],
};
expect(lintDeprecatedAliases(stack)).toEqual([]);
});
});
Loading
Loading