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
53 changes: 53 additions & 0 deletions .changeset/alias-tables-are-claims-about-the-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/spec": patch
---

fix(spec): unknown-key suggestions no longer point authors at keys the schema rejects (#5013)

An `aliases` table on an authoring schema is a **claim about that schema**, in
two halves: that the key it is filed under is one the shape *rejects* (an alias
is consulted only from the `unrecognized_keys` path, so a key the shape declares
can never reach it), and that the key it prescribes is one the shape *accepts*.
Nothing checked either half, and both were false on `main`.

Writing `filter` on a report produced:

```
Unrecognized key(s) on this report: `filter`. … Did you mean `filter` -> `filters`?
```

and taking that advice produced a **second** rejection — `ReportSchema` declares
neither `filter` nor `filters`, only `runtimeFilter` — this time with no
suggestion at all. That is the exact failure the unknown-key strictness work
exists to remove, shipped by its own fix, and it is worst for AI authors whose
only signal is whether the parse complained.

**What changed** — no authorable key was added or removed, so nothing that
parsed before stops parsing; only the guidance an author gets when a key is
rejected:

- `ReportSchema` — `filter`, `filters`, `where` and `criteria` now all name
`runtimeFilter`, matching `JoinedReportBlockSchema`'s table verbatim so a
report and its sub-reports correct the author identically.
- `EmailTemplateDefinitionSchema` — `html`/`content` name `bodyHtml`, `text`
names `bodyText`, and `from`/`sender` name `fromOverride`. All five previously
named `body` / `fromAddress`, neither of which the schema declares.
- `SkillSchema` — `trigger` no longer renames onto `triggers` (never a key).
It now carries a prescription instead, because the correct answer is a split:
routing intent belongs in `triggerConditions`, natural-language intent in
`description` / `instructions`. A rename would have landed a phrase in an
array-of-conditions slot and been rejected on the value instead of the key.
- Six entries filed under keys their own schema already declares — and which
therefore could never run — are gone: `columns` and `chart` on `ReportSchema`,
`measures` and `filter` on `DatasetSchema`, `body` on `ActionSchema`.

**What keeps it true** — `strictObject` now records each shape it builds, and
`alias-integrity.test.ts` judges every one of the 235 authoring surfaces in the
package against the runtime `.shape` it makes claims about. Reading the runtime
shape rather than the source is what makes it work: shapes spread
(`...MetadataProtectionFields`), ten alias tables are assembled rather than
written as literals, and two different schemas share the surface string
`'this field group'` — a source-literal reader is wrong or blind on all three.
An alias pointing at a **tombstone** is caught too, which the suggester's own
`knownKeys` filter cannot do, since the alias table is consulted before that
fallback runs.
21 changes: 20 additions & 1 deletion packages/spec/src/ai/skill.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,27 @@ export const SkillSchema = lazySchema(() => strictObject({
surface: 'this skill',
history:
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
aliases: { prompt: 'instructions', content: 'instructions', body: 'instructions', trigger: 'triggers', tool: 'tools' },
aliases: { prompt: 'instructions', content: 'instructions', body: 'instructions', tool: 'tools' },
guidance: {
// #5013 — `trigger` used to be an ALIAS pointing at `triggers`, a key this
// schema has never declared: the author was told to write it, wrote it, and
// was rejected a second time with no suggestion left to give.
//
// It is not repointed at `triggerConditions`, because a rename is the wrong
// instrument here. The prescription the `triggerPhrases` tombstone below
// carries is a SPLIT — routing intent goes to `triggerConditions`, natural
// language to `description` / `instructions` — so an author who wrote
// `trigger: 'create a case'` and took a rename would land a phrase in an
// array-of-conditions slot and be rejected on the value instead of the key.
// That is ledger finding 7 exactly: this campaign's own fix signposting the
// way back into the failure mode it exists to kill.
trigger:
'`trigger` is not a skill key, and skills have never been activated by a phrase. '
+ 'Activation is `triggerConditions` (an AND of context field/operator/value) intersected '
+ "with the agent's `skills[]` allowlist, plus explicit /skill-name pinning. If you meant a "
+ 'programmatic condition, write `triggerConditions: [{ field: …, operator: …, value: … }]`; '
+ 'if you meant natural-language intent for the LLM to route on, that belongs in '
+ '`description` / `instructions`, which are the strings actually put in front of the model.',
permissions:
'`permissions` is not a skill key — skill invocation was never permission-gated, '
+ 'so this was stripped in silence and the author believed they had a gate. Gate at '
Expand Down
Loading
Loading