From 764bdd9db4b9150b4b14f1471e28bfe31e7a9c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:12:16 +0000 Subject: [PATCH 01/12] skills: fix reference generator for third-party installs; correct README framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review of the published skills bundle (installed via `npx skills add objectstack-ai/framework/skills --all` into a scaffolded app) found the references layer leaking monorepo-only surface: - build-skill-references.ts listed transitively-imported src files that are NOT in the published package (only src/**/*.zod.ts ships) — lazy-schema.ts / visibility.ts / public-auth-features.ts pointers 404 in a consumer's node_modules. The generator now traverses through non-shipping files but never lists them. - Add missing load-bearing schemas to the map: ai/knowledge-source + knowledge-document (the real RAG primitives), automation/ time-relative-trigger + data/validation (state-machine rules), ui/dataset, query date-macros. - Give objectstack-formula a references/_index.md like its 8 siblings (it previously pointed at monorepo-relative packages/ paths instead). - Mark the regen note maintainers-only and stop claiming the root barrel re-exports every symbol (it doesn't — e.g. DATE_MACRO_TOKENS lives on @objectstack/spec/data only). - skills/README.md: reposition the bundle as serving any ObjectStack app (not 'the monorepo'), document the install commands and the /skills catalog boundary (#3101), and describe the real skill anatomy (rules/, contracts/, evals/). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- .../spec/scripts/build-skill-references.ts | 29 +++++++++++++++---- skills/README.md | 28 ++++++++++++++---- skills/objectstack-ai/references/_index.md | 14 +++++---- skills/objectstack-api/references/_index.md | 12 ++++---- .../references/_index.md | 14 +++++---- skills/objectstack-data/references/_index.md | 14 ++++----- .../objectstack-formula/references/_index.md | 25 ++++++++++++++++ skills/objectstack-i18n/references/_index.md | 15 +++++----- .../objectstack-platform/references/_index.md | 13 +++++---- skills/objectstack-query/references/_index.md | 16 +++++----- skills/objectstack-ui/references/_index.md | 16 +++++----- 11 files changed, 135 insertions(+), 61 deletions(-) create mode 100644 skills/objectstack-formula/references/_index.md diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index a039d149bd..1106873585 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -53,6 +53,7 @@ const SKILL_MAP: Record = { 'objectstack-query': [ 'data/query.zod.ts', 'data/filter.zod.ts', + 'data/date-macros.zod.ts', ], 'objectstack-ai': [ 'ai/agent.zod.ts', @@ -62,6 +63,8 @@ const SKILL_MAP: Record = { 'ai/conversation.zod.ts', 'ai/mcp.zod.ts', 'ai/embedding.zod.ts', + 'ai/knowledge-source.zod.ts', + 'ai/knowledge-document.zod.ts', 'ai/usage.zod.ts', ], 'objectstack-api': [ @@ -78,11 +81,13 @@ const SKILL_MAP: Record = { 'objectstack-automation': [ 'automation/flow.zod.ts', 'automation/trigger-registry.zod.ts', + 'automation/time-relative-trigger.zod.ts', 'automation/approval.zod.ts', 'automation/state-machine.zod.ts', 'automation/execution.zod.ts', 'automation/webhook.zod.ts', 'automation/node-executor.zod.ts', + 'data/validation.zod.ts', ], 'objectstack-ui': [ 'ui/view.zod.ts', @@ -95,6 +100,7 @@ const SKILL_MAP: Record = { 'ui/component.zod.ts', 'ui/report.zod.ts', 'ui/theme.zod.ts', + 'ui/dataset.zod.ts', ], 'objectstack-platform': [ // project setup (was objectstack-quickstart) @@ -115,6 +121,10 @@ const SKILL_MAP: Record = { 'system/translation.zod.ts', 'ui/i18n.zod.ts', ], + 'objectstack-formula': [ + 'shared/expression.zod.ts', + 'data/date-macros.zod.ts', + ], }; // ── Import resolver ────────────────────────────────────────────────────────── @@ -174,7 +184,13 @@ function resolveAll(entryFiles: string[]): { files: string[]; missing: string[] if (!visited.has(dep)) queue.push(dep); } } - return { files: [...visited].sort(), missing }; + // Only `src/**/*.zod.ts` ships in the published package (`files` allowlist in + // package.json) — a pointer to any other src file 404s in a consumer's + // node_modules (#skills-review: lazy-schema.ts, visibility.ts, + // public-auth-features.ts all leaked through transitive imports). Traverse + // through non-shipping files (they may import shipping ones) but never list them. + const shipped = [...visited].filter((rel) => rel.endsWith('.zod.ts')); + return { files: shipped.sort(), missing }; } // ── JSDoc description extractor ────────────────────────────────────────────── @@ -213,8 +229,9 @@ function generateIndex(skillName: string, coreFiles: string[], allFiles: string[ const lines: string[] = [ `# ${skillName} — Schema References`, '', - '> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`.', - `> Do not edit — re-run \`pnpm --filter ${SPEC_PKG} run gen:skill-refs\` to update.`, + '> **Auto-generated** — do not edit. Maintainers regenerate this in the', + `> framework repo with \`pnpm --filter ${SPEC_PKG} run gen:skill-refs\``, + '> (not runnable in an installed app).', '', `Schemas live in the published \`${SPEC_PKG}\` package. Read them directly`, 'from `node_modules` — there is no local copy in the skill bundle.', @@ -246,8 +263,10 @@ function generateIndex(skillName: string, coreFiles: string[], allFiles: string[ ` \`.describe()\` text, enums, and refinements.`, `2. TypeScript types: \`import type { … } from '${SPEC_PKG}'\` (or the`, ' matching subpath export).', - '3. Runtime values: `import { … } from \'' + SPEC_PKG + '\'` — the package', - ' re-exports every schema and helper.', + '3. Runtime values: import from the **matching subpath** shown in the', + ` schema's directory (\`'${SPEC_PKG}/data'\`, \`'${SPEC_PKG}/ai'\`, …).`, + ' The root barrel re-exports the common factories, but not every symbol —', + ' when in doubt, use the subpath.', '', ); diff --git a/skills/README.md b/skills/README.md index deb203eccc..7c44ada875 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,9 +1,22 @@ # ObjectStack Skills Domain-scoped instructions for AI coding assistants (Claude Code, Copilot, Cursor) -working in the ObjectStack monorepo. Each skill is self-contained: a `SKILL.md` -with YAML frontmatter, plus a `references/_index.md` that points into the -authoritative Zod sources in `node_modules/@objectstack/spec/src/...`. +working in **any ObjectStack app** — this monorepo *and* third-party projects. +`npm create objectstack` installs them into new apps automatically; existing +projects add (or update) the bundle with: + +```bash +npx skills add objectstack-ai/framework/skills --all +``` + +The `/skills` subpath matters: it is the published catalog boundary — pointing +the skills CLI at the repo root would also pick up repo-internal skills (#3101). + +Each skill is self-contained: a `SKILL.md` with YAML frontmatter, plus a +`references/_index.md` that points into the authoritative Zod sources in +`node_modules/@objectstack/spec/src/...` (the published `@objectstack/spec` +package ships these `.zod.ts` sources, so the pointers resolve in consumer +apps too). > **Always read the spec source for exact field shapes.** Skills give shape and > intent; the Zod schemas are the truth. @@ -37,8 +50,13 @@ authoritative Zod sources in `node_modules/@objectstack/spec/src/...`. ``` skills// ├── SKILL.md # frontmatter + prose guide -└── references/ - └── _index.md # pointers into @objectstack/spec sources +├── references/ +│ └── _index.md # generated pointers into @objectstack/spec sources +│ # (pnpm --filter @objectstack/spec gen:skill-refs — do not hand-edit) +├── rules/ # (optional) detailed per-topic rule files linked from SKILL.md +├── contracts/ # (optional) generated machine-readable contracts (e.g. react-blocks) +└── evals/ # skill eval fixtures — used by maintainers to score the skill, + # inert (but harmless) in consumer installs ``` `SKILL.md` frontmatter fields: diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index ac511bf4da..af1ab30d44 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -1,7 +1,8 @@ # objectstack-ai — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -11,6 +12,8 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/ai/agent.zod.ts` — AI Model Configuration - `node_modules/@objectstack/spec/src/ai/conversation.zod.ts` — AI Conversation Memory Protocol - `node_modules/@objectstack/spec/src/ai/embedding.zod.ts` — Embedding & Vector Store Primitives +- `node_modules/@objectstack/spec/src/ai/knowledge-document.zod.ts` — Knowledge Document / Chunk / Hit — canonical shapes shared by every +- `node_modules/@objectstack/spec/src/ai/knowledge-source.zod.ts` — Knowledge Source — declarative metadata describing what to index and - `node_modules/@objectstack/spec/src/ai/mcp.zod.ts` — Model Context Protocol (MCP) — Reference & Binding Primitives - `node_modules/@objectstack/spec/src/ai/model-registry.zod.ts` — AI Model Registry Protocol - `node_modules/@objectstack/spec/src/ai/skill.zod.ts` — Skill Trigger Condition Schema @@ -23,7 +26,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) ## How to read these @@ -33,5 +35,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md index f49bb97771..fc712ffdf1 100644 --- a/skills/objectstack-api/references/_index.md +++ b/skills/objectstack-api/references/_index.md @@ -1,7 +1,8 @@ # objectstack-api — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -28,7 +29,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. ## How to read these @@ -37,5 +37,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 8a914ac250..9e781455f1 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -1,7 +1,8 @@ # objectstack-automation — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -13,15 +14,16 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/automation/flow.zod.ts` — Flow Node Types — **built-in seed set** (ADR-0018). - `node_modules/@objectstack/spec/src/automation/node-executor.zod.ts` — Node Executor Plugin Protocol — Wait Node Pause/Resume - `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol +- `node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts` — Time-Relative Trigger Protocol - `node_modules/@objectstack/spec/src/automation/trigger-registry.zod.ts` — Trigger Registry Protocol - `node_modules/@objectstack/spec/src/automation/webhook.zod.ts` — Webhook Trigger Event +- `node_modules/@objectstack/spec/src/data/validation.zod.ts` — ObjectStack Validation Protocol ## Transitive dependencies - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) ## How to read these @@ -31,5 +33,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 17380b0116..0c5e0c423f 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -1,7 +1,8 @@ # objectstack-data — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -21,14 +22,11 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) -- `node_modules/@objectstack/spec/src/kernel/public-auth-features.ts` — Public auth feature-flag registry (#2874) - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) -- `node_modules/@objectstack/spec/src/shared/visibility.ts` — Conditional-visibility predicate normalization (ADR-0089) - `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema - `node_modules/@objectstack/spec/src/ui/responsive.zod.ts` — Breakpoint Name Enum @@ -42,5 +40,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-formula/references/_index.md b/skills/objectstack-formula/references/_index.md new file mode 100644 index 0000000000..6b540e9400 --- /dev/null +++ b/skills/objectstack-formula/references/_index.md @@ -0,0 +1,25 @@ +# objectstack-formula — Schema References + +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). + +Schemas live in the published `@objectstack/spec` package. Read them directly +from `node_modules` — there is no local copy in the skill bundle. + +## Core schemas + +- `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes +- `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol + +## How to read these + +1. The schemas are runtime Zod definitions. Use `Read` on the absolute + path under `node_modules/@objectstack/spec/src/` to inspect field shapes, + `.describe()` text, enums, and refinements. +2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the + matching subpath export). +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-i18n/references/_index.md b/skills/objectstack-i18n/references/_index.md index 4705c65c97..995e7c645f 100644 --- a/skills/objectstack-i18n/references/_index.md +++ b/skills/objectstack-i18n/references/_index.md @@ -1,7 +1,8 @@ # objectstack-i18n — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -11,10 +12,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/system/translation.zod.ts` — Field Translation Schema - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema -## Transitive dependencies - -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - ## How to read these 1. The schemas are runtime Zod definitions. Use `Read` on the absolute @@ -22,5 +19,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index f852885ecb..a4226f9376 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -1,7 +1,8 @@ # objectstack-platform — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -29,10 +30,8 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/metadata-customization.zod.ts` — Metadata Customization Layer Protocol - `node_modules/@objectstack/spec/src/kernel/metadata-loader.zod.ts` — Metadata Loader Protocol - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) -- `node_modules/@objectstack/spec/src/kernel/public-auth-features.ts` — Public auth feature-flag registry (#2874) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/system/tenant.zod.ts` — Tenant Schema (Multi-Tenant Architecture) - `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema @@ -47,5 +46,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-query/references/_index.md b/skills/objectstack-query/references/_index.md index 46f12752e7..0418e32b8d 100644 --- a/skills/objectstack-query/references/_index.md +++ b/skills/objectstack-query/references/_index.md @@ -1,20 +1,18 @@ # objectstack-query — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. ## Core schemas +- `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node -## Transitive dependencies - -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - ## How to read these 1. The schemas are runtime Zod definitions. Use `Read` on the absolute @@ -22,5 +20,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index 2041eee2b8..39919f86b6 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -1,7 +1,8 @@ # objectstack-ui — Schema References -> **Auto-generated** by `packages/spec/scripts/build-skill-references.ts`. -> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update. +> **Auto-generated** — do not edit. Maintainers regenerate this in the +> framework repo with `pnpm --filter @objectstack/spec run gen:skill-refs` +> (not runnable in an installed app). Schemas live in the published `@objectstack/spec` package. Read them directly from `node_modules` — there is no local copy in the skill bundle. @@ -13,6 +14,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/ui/chart.zod.ts` — Unified Chart Type Taxonomy - `node_modules/@objectstack/spec/src/ui/component.zod.ts` — Empty Properties Schema - `node_modules/@objectstack/spec/src/ui/dashboard.zod.ts` — Color variant for dashboard widgets (e.g., KPI cards). +- `node_modules/@objectstack/spec/src/ui/dataset.zod.ts` — Analytics Dataset — the one semantic layer (ADR-0021). - `node_modules/@objectstack/spec/src/ui/page.zod.ts` — Page Region Schema - `node_modules/@objectstack/spec/src/ui/report.zod.ts` — Report Type Enum - `node_modules/@objectstack/spec/src/ui/theme.zod.ts` — Color Palette Schema @@ -25,15 +27,13 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. +- `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) -- `node_modules/@objectstack/spec/src/kernel/public-auth-features.ts` — Public auth feature-flag registry (#2874) - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: AggregationFunctionEnum, SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema -- `node_modules/@objectstack/spec/src/shared/lazy-schema.ts` — Wrap a Zod schema constructor so its body is only evaluated on first use. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) -- `node_modules/@objectstack/spec/src/shared/visibility.ts` — Conditional-visibility predicate normalization (ADR-0089) - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema - `node_modules/@objectstack/spec/src/ui/keyboard.zod.ts` — Focus Trap Configuration Schema - `node_modules/@objectstack/spec/src/ui/responsive.zod.ts` — Breakpoint Name Enum @@ -47,5 +47,7 @@ from `node_modules` — there is no local copy in the skill bundle. `.describe()` text, enums, and refinements. 2. TypeScript types: `import type { … } from '@objectstack/spec'` (or the matching subpath export). -3. Runtime values: `import { … } from '@objectstack/spec'` — the package - re-exports every schema and helper. +3. Runtime values: import from the **matching subpath** shown in the + schema's directory (`'@objectstack/spec/data'`, `'@objectstack/spec/ai'`, …). + The root barrel re-exports the common factories, but not every symbol — + when in doubt, use the subpath. From e0eaa76fbf90c5f90b6e27a0a581b20e710dddd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:13:30 +0000 Subject: [PATCH 02/12] skills(ai): rebuild RAG/tool guidance on the real spec surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found the skill teaching schemas that don't exist in @objectstack/spec 16.x: - The 'RAG Pipeline Configuration' section (sources[]/indexes[]/ retrieval{topK,scoreThreshold,reranker}) matched no schema — the spec explicitly scopes chunking/retrieval/reranking out of the platform. Rewritten around KnowledgeSourceSchema (object/file/http sources, contentFields, where, adapter binding, refresh policy), with an os:check'd example, and honest registration guidance (IKnowledgeService.registerSource — no defineStack collection). - Tool section now teaches first-class defineTool metadata (category enum, JSON-Schema parameters, required label) with the spec's own READ-ONLY-PROJECTION caveat (hand-authored tool metadata does not execute in the open edition), separated from the legacy inline agent tools[] shape. - structuredOutput: retry:{maxAttempts} → maxRetries (real field); guardrails pitfall no longer cites nonexistent requireApprovalFor. - Agent-level table no longer lists temperature/maxTokens (they live under model); adds surface/planning/memory/permissions. - Coherence: description and blueprint now lead with skills + tools + knowledge sources; *.agent.ts flagged platform-internal (ADR-0063 §2) instead of being taught to third parties against a nonexistent src/{agents,skills,rag} reference layout. - Auto-exposed actions + HITL sections labeled Cloud/EE runtime; actionSkipReason import caveated as cloud-only. - evals/README.md: replace data-skill boilerplate with AI-domain evals. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-ai/SKILL.md | 317 +++++++++++++++++--------- skills/objectstack-ai/evals/README.md | 20 +- 2 files changed, 214 insertions(+), 123 deletions(-) diff --git a/skills/objectstack-ai/SKILL.md b/skills/objectstack-ai/SKILL.md index a42771adab..8519991b7e 100644 --- a/skills/objectstack-ai/SKILL.md +++ b/skills/objectstack-ai/SKILL.md @@ -1,35 +1,37 @@ --- name: objectstack-ai description: > - Design ObjectStack AI agents, tools, skills, conversations, model registry - entries, and MCP integrations. Use when the user is adding `*.agent.ts` / - `*.tool.ts` / `*.skill.ts`, configuring an LLM provider, wiring agent - tools, or designing an embedding/RAG flow on top of ObjectStack data. Do - not use for general LLM prompting questions unrelated to ObjectStack - metadata. + Design ObjectStack AI skills, tools, knowledge sources, conversations, + model registry entries, and MCP integrations. Use when the user is adding + `*.skill.ts` / `*.tool.ts`, configuring an LLM provider, wiring agent + tools, or indexing ObjectStack data as a knowledge source for RAG. Agents + themselves are platform-internal (`ask` / `build`) — third parties extend + them via skills and tools, not by authoring `*.agent.ts`. Do not use for + general LLM prompting questions unrelated to ObjectStack metadata. license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.1" + version: "1.2" domain: ai tags: agent, tool, skill, conversation, llm, embedding, mcp --- # AI Agent Design — ObjectStack AI Protocol -Expert instructions for designing AI-powered agents, skills, tools, and RAG -pipelines using the ObjectStack specification. This skill covers the -Agent → Skill → Tool three-tier architecture aligned with Salesforce -Agentforce, Microsoft Copilot Studio, and ServiceNow Now Assist patterns. +Expert instructions for designing AI skills, tools, and knowledge sources — +and the platform agents they plug into — using the ObjectStack specification. +This skill covers the Agent → Skill → Tool three-tier architecture aligned with +Salesforce Agentforce, Microsoft Copilot Studio, and ServiceNow Now Assist +patterns. -> **Edition boundary (cloud ADR-0025 — `service-ai → cloud; open = MCP-only`).** +> **Edition boundary (`service-ai` → cloud; open = MCP-only).** > The in-UI AI **runtime** — the `ask` / `build` agents, in-product chat, and the > `/api/v1/ai/*` routes (`@objectstack/service-ai`) — ships in the **cloud / > Enterprise** distribution, not the open framework. The agent / skill / tool -> **schemas** in `@objectstack/spec/ai` stay open, so you author `*.agent.ts` / -> `*.skill.ts` / `*.tool.ts` as source either way — but they only execute in a -> cloud / EE host. On the **open edition** there is no in-product agent: expose the +> **schemas** in `@objectstack/spec/ai` stay open, so you author `*.skill.ts` / +> `*.tool.ts` as source either way (`*.agent.ts` is platform-internal) — but +> they only execute in a cloud / EE host. On the **open edition** there is no in-product agent: expose the > app to your own AI via `@objectstack/mcp` (BYO-AI) for data query, and author > metadata in **source mode** with an AI coding agent (Claude Code, Cursor). @@ -37,11 +39,14 @@ Agentforce, Microsoft Copilot Studio, and ServiceNow Now Assist patterns. ## When to Use This Skill -- You are creating an **AI agent** with a specific role and capabilities. -- You need to define **skills** — bundles of related tools an agent can use. +- You need to define **skills** — bundles of related tools bound to the + `ask` / `build` surfaces. - You are configuring **tools** for data queries, actions, or integrations. -- You want to set up a **RAG pipeline** for knowledge retrieval. -- You are choosing and configuring **LLM models** for your agent. +- You want to index ObjectStack data as a **knowledge source** for RAG + retrieval. +- You are choosing and configuring **LLM models** (model registry). +- You need to read or review **agent** configuration — platform-internal; + third parties extend agents via skills, not by authoring them. --- @@ -75,10 +80,10 @@ never picks from a roster; the surface they are in selects the agent: - **`ask`** — the **data product** (≈ Claude Chat). Conversational read / query / explore over records, plus running the business **actions** the app already exposes. End-user audience, RLS-bounded. Canonical id `ask` (`ASK_AGENT_NAME`). - **Cloud / Enterprise** — the `ask` runtime moved from the open framework into the - cloud AI runtime (`@objectstack/service-ai` → `cloud/packages/service-ai`, closed) - per cloud ADR-0025; it is the implicit copilot for any cloud / EE app that does - not pin `app.defaultAgent`. (Open editions have no in-product `ask`; use MCP.) + **Cloud / Enterprise** — the `ask` runtime ships in the closed cloud AI runtime + (`@objectstack/service-ai`); it is the implicit copilot for any cloud / EE app + that does not pin `app.defaultAgent`. (Open editions have no in-product `ask`; + use MCP.) - **`build`** — the **authoring product** (≈ Claude Code). Agentic authoring of *metadata* (objects, fields, views, flows) through plan → draft → verify → publish. Builder audience, governance-gated. Canonical id `build`. Cloud-only · @@ -126,20 +131,27 @@ To grant data exploration to your own (platform-internal) agent, add > instead of dead-ending — this is intentional tiering. Do not assume authoring > tools resolve in the open framework. -> **`visualize_data` (#1820/#1821):** the only built-in tool that draws a chart — +> **`visualize_data`:** the only built-in tool that draws a chart — > it aggregates an object and emits an inline `data-chart` part. Auto-registered > **only** when an analytics service (`IAnalyticsService`) is wired; `query_data` / > `aggregate_data` return numbers, not charts. > **Ops:** set `AI_DAILY_USER_MESSAGES=` to cap user turns per user per day -> (ADR-0040 §5; backed by the `ai_usage_daily` object, no-op if unset). Adapter -> health is observable at `GET /api/v1/ai/status`; invalid `ai` settings are -> rejected at save time (#1788). +> (backed by the `ai_usage_daily` object; no-op if unset). Adapter health is +> observable at `GET /api/v1/ai/status`; invalid `ai` settings are rejected at +> save time. --- ## Agent Configuration +> **Reference only — third parties do not author agents.** The `agent` type is +> closed (`allowRuntimeCreate:false`; ADR-0063 §2): the platform ships exactly +> `ask` and `build`, maintained by platform / cloud plugin authors. You extend +> the platform with **skills + tools** (and knowledge sources) — never by +> adding an agent. This section documents `AgentSchema` for reading existing +> agents and for platform-internal work. + ### Required Properties | Property | Type | Description | @@ -155,14 +167,19 @@ To grant data exploration to your own (platform-internal) agent, add |:---------|:--------| | `skills` | Array of skill names — **primary capability model** | | `tools` | Direct tool references — legacy fallback | -| `model` | LLM model configuration | -| `knowledge` | RAG knowledge sources | -| `guardrails` | Safety constraints and topic restrictions | +| `surface` | `'ask' \| 'build'` — the product surface this agent is (default `'ask'`) | +| `model` | LLM model configuration — `provider`, `model`, `temperature`, `maxTokens`, `topP` | +| `knowledge` | RAG access — `sources` (canonical; `topics` is a deprecated alias) + `indexes` | +| `guardrails` | `maxTokensPerInvocation`, `maxExecutionTimeSec`, `blockedTopics` | | `structuredOutput` | Output format (JSON schema, regex, etc.) | -| `temperature` | LLM creativity level (0.0–2.0) | -| `maxTokens` | Response token limit | +| `planning` | Autonomous reasoning — `maxIterations` (default 10) | +| `memory` | `longTerm` persistence + `reflectionInterval` | +| `permissions` | Permission-set capabilities required to use the agent | | `active` | Enable/disable the agent | +There is **no top-level `temperature` / `maxTokens`** on an agent — sampling +parameters live under `model` (`AIModelConfigSchema`). + ### Agent Example @@ -210,18 +227,19 @@ trigger conditions. |:---------|:-----|:------------| | `name` | `snake_case` | Unique skill identifier (`/^[a-z_][a-z0-9_]*$/`) | | `label` | string | Human-readable name | -| `tools` | `string[]` | Tool names this skill grants access to | -| `active` | boolean | Is the skill enabled (default: `true`) | +| `tools` | `string[]` | Tool names this skill grants access to (trailing wildcard allowed, e.g. `action_*`) | ### Important Optional Properties | Property | Purpose | |:---------|:--------| +| `surface` | `'ask' \| 'build' \| 'both'` — agent surface affinity (default `'ask'`; see above) | | `description` | What the skill does — helps the agent decide when to use it | | `instructions` | LLM prompt guidance specific to this skill's context | | `triggerPhrases` | Natural language phrases that activate the skill | | `triggerConditions` | Programmatic activation rules | | `permissions` | Required permission profiles/roles | +| `active` | Is the skill enabled (default: `true`) | ### Skill Example @@ -252,7 +270,7 @@ export default defineSkill({ 'Escalate this issue', ], triggerConditions: [ - { field: 'object', operator: 'eq', value: 'support_case' }, + { field: 'objectName', operator: 'eq', value: 'support_case' }, ], permissions: ['support_agent', 'support_admin'], active: true, @@ -275,33 +293,68 @@ export default defineSkill({ Tools are the atomic operations that skills expose to agents. -### Tool Types +### First-Class Tool Metadata (`defineTool`) -| Type | Purpose | Example | -|:-----|:--------|:--------| -| `action` | Trigger a server-side action | "Close case", "Send email" | -| `flow` | Launch a flow | "Reset password flow" | -| `query` | Query ObjectStack records | "Get open cases for account" | -| `vector_search` | Semantic search over embeddings | "Find similar articles" | - -### Tool Definition +A tool authored as metadata (`type: 'tool'`, `*.tool.ts`) is validated by +`ToolSchema`: required `name` / `label` / `description`, a **JSON Schema** +`parameters` object, plus optional `category`, `objectName`, `outputSchema`, +`requiresConfirmation` (default `false`), `permissions`, `active`. + ```typescript -{ - name: 'query_support_case', - type: 'query', - object: 'support_case', - description: 'Search support cases by any combination of filters.', +import { defineTool } from '@objectstack/spec'; + +export default defineTool({ + name: 'create_case', + label: 'Create Support Case', + description: 'Creates a new support case record', + category: 'action', parameters: { - status: { type: 'string', description: 'Filter by case status' }, - account_id: { type: 'string', description: 'Filter by account ID' }, - priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] }, + type: 'object', + properties: { + subject: { type: 'string', description: 'Case subject' }, + priority: { type: 'string', enum: ['low', 'medium', 'high'] }, + }, + required: ['subject'], }, -} + objectName: 'support_case', + requiresConfirmation: true, +}); ``` +The optional `category` classifies the tool's operational domain: + +| Category | Purpose | +|:---------|:--------| +| `data` | CRUD / query operations | +| `action` | Side-effect actions (send email, create record) | +| `flow` | Trigger a visual flow | +| `integration` | External API / webhook calls | +| `vector_search` | RAG / vector search | +| `analytics` | Aggregation & reporting | +| `utility` | Formatters, parsers, helpers | + +> **Tool metadata is a read-only projection — not an execution entry point.** +> `ToolSchema` has no `handler` / `implementation` field, and no framework +> executor loads a metadata-authored tool. The runtime executes a +> separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); +> tool metadata is a one-way projection for Studio / discovery. Do not expect a +> hand-authored tool to run in the open edition (liveness audit #1878/#1892). + +### Inline Agent `tools[]` (legacy) + +Entries in an agent's inline `tools[]` array are a **different, legacy shape** +(`AIToolSchema`): `{ type: 'action' | 'flow' | 'query' | 'vector_search', +name, description? }` — references to existing actions / flows / queries, not +tool definitions. Prefer skills + first-class tool names. + ### Auto-Exposed Actions +> **Cloud / EE runtime.** `registerActionsAsTools()`, `AIServicePlugin`, and +> the HITL approval queue below ship in `@objectstack/service-ai` — the closed +> cloud / Enterprise runtime, not an open package. On the open edition, expose +> actions to your own AI via `@objectstack/mcp` instead. + You usually **don't author tool definitions by hand** for action invocation. Every `Action` you attach to an object via `defineObject({ actions: [...] })` is auto-exposed as a tool named `action_` by `registerActionsAsTools()` (invoked from `AIServicePlugin`). Three action types dispatch headlessly: @@ -319,10 +372,13 @@ Three action types dispatch headlessly: **`type:'api'` body assembly** (last wins): user params → `recordIdParam` (using `recordIdField`, default `'id'`) → `bodyExtra`. `bodyShape: { wrap: 'data' }` nests user params under `data` while keeping `recordIdParam` flat. -Use `actionSkipReason(action, ctx)` (exported from `@objectstack/service-ai`) when authoring an action and you want to know *why* it isn't surfacing in chat. Studio's "AI exposure" diagnostics use the same predicate. Pair with `actionRequiresApproval(action)` to know whether a registered action will be routed through HITL. +Use `actionSkipReason(action, ctx)` (exported from `@objectstack/service-ai` — cloud-only, not importable on the open edition) when authoring an action and you want to know *why* it isn't surfacing in chat. Studio's "AI exposure" diagnostics use the same predicate. Pair with `actionRequiresApproval(action)` to know whether a registered action will be routed through HITL. ### Human-In-The-Loop approval +> **Cloud / EE runtime.** The HITL approval queue is part of +> `@objectstack/service-ai` and is not available in the open framework. + ```ts kernel.use(new AIServicePlugin({ enableActionApproval: true, // opt in; default is false @@ -340,55 +396,78 @@ Programmatic API on `IAIService`: `proposePendingAction`, `approvePendingAction` --- -## RAG Pipeline Configuration +## Knowledge Sources (RAG) + +The platform's RAG primitive is the **KnowledgeSource** +(`KnowledgeSourceSchema` in `@objectstack/spec/ai`): declarative metadata +pairing *what to index* with the id of an `IKnowledgeAdapter` that does the +work. Sources are registered at runtime via +`IKnowledgeService.registerSource()` (there is no `defineStack` collection for +them), and the `search_knowledge` tool exposes registered sources to agents. -Retrieval-Augmented Generation gives agents access to domain knowledge. +### KnowledgeSource Structure -### RAG Pipeline Structure +| Property | Purpose | +|:---------|:--------| +| `id` | Snake_case source id | +| `label` / `description` | Display metadata | +| `adapter` | Adapter id (e.g. `'ragflow'`, `'memory'`), resolved via `IKnowledgeService.registerAdapter` | +| `adapterConfig` | Adapter-specific configuration (opaque to the service) | +| `source` | What gets indexed — discriminated on `kind`: `'object'` \| `'file'` \| `'http'` | +| `embedding` | Optional `EmbeddingModelSchema` ref (`provider`, `model`, `dimensions`) — adapters that manage embeddings internally (RAGFlow, Dify, Vectara) may ignore it | +| `vectorStore` | Optional `VectorStoreSchema` ref (`provider`, `collection`) — same caveat | +| `refresh` | `onRecordChange` (default `true` for object sources) + optional `cron` (surfaced for an external scheduler, not self-scheduled) | +| `aiExposed` | Whether `search_knowledge` may expose this source to agents (default `true`) | + +Source kinds: + +| `source.kind` | Fields | +|:--------------|:-------| +| `object` | `object`, `contentFields[]` (min 1; `*` = every readable text field), `metadataFields?`, `where?` (ObjectQL `where` syntax) | +| `file` | `prefix` (storage prefix, e.g. `kb/handbooks/`), `mimeTypes?` | +| `http` | `urls[]`, `userAgent?` | + +### Knowledge Source Example + ```typescript -{ - name: 'support_knowledge', +import { KnowledgeSourceSchema, type KnowledgeSource } from '@objectstack/spec/ai'; + +export const supportKb: KnowledgeSource = KnowledgeSourceSchema.parse({ + id: 'support_kb', label: 'Support Knowledge Base', - sources: [ - { - type: 'object', - object: 'knowledge_article', - fields: ['title', 'content', 'category'], - filter: [{ field: 'published', operator: 'equals', value: true }], - }, - { - type: 'document', - path: 'docs/support-handbook.md', - }, - ], - indexes: [ - { - name: 'article_embeddings', - model: 'text-embedding-3-small', - dimensions: 1536, - distanceMetric: 'cosine', - }, - ], - retrieval: { - topK: 5, - scoreThreshold: 0.75, - reranker: 'cohere-rerank-v3', + adapter: 'ragflow', // or 'memory' for dev/test + source: { + kind: 'object', + object: 'kb_article', + contentFields: ['title', 'body'], // concatenated into document content + metadataFields: ['category', 'owner_id'], // projected for search-time filtering + where: { published: true }, // index published articles only }, -} + refresh: { onRecordChange: true }, // re-index on record.* events +}); ``` -### RAG Best Practices - -1. **Chunk documents appropriately.** 500–1000 tokens per chunk with 100-token - overlap works well for most use cases. -2. **Set a `scoreThreshold`** to filter low-relevance results. Start with `0.7` - and tune. -3. **Use a reranker** for better precision when the initial retrieval returns - many candidates. -4. **Filter by published/active status** to avoid surfacing draft or archived - content. -5. **Index only searchable fields** — do not index system fields or IDs. +> **Chunking, top-K, score thresholds, and rerankers are NOT platform +> metadata.** The spec deliberately scopes them out (`embedding.zod.ts`): +> chunking strategies, retrieval pipelines, and RAG orchestration belong to +> the adapter (`adapterConfig`) or application code. The platform only carries +> the embed + vector primitives so any RAG strategy can be built on top. + +### Knowledge Source Best Practices + +1. **Filter with `where`.** Index only published/active records + (`where: { published: true }`) so draft or archived content never enters + the index. +2. **Index only meaningful text via `contentFields`.** Do not include system + fields or IDs; use `*` (all readable text fields) sparingly. +3. **Project filter fields into `metadataFields`** (e.g. `status`, `owner_id`, + `tags`) so searches can be narrowed at query time. +4. **Hide with `aiExposed: false`** when a source should be indexed but not + agent-searchable. +5. **Tune relevance in the adapter, not the metadata.** Top-K, thresholds, and + reranking are configured in your RAG backend (via `adapterConfig`), not in + ObjectStack metadata. --- @@ -444,10 +523,17 @@ structuredOutput: { }, required: ['summary', 'priority'], }, - retry: { maxAttempts: 3 }, + strict: true, // enforce exact schema compliance (default: false) + maxRetries: 3, // max retries on validation failure (default: 3) } ``` +On validation failure the runtime retries by default +(`retryOnValidationFailure: true`). Optional extras: `fallbackFormat` and a +`transformPipeline` of post-processing steps (`trim`, `parse_json`, +`validate`, `coerce_types`). There is no `retry` object — the knobs are +`retryOnValidationFailure` + `maxRetries`. + --- ## Common Pitfalls @@ -456,37 +542,44 @@ structuredOutput: { more. Be specific about what the agent should and should not do. 2. **Too many tools per skill.** Keep skills focused (3–8 tools). If a skill has 15+ tools, split it. -3. **Missing guardrails.** Always define `blockedTopics` and - `requireApprovalFor` destructive operations. +3. **Missing guardrails and approval gates.** Define `blockedTopics` (plus the + token / time budgets) in agent `guardrails`; for destructive operations put + a human in the loop — `requiresConfirmation: true` on the tool, + `approval: 'always'` on an MCP tool binding, `enableActionApproval: true` + (HITL queue, cloud) for auto-exposed actions. There is no + `requireApprovalFor` field. 4. **Ignoring tool descriptions.** The LLM uses tool `description` to decide when to call it. Poor descriptions = wrong tool selection. 5. **Not testing trigger phrases.** Ambiguous trigger phrases cause skill conflicts. Test with edge-case inputs. -6. **RAG without score threshold.** Without a threshold, low-relevance - passages pollute the context window and degrade responses. +6. **Indexing everything.** A knowledge source without a `where` filter and + curated `contentFields` fills the index with drafts and boilerplate that + pollute retrieval. Source hygiene is the metadata's job; relevance tuning + (top-K, thresholds, reranking) belongs to the adapter. --- -## CRM AI Blueprint (Agent + Skill + RAG) +## App AI Blueprint (Skills + Tools + Knowledge) -Reference implementation shape: `src/{agents,skills,rag}/` +Reference layout for a scaffolded app: -| Layer | CRM File | Pattern | +| Layer | File | Pattern | |:--|:--|:--| -| Persona agent | `agents/sales-copilot.agent.ts` | Keep agent role-focused; compose capabilities via `skills[]` | -| Reusable skill | `skills/lead-qualification.skill.ts` | Encode trigger phrases + trigger conditions + bounded toolset | -| Knowledge pipeline | `rag/sales-knowledge.rag.ts` | Define embedding, vector store, chunking, retrieval, reranking as metadata | -| Central registration | `agents/index.ts`, `skills/index.ts`, `rag/index.ts` | Export typed aggregates and register in `defineStack()` | +| Reusable skill | `src/skills/lead-qualification.skill.ts` | `defineSkill` — trigger phrases + trigger conditions + bounded toolset; pick a `surface` | +| Tool metadata | `src/tools/query-leads.tool.ts` | `defineTool` — JSON-Schema `parameters`; a discovery projection, not an executor (see caveat above) | +| Knowledge source | `src/knowledge/sales-kb.ts` | `KnowledgeSourceSchema` metadata, registered at runtime via `IKnowledgeService.registerSource()` | +| Central registration | `defineStack({ skills: [...], tools: [...] })` | `agents` / `tools` / `skills` are the only AI stack collections — knowledge sources have none; agents are platform-supplied | -Default for metadata apps: keep **few persona agents**, push business capability -logic into **skills**, and wire domain knowledge through **RAG pipelines**. +Default for metadata apps: push business capability logic into **skills**, keep +tools atomic, and wire domain knowledge through **knowledge sources**. --- ## Verify your work -After authoring a `*.agent.ts` / `*.tool.ts` / `*.skill.ts` or a model-registry -entry, run the author-time gate before reporting done: +After authoring a `*.skill.ts` / `*.tool.ts` (or platform-internal +`*.agent.ts`) or a model-registry entry, run the author-time gate before +reporting done: ```bash os validate # Zod schema + CEL predicate validation + bindings (no artifact) diff --git a/skills/objectstack-ai/evals/README.md b/skills/objectstack-ai/evals/README.md index d6e9bf6314..2bf7c8c981 100644 --- a/skills/objectstack-ai/evals/README.md +++ b/skills/objectstack-ai/evals/README.md @@ -12,16 +12,14 @@ When implemented, evals will follow this structure: ``` evals/ -├── naming/ -│ ├── test-object-names.md -│ ├── test-field-keys.md -│ └── test-option-values.md -├── relationships/ -│ ├── test-lookup-vs-master-detail.md -│ └── test-junction-patterns.md -├── validation/ -│ ├── test-script-inversion.md -│ └── test-state-machine.md +├── skills/ +│ ├── test-trigger-phrases.md +│ └── test-surface-affinity.md +├── tools/ +│ ├── test-json-schema-parameters.md +│ └── test-requires-confirmation.md +├── knowledge/ +│ └── test-knowledge-source-filters.md └── ... ``` @@ -42,5 +40,5 @@ Each eval file will contain: When adding evals: 1. Each eval should test a single, specific rule or pattern 2. Include both positive (correct) and negative (incorrect) examples -3. Reference the corresponding rule file in `rules/` +3. Reference the corresponding section of `SKILL.md` 4. Use realistic scenarios from actual ObjectStack projects From d7e5b3e6c3057cb7958dc671a905ad74c530c578 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:13:44 +0000 Subject: [PATCH 03/12] skills(formula): correct surfaces table and dialect set to the 16.x reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review findings applied: - P0: formula fields carry their CEL in `expression`, not `formula` (field.zod.ts; the engine reads def.expression) — the wrong key produced zod-stripped, never-evaluated formulas. - Dialect set is cel/cron/template — the js dialect was retired (#3278); procedural JS is the L2 ScriptBody surface, not an expression dialect. - daysFromNow/daysAgo are calendar-day functions (today() ± n at UTC midnight) — the time-of-day claim was inverted; today() documented as reference-timezone calendar day at UTC midnight (ADR-0053). - Removed surface rows that match no schema: View.criteria, Workflow.Task.dueDate, Workflow.criteria, ai/orchestration, ai/devops-agent, ai/agent-action, ai/nlq, ai/mcp.systemPrompt. - os.org context is { id, tier } — dropped os.org.slug/name examples; documented the current_user binding (ADR-0068). - titleFormat marked deprecated (→ nameField, ADR-0079); model-registry template keys corrected to promptTemplate.system/user. - Replaced monorepo-relative links (packages/, content/docs/) with node_modules paths that resolve in a consumer app; dropped ROADMAP/north-star pointers and the nonexistent build-twice CI claim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-formula/SKILL.md | 75 +++++++++++++++-------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index a52994dc55..de2625ab38 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -9,10 +9,10 @@ description: > predicate". Do not use for SQL fragments (driver-native), cron schedules (cron dialect), or L2 hook bodies (those belong in objectstack-data). license: Apache-2.0 -compatibility: Requires @objectstack/spec v4+ and @objectstack/formula +compatibility: Requires @objectstack/spec 16.x and @objectstack/formula 16.x (CEL) metadata: author: objectstack-ai - version: "1.0" + version: "1.1" domain: expression tags: cel, formula, predicate, condition, validation, visibility, seed-dynamic --- @@ -63,21 +63,24 @@ Every expression in metadata is the same envelope: ```ts type Expression = { - dialect: 'cel' | 'js' | 'cron' | 'template'; + dialect: 'cel' | 'cron' | 'template'; source?: string; ast?: unknown; meta?: { rationale?: string; generatedBy?: string }; }; ``` -**Four registered dialects** (M9.9): +**Three registered dialects**: | Dialect | Engine | Purpose | Helper | Example | |:-----------|:-----------------------|:--------------------------------------------------|:--------------|:---------------------------------------| | `cel` | `@marcbachmann/cel-js` | Computed values + boolean predicates | `` cel`...` `` / `` F`...` `` / `` P`...` `` | `` cel`record.amount * 1.1` `` | | `cron` | built-in validator | Recurring schedules | `` cron`...` `` | `` cron`0 6 * * MON` `` | | `template` | built-in interpolator | `{{path}}` text interpolation (notif/prompt/title) | `` tmpl`...` `` | `` tmpl`Hello {{record.first_name}}` ``| -| `js` | (sandboxed, future) | Edge cases needing arbitrary JS — avoid | n/a | reserved | + +There is **no `js` dialect** — it was retired (#3278). Procedural JavaScript is +the L2 `ScriptBody { language: 'js' }` authoring surface (hook bodies, mapping +transforms — see objectstack-data), not an expression dialect. **Authors emit the right dialect for the surface.** Bare strings on cron and template fields are auto-wrapped at validate time, but emitting the full @@ -98,7 +101,7 @@ scope as CEL — you do **not** learn three languages. | Current record field | `record.first_name` | | Previous record (update hooks) | `previous.status` | | Hook input payload | `input.amount` | -| Identity context | `os.user.id`, `os.org.slug`, `os.env` | +| Identity context | `os.user.id`, `os.org.id`, `os.org.tier`, `os.env` | | Equality | `==` / `!=` | | Logical | `&&` / `\|\|` / `!` | | Ternary | `cond ? a : b` | @@ -107,6 +110,10 @@ scope as CEL — you do **not** learn three languages. | Key existence (NOT null-safety) | `has(record.foo)` | | Null check | `record.foo == null` or `isBlank(record.foo)` | +The org context is `{ id, tier }` — there is no `os.org.slug` or `os.org.name`. +The evaluator also binds the current user as `current_user` (alias `user`) per +ADR-0068 — spec field docs write predicates like `current_user.positions`. + ### `has()` is NOT a null check `has(record.x)` is **true whenever the key exists**, even when its value is @@ -124,10 +131,10 @@ in `coalesce(..., '')`. ## ObjectStack CEL standard library Registered automatically. Source: -[`packages/formula/src/stdlib.ts`](../../packages/formula/src/stdlib.ts). +`node_modules/@objectstack/formula/src/stdlib.ts`. The canonical list is `CEL_STDLIB_FUNCTIONS` in -[`packages/formula/src/validate.ts`](../../packages/formula/src/validate.ts) — a +`node_modules/@objectstack/formula/src/validate.ts` — a test asserts every entry resolves at runtime, so this table stays in sync with it. **Dates** @@ -135,15 +142,15 @@ test asserts every entry resolves at runtime, so this table stays in sync with i | Function | Returns | Notes | |:---|:---|:---| | `now()` | timestamp | Current instant. Pinned per evaluation run; deterministic in build | -| `today()` | timestamp | UTC **start-of-day** (midnight) | -| `daysFromNow(n)` | timestamp | `now()` + `n` days — **keeps the current time-of-day** (NOT midnight) | -| `daysAgo(n)` | timestamp | `now()` − `n` days — keeps the current time-of-day | +| `today()` | timestamp | Reference-timezone **calendar day**, expressed as **UTC midnight** (not plain UTC start-of-day) | +| `daysFromNow(n)` | timestamp | Calendar-day: `today()` + `n` days, at **UTC midnight** (never carries time-of-day) | +| `daysAgo(n)` | timestamp | Calendar-day: `today()` − `n` days, at **UTC midnight** | | `daysBetween(a, b)` | int | Whole days from `a` to `b` (negative if `b` precedes `a`). `daysBetween(today(), record.due)` = days remaining | | `addDays(d, n)` | timestamp | Shift **any** date by `n` days (negative ok). `addDays(record.last_service, record.cycle_days)` = next due date | | `addMonths(d, n)` | timestamp | Shift **any** date by `n` months; clamps to month-end (`addMonths(date('2026-01-31'), 1)` → Feb 28) | | `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string to a timestamp | -> **No date arithmetic.** Do NOT write `end - start`, `date + n`, or `today() + 30` — CEL has no numeric arithmetic on dates, so these fault and the field silently nulls (the build now rejects them). Use `daysBetween(start, end)` for a span in days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date. Inclusive day span: `daysBetween(record.start_date, record.end_date) + 1`. Tenure in years: `daysBetween(record.hire_date, today()) / 365`. +> **No date arithmetic.** Do NOT write `end - start`, `date + n`, or `today() + 30` — CEL has no numeric arithmetic on dates, so these fault and the field silently nulls (the build now rejects them). Use `daysBetween(start, end)` for a span in days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date. Inclusive day span: `daysBetween(record.start_date, record.end_date) + 1`. Tenure in years: `daysBetween(record.hire_date, today()) / 365`. For a genuine sub-day offset use `now() + duration("3h")` — the calendar-day helpers always land on UTC midnight. **Numbers** @@ -229,7 +236,9 @@ For field-level conditional rules, emit the canonical field properties: `visibleWhen`, `readonlyWhen`, and `requiredWhen`. Treat `conditionalRequired` as a read/compatibility alias only. -❌ Salesforce-flavor — will compile but evaluate to `null`: +❌ Salesforce-flavor — **fails CEL compile**: `objectstack build` errors with a +located message, and the flow engine throws if it ever reaches runtime (see the +ADR-0032 note at the top of this skill): ```ts "status = 'qualified'" @@ -301,24 +310,24 @@ to the envelope. | Surface | Field | Dialect | |:---|:---|:---| -| `Field` | `formula` (when `type: 'formula'`) | cel | +| `Field` | `expression` (when `type: 'formula'`) | cel | | `Field` | `visibleWhen` / `readonlyWhen` / `requiredWhen` | cel | | `Field` | `conditionalRequired` (deprecated alias of `requiredWhen`) | cel | | `View` / `Page` | `visibleWhen` (form section/field, page component) | cel | | `Field` | `defaultValue` (M9.9b) | cel | | `ConditionalValidation` | `when` | cel | | `View` / `Page` | `visibleOn` / `visibility` (deprecated aliases of `visibleWhen`, ADR-0089) | cel | -| `View.criteria` | filter expression | cel | | `Action` | `disabled` | cel (or boolean) | | `Hook` | `condition` | cel | | `SharingRule` | `condition` | cel | | `Flow.decision` | `expression` / edge `condition` | cel (use `vars..`) | -| `Workflow.Task` | `dueDate` | cel (e.g. `cel\`daysFromNow(3)\``) | -| `Workflow` | `criteria` | cel | | `GraphQL.ComputedField` | `expression` | cel | | `Dataset.records[*]` | any value | cel (via `cel\`\``) | | `audit` / `metrics` / `tracing` | `condition` / `successCriteria` | structured \| cel | +View list filters are **not** a CEL surface — they are structured JSON filter +rules (`ViewFilterRuleSchema`), so do not emit CEL there. + ### Cron surfaces (recurring schedules) All accept bare strings (auto-wrapped to `{dialect:'cron', source}`) or the @@ -332,8 +341,6 @@ All accept bare strings (auto-wrapped to `{dialect:'cron', source}`) or the | `system/disaster-recovery.schedule` | backup + drill | | `automation/execution.cronExpression` | scheduled state | | `api/export.cronExpression` | scheduled exports (×2) | -| `ai/orchestration.cron` | recurring runs | -| `ai/devops-agent.iterationFrequency` | iteration cadence | ### Template surfaces (`{{ path }}` interpolation) @@ -361,17 +368,15 @@ tmpl`Deal {{ record.name }} — {{ record.amount | currency }} closes {{ record. | Surface | Field | |:---|:---| -| `Object.titleFormat` | record title | +| `Object.titleFormat` | record title — **deprecated** (→ `nameField`, ADR-0079) | | `system/notification` | email subject + body, SMS message, push body + message (5 fields) | -| `ai/model-registry` | systemPrompt, userPromptTemplate | -| `ai/agent-action` | subject, message | -| `ai/nlq.systemPrompt`, `ai/mcp.systemPrompt` | prompt templates | +| `ai/model-registry` | `promptTemplate.system`, `promptTemplate.user` | | `integration/connector/github` | titleTemplate, bodyTemplate (PR + release) | | `api/graphql` | cache key | -### JS surface (sandboxed body) - -Reserved for L2 hook bodies / mapping transforms. Use TypeScript source. +There is no JS expression surface: procedural JS is the L2 +`ScriptBody { language: 'js' }` surface (hook bodies), not an expression +dialect (#3278). --- @@ -397,11 +402,13 @@ explicit. ```ts import { tmpl } from '@objectstack/spec'; -titleFormat: tmpl`{{record.first_name}} {{record.last_name}}` -subject: tmpl`Welcome to {{os.org.name}}, {{os.user.name}}!` +subject: tmpl`Deal {{record.name}} needs review, {{os.user.name}}` +body: tmpl`{{record.name}} closes {{record.close_date | date:long}}` ``` Missing paths render as empty string. `Date` instances are ISO-formatted. +(`Object.titleFormat` also takes a template but is deprecated — use `nameField`, +ADR-0079.) --- @@ -413,11 +420,12 @@ Builds are deterministic only if: 2. CEL stdlib helpers honor the pinned `now` from `EvalContext`. 3. No expression source contains random / non-pure data. -CI runs `objectstack build` twice and asserts SHA-1 match. +Two consecutive `objectstack build` runs must produce byte-identical +`dist/objectstack.json` — spot-check by diffing the artifact. --- -## Open questions (track in ROADMAP M9.7+) +## Open questions - Authors will emit `ast` directly once `CelExprSchema` is published as JSON Schema for AI constrained decoding (M9.7). @@ -440,8 +448,5 @@ validator inline. ## See also -- [`content/docs/data-modeling/formulas.mdx`](../../content/docs/data-modeling/formulas.mdx) — human-facing guide -- [`packages/formula/`](../../packages/formula/) — engine + stdlib -- [`packages/spec/src/shared/expression.zod.ts`](../../packages/spec/src/shared/expression.zod.ts) — `Expression`, `ExpressionInput`, `cel` / `F` / `P` -- ROADMAP M9 — Expression Unification milestone -- north-star §8 — "No private expression DSL" +- `node_modules/@objectstack/formula/` — engine + stdlib +- `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — `Expression`, `ExpressionInput`, `cel` / `F` / `P` From 82e14b2aa1361c22eb0b17a625f5201fc08b2b70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:14:42 +0000 Subject: [PATCH 04/12] skills(i18n): restructure around the shipped objects.* translation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found the skill's narrative built on the aspirational o.* AppTranslationBundle pipeline while every shipped consumer (runtime resolvers, defineStack translations, coverage CLI, os i18n extract, example apps) runs on objects.* TranslationData: - Primary authoring path is now TranslationData + defineTranslationBundle wired via defineStack({ translations }) with an os:check'd example; AppTranslationBundle demoted to a clearly-labeled Studio/workbench secondary format with a do-not-register warning. - CLI truth: os i18n suggest / os i18n coverage don't exist — workflow now uses os i18n extract (--locales/--out/--fill, emits TS modules, not JSON) and os i18n check --locales / os lint --i18n-strict. - Interpolation is {{var}} (both shipped adapters) — single-brace examples silently no-op'd; ICU section marked EXPERIMENTAL quoting the schema's own not-enforced annotation. - getCoverage/suggestTranslations/getAppBundle/loadAppBundle flagged as optional contract methods with no shipped implementation. - localesDir loads only flat {locale}.json; fileOrganization has no runtime consumer — layouts labeled authoring conventions. - defaultLocale/supportedLocales marked required; cache/lazyLoad marked unenforced; _options keyed by field name; namespace prefixing claim dropped; unshipped contracts/i18n-service.ts pointer replaced with the subpath type import; evals README rewritten for i18n. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-i18n/SKILL.md | 594 ++++++++++++------------ skills/objectstack-i18n/evals/README.md | 21 +- 2 files changed, 313 insertions(+), 302 deletions(-) diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index 17a97bd39d..b0232b5a77 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -8,10 +8,10 @@ description: > resolving missing-translation warnings. Do not use for general i18n library questions unrelated to ObjectStack bundles. license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.1" + version: "1.2" domain: i18n tags: i18n, translation, locale, l10n, bundle, coverage --- @@ -30,14 +30,13 @@ and integration with the I18nService. - You are **configuring i18n** for a new ObjectStack project. - You need to **create translation bundles** for multiple locales. - You are designing **object-first translation structures** (per-object translation files). -- You need to **detect missing or stale translations** (coverage analysis). -- You are integrating **AI-powered translation suggestions**. +- You need to **detect missing translations** (`os i18n check` coverage analysis). +- You are extending the service contract with **AI translation suggestions** (TMS / machine-translation integrations). - You are implementing **locale-specific formatting** (dates, numbers, currency). - > Workspace regional defaults — reference `timezone`, `locale`, and **`currency`** - > — live in the `localization` SETTINGS (tenant-scoped), are resolved onto every - > request's `ExecutionContext`, and are exposed to the client at - > `GET /api/v1/auth/me/localization`. `localization.currency` is the fallback a - > currency field/measure uses when it omits its own (ADR-0053). + Related: workspace regional defaults (`timezone`, `locale`, `currency`) live in the + tenant-scoped `localization` settings, are resolved onto each request's + `ExecutionContext`, and are exposed at `GET /api/v1/auth/me/localization`; a currency + field falls back to `localization.currency` when it omits its own (ADR-0053). - You need to understand **translation file organization strategies** (bundled, per_locale, per_namespace). --- @@ -46,15 +45,25 @@ and integration with the I18nService. ### Translation Architecture Overview -ObjectStack follows an **object-first translation model** inspired by Salesforce and Dynamics 365: +1. **Runtime format — `objects.*` (`TranslationData`)**: each locale is authored as one + `TranslationData` value. All translatable content for an object (label, fields, + options, views, sections, actions) is grouped under `objects.{object_name}`, with + global groups (`apps`, `messages`, `validationMessages`, `globalActions`, + `dashboards`, `settings`, `metadataForms`) at the top level. -1. **Object-First Aggregation**: All translatable content for an object (labels, fields, options, views, actions) is grouped under a single namespace: `o.{object_name}`. +2. **Bundle registration**: per-locale files are assembled with + `defineTranslationBundle({ en, 'zh-CN': … })` into a `TranslationBundle` + (locale code → `TranslationData`) and registered via + `defineStack({ translations: [...] })`. This is the format the runtime resolvers, + `os i18n extract`, `os i18n check`, and the example apps all use. -2. **Global Groups**: Non-object-bound translations (apps, navigation, messages) live at the top level. +3. **Coverage detection**: `os i18n check` compares registered bundles against source + metadata to report missing keys per locale. -3. **Locale Files**: Each locale has its own complete translation bundle (e.g., `en.json`, `zh-CN.json`). - -4. **Coverage Detection**: The system can compare translation bundles against source metadata to identify missing, redundant, or stale entries. +4. **Secondary format — `o.*` (`AppTranslationBundle`)**: a separate object-first, + single-locale format aimed at translation-workbench UIs, Studio-authored + `translation` metadata, and the coverage-diff schemas. It is **not** what the stack + `translations` array consumes — see "Secondary Format: AppTranslationBundle" below. --- @@ -74,22 +83,21 @@ export default defineStack({ supportedLocales: ['en', 'zh-CN', 'ja-JP', 'es-ES'], fallbackLocale: 'en', fileOrganization: 'per_locale', - messageFormat: 'simple', // or 'icu' for complex plurals - lazyLoad: false, - cache: true, + messageFormat: 'simple', }, + // translations: [MyTranslations], ← register your bundles here (see below) }); ``` -| Property | Type | Default | Description | -|:---------|:-----|:--------|:------------| -| `defaultLocale` | `string` | `'en'` | Default BCP-47 locale code | -| `supportedLocales` | `string[]` | `['en']` | All supported locales | -| `fallbackLocale` | `string` | same as `defaultLocale` | Fallback when translation missing | -| `fileOrganization` | `'bundled'` \| `'per_locale'` \| `'per_namespace'` | `'per_locale'` | How translation files are organized | -| `messageFormat` | `'simple'` \| `'icu'` | `'simple'` | Interpolation format (ICU for plurals/gender) | -| `lazyLoad` | `boolean` | `false` | Load translations on demand | -| `cache` | `boolean` | `true` | Cache loaded translations in memory | +| Property | Type | Required / Default | Description | +|:---------|:-----|:-------------------|:------------| +| `defaultLocale` | `string` | **required** | Default BCP-47 locale code | +| `supportedLocales` | `string[]` | **required** | All supported locales | +| `fallbackLocale` | `string` | optional | Fallback when translation missing | +| `fileOrganization` | `'bundled'` \| `'per_locale'` \| `'per_namespace'` | `'per_locale'` | Declared authoring convention — no runtime consumer (see below) | +| `messageFormat` | `'simple'` \| `'icu'` | `'simple'` | `'icu'` is EXPERIMENTAL — not enforced (see Message Interpolation) | +| `lazyLoad` | `boolean` | `false` | Declared only — no runtime consumer yet | +| `cache` | `boolean` | `true` | EXPERIMENTAL — not enforced; no runtime consumer reads it | > **BCP-47 Locale Codes**: Use standard locale tags (e.g., `en-US`, `zh-CN`, `pt-BR`, `en-GB`). @@ -123,154 +131,155 @@ src/translations/ ### 3. Per-Namespace (Enterprise) -One file per namespace (object) per locale. Recommended for large projects with many objects/languages. Aligns with Salesforce DX and ServiceNow conventions. +One file per namespace (object) per locale. Aligns with Salesforce DX and ServiceNow conventions. ``` i18n/ en/ account.json # ObjectTranslationData contact.json - project_task.json common.json # messages + app labels zh-CN/ account.json contact.json - project_task.json common.json ``` **When to use:** Large projects (20+ objects), 5+ locales, team collaboration, CI/CD pipelines. ---- +> These are **authoring conventions**: your import graph assembles whichever layout you +> choose into the `TranslationBundle` values you register on the stack. The +> `fileOrganization` config field declares the convention but has no runtime consumer, +> and `FileI18nAdapter`'s `localesDir` loads only flat top-level `{locale}.json` files +> (subdirectories are skipped) — a per-namespace tree must be assembled by your own +> imports or build step. -## Object-First Translation Bundle +--- -### AppTranslationBundle Structure +## Authoring Translation Bundles (`objects.*`) -The `AppTranslationBundle` is the canonical format for a single locale: +The canonical authoring path: one `TranslationData` per locale, assembled with +`defineTranslationBundle` and registered on the stack. This mirrors the shipped +example apps (`src/translations/{en,zh-CN}.ts` + `index.ts`): + ```typescript -const zh: AppTranslationBundle = { - _meta: { - locale: 'zh-CN', - direction: 'ltr', - }, - - // Object-first translations - o: { - account: { - label: '客户', - pluralLabel: '客户', - description: '客户管理对象', +// src/translations/en.ts — one TranslationData per locale +import { defineStack, defineTranslationBundle } from '@objectstack/spec'; +import type { TranslationData } from '@objectstack/spec/system'; + +const en: TranslationData = { + objects: { + task: { + label: 'Task', + pluralLabel: 'Tasks', fields: { - name: { label: '客户名称', help: '公司或组织的法定名称' }, - industry: { - label: '行业', - options: { tech: '科技', finance: '金融', retail: '零售' } + subject: { label: 'Subject', help: 'Brief title of the task' }, + status: { + label: 'Status', + options: { + not_started: 'Not Started', + in_progress: 'In Progress', + completed: 'Completed', + }, }, - website: { label: '网站', placeholder: '输入网站地址' }, - }, - _options: { - status: { active: '活跃', inactive: '停用' }, + due_date: { label: 'Due Date' }, }, _views: { - all_accounts: { label: '全部客户' }, - my_accounts: { label: '我的客户' }, + all_tasks: { + label: 'All Tasks', + emptyState: { title: 'No tasks yet', message: 'Create your first task' }, + }, }, _sections: { - basic_info: { label: '基本信息' }, - contact_info: { label: '联系方式' }, + details: { label: 'Details' }, }, _actions: { - convert_lead: { label: '转换线索', confirmMessage: '确认转换为客户?' }, - merge: { label: '合并客户', confirmMessage: '此操作无法撤销,确认合并?' }, + complete: { + label: 'Complete', + confirmText: 'Mark this task as completed?', + successMessage: 'Task completed', + }, }, }, }, - - // Global picklist options (not object-specific) - _globalOptions: { - currency: { usd: '美元', eur: '欧元', cny: '人民币' }, + apps: { + todo_app: { label: 'Todo Manager', description: 'Personal task management' }, }, - - // App-level translations - app: { - crm: { label: '客户关系管理', description: '管理销售流程' }, - helpdesk: { label: '服务台', description: '客户支持系统' }, - }, - - // Navigation menu - nav: { - home: '首页', - settings: '设置', - reports: '报表', - admin: '管理', + messages: { + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'welcome.user': 'Welcome, {{userName}}!', }, - - // Dashboard translations - dashboard: { - sales_overview: { label: '销售概览', description: '销售漏斗与目标' }, + validationMessages: { + completed_date_required: 'Completed date is required when status is Completed', }, +}; - // Report translations - reports: { - pipeline_report: { label: '管道报表' }, +// src/translations/zh-CN.ts — same shape, translated values +const zhCN: TranslationData = { + objects: { + task: { + label: '任务', + pluralLabel: '任务', + fields: { + subject: { label: '主题', help: '任务的简要标题' }, + status: { + label: '状态', + options: { not_started: '未开始', in_progress: '进行中', completed: '已完成' }, + }, + due_date: { label: '截止日期' }, + }, + }, }, - - // Page translations - pages: { - landing: { title: '欢迎', description: '开始使用 ObjectStack' }, + apps: { + todo_app: { label: '待办管理', description: '个人任务管理' }, }, - - // UI messages (supports ICU MessageFormat if enabled) messages: { 'common.save': '保存', 'common.cancel': '取消', - 'common.delete': '删除', - 'common.confirm': '确认', - 'validation.required': '此字段为必填项', - 'pagination.showing': '显示 {start} 到 {end},共 {total} 条', - }, - - // Validation error messages - validationMessages: { - discount_limit: '折扣不能超过40%', - end_date_after_start: '结束日期必须晚于开始日期', + 'welcome.user': '欢迎,{{userName}}!', }, +}; - // Global notifications - notifications: { - record_created: { title: '创建成功', body: '记录已创建' }, - }, +// src/translations/index.ts — assemble the locales into one bundle… +export const TodoTranslations = defineTranslationBundle({ + en, + 'zh-CN': zhCN, +}); - // Global error messages - errors: { - 'ERR_NETWORK': '网络连接失败', - 'ERR_PERMISSION': '权限不足', - }, -}; +// objectstack.config.ts — …and register it on the stack +export default defineStack({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + translations: [TodoTranslations], +}); ``` +`defineTranslationBundle` validates the bundle at authoring time via `.parse()` — +prefer it over a bare `: TranslationBundle` literal. + --- ## Object-Level Translation Structure All translatable content for a single object is aggregated under -`o.{object_name}` with these sub-keys: +`objects.{object_name}` with these sub-keys: | Sub-key | Holds | |:--------|:------| -| `label` / `pluralLabel` / `description` / `helpText` | Object-level text | -| `fields.{field_name}` | `label`, `help`, `placeholder`, `options` per field | -| `_options.{picklist_name}` | Object-scoped picklist option labels | -| `_views.{view_name}` | View label / description | -| `_sections.{section_name}` | Form section / tab labels | -| `_actions.{action_name}` | Action label + `confirmMessage` | -| `_notifications.{key}` / `_errors.{code}` | Per-object messages | +| `label` / `pluralLabel` / `description` | Object-level text (`label` is required) | +| `fields.{field_name}` | `label`, `help`, `placeholder`, `options` (option value → label) per field | +| `_views.{view_name}` | `label`, `description`, `emptyState.title` / `emptyState.message` | +| `_actions.{action_name}` | `label`, `confirmText`, `successMessage`, `params.{param_name}`, `resultDialog` | +| `_sections.{section_name}` | Form section / tab `label`, `description` | + +Top-level groups alongside `objects`: `apps` (label, description, navigation), +`messages`, `validationMessages`, `globalActions` (object-less actions), +`dashboards`, `settings`, `metadataForms`, `settingsCommon`. For the exact Zod shape (and any field that may have been added since), read `node_modules/@objectstack/spec/src/system/translation.zod.ts` — -`ObjectTranslationNodeSchema` and `FieldTranslationSchema`. +`TranslationDataSchema`, `ObjectTranslationDataSchema`, and `FieldTranslationSchema`. --- @@ -279,7 +288,7 @@ For the exact Zod shape (and any field that may have been added since), read | Context | Convention | Example | |:--------|:-----------|:--------| | Locale codes | BCP-47 | `en`, `en-US`, `zh-CN`, `pt-BR` | -| Object keys in `o.*` | `snake_case` | `o.project_task`, `o.support_case` | +| Object keys in `objects.*` | `snake_case` | `objects.project_task`, `objects.support_case` | | Field keys | `snake_case` | `fields.first_name`, `fields.due_date` | | Option values | lowercase | `options.status.in_progress` | | Message keys | dot-separated | `common.save`, `validation.required` | @@ -288,72 +297,59 @@ For the exact Zod shape (and any field that may have been added since), read --- -## Translation Coverage & Diff Detection - -### Coverage Analysis - -The `II18nService.getCoverage()` method compares a translation bundle against source metadata to detect: - -1. **Missing** — Keys that exist in metadata but not in the translation bundle -2. **Redundant** — Keys in the bundle that have no matching metadata -3. **Stale** — Keys where the source metadata has changed since translation - -```typescript -const coverage = i18nService.getCoverage('zh-CN', 'account'); - -console.log(coverage); -// { -// locale: 'zh-CN', -// objectName: 'account', -// totalKeys: 120, -// translatedKeys: 105, -// missingKeys: 12, -// redundantKeys: 3, -// staleKeys: 0, -// coveragePercent: 87.5, -// items: [ -// { key: 'o.account.fields.website.label', status: 'missing', locale: 'zh-CN' }, -// ... -// ], -// breakdown: [ -// { group: 'fields', totalKeys: 45, translatedKeys: 40, coveragePercent: 88.9 }, -// { group: 'views', totalKeys: 8, translatedKeys: 8, coveragePercent: 100 }, -// ], -// } -``` - -### TranslationDiffItem - -Each item in `coverage.items` carries `key` (dot path), `status` -(`missing | redundant | stale`), `objectName`, `locale`, optional -`sourceHash` for stale detection, and AI-enrichment fields (`aiSuggested`, -`aiConfidence`) when `suggestTranslations()` has been run. Full Zod shape: -`node_modules/@objectstack/spec/src/system/translation.zod.ts` — -`TranslationDiffItemSchema`. - ---- +## Secondary Format: AppTranslationBundle (`o.*`) -## AI-Powered Translation Suggestions +`AppTranslationBundle` is a **separate, object-first format for a single locale** +where per-object content lives under `o.{object_name}`. It targets translation +workbench UIs, Studio-authored `translation` metadata, and the coverage/diff +schemas. **Do not** use it in the files you register through +`defineStack({ translations: [...] })` — the runtime resolvers read `objects.*` +(`TranslationData`). -### Using suggestTranslations() +Differences from the runtime format worth knowing: -The `II18nService.suggestTranslations()` method enriches diff items with AI-generated translations: +- Objects live under `o.*` (not `objects.*`); extra groups are `_meta`, + `_globalOptions`, `app`, `nav`, `dashboard`, `reports`, `pages`, + `notifications`, `errors`. +- `_options` is keyed by **field name** → `{ optionValue: label }` (not by picklist name). +- Actions use `confirmMessage` (the runtime format's `_actions` use `confirmText` / `successMessage`). +- `namespace` is a **declared** isolation field for multi-plugin bundles; no shipped + code prefixes keys with it. +- `_meta.direction: 'rtl'` lets UI frameworks apply RTL CSS for locales like Arabic. + ```typescript -const missingItems = coverage.items.filter(item => item.status === 'missing'); +import type { AppTranslationBundle } from '@objectstack/spec/system'; -const suggestions = await i18nService.suggestTranslations('zh-CN', missingItems); - -suggestions.forEach(item => { - console.log(`${item.key}: ${item.aiSuggested} (confidence: ${item.aiConfidence})`); - // o.account.fields.website.label: 网站 (confidence: 0.95) -}); +const zh: AppTranslationBundle = { + _meta: { locale: 'zh-CN', direction: 'ltr' }, + o: { + account: { + label: '客户', + pluralLabel: '客户', + fields: { + name: { label: '客户名称', help: '公司或组织的法定名称' }, + industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, + }, + _options: { + status: { active: '活跃', inactive: '停用' }, // keyed by FIELD name + }, + _views: { all_accounts: { label: '全部客户' } }, + _sections: { basic_info: { label: '基本信息' } }, + _actions: { + merge: { label: '合并客户', confirmMessage: '此操作无法撤销,确认合并?' }, + }, + }, + }, + _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, + app: { crm: { label: '客户关系管理', description: '管理销售流程' } }, + nav: { home: '首页', settings: '设置' }, + messages: { 'common.save': '保存' }, +}; ``` -> **Best Practice:** AI suggestions work best when: -> - You provide source locale context (e.g., English labels) -> - You include domain-specific glossaries -> - You review and approve suggestions before committing +Exact Zod shape: `node_modules/@objectstack/spec/src/system/translation.zod.ts` — +`AppTranslationBundleSchema` and `ObjectTranslationNodeSchema`. --- @@ -361,13 +357,16 @@ suggestions.forEach(item => { ### Simple Format (Default) -Use `{variable}` placeholders: +Both shipped adapters (`FileI18nAdapter` and the in-memory fallback) substitute +**double-brace** `{{variable}}` placeholders only — single braces pass through +unchanged. (The schema docstring mentions `{variable}` notation, but that is not +what the runtime implements.) ```json { "messages": { - "welcome": "Welcome, {userName}!", - "pagination": "Showing {start} to {end} of {total} items" + "welcome": "Welcome, {{userName}}!", + "pagination": "Showing {{start}} to {{end}} of {{total}} items" } } ``` @@ -378,29 +377,63 @@ i18n.t('messages.welcome', 'en', { userName: 'Alice' }); // "Welcome, Alice!" ``` -### ICU MessageFormat +### ICU MessageFormat [EXPERIMENTAL] -For complex pluralization, gender, and select: +`messageFormat: 'icu'` is accepted by the config schema but **not enforced**. The +schema's own liveness annotation reads: "[EXPERIMENTAL — 'icu' not enforced] No ICU +MessageFormat engine is wired; messageFormat:'icu' is accepted but interpolation +falls back to simple substitution." Until an engine ships, author messages for +simple `{{variable}}` substitution — ICU plural/select strings like +`{count, plural, one {1 message} other {# messages}}` will not be evaluated. -```typescript -// Enable in stack config -i18n: { messageFormat: 'icu' } -``` +--- -```json -{ - "messages": { - "inbox": "{count, plural, =0 {No messages} one {1 message} other {# messages}}", - "gender": "{gender, select, male {He} female {She} other {They}} replied" - } -} +## Translation Coverage + +### `os i18n check` + +The working coverage path is the CLI: + +```bash +os i18n check # every locale found in the config +os i18n check --locales=zh-CN # scope to specific locales +os i18n check --strict --threshold=95 # CI gate: locale parity + minimum coverage ``` -> **When to use ICU:** -> - Languages with complex plural rules (Arabic, Slavic languages) -> - Gender-aware translations -> - Ordinal numbers (1st, 2nd, 3rd) -> - Date/time/number formatting +It compares registered bundles against source metadata and reports missing +object/field/option/view/action keys per locale. Missing keys in the default +locale are errors; `--strict` promotes non-default gaps to errors and +`--show-keys` lists every missing key. `os lint --i18n-strict` folds the same +gate into linting. + +### Diff & Coverage Schemas + +The spec models coverage results for tooling: `TranslationCoverageResult` +(totals, `coveragePercent`, per-group `breakdown`) and `TranslationDiffItem` — +`key` (dot path), `status` (`missing | redundant | stale`), `locale`, optional +`sourceHash` for stale detection, and AI-enrichment fields (`aiSuggested`, +`aiConfidence`). Full Zod shape: +`node_modules/@objectstack/spec/src/system/translation.zod.ts` — +`TranslationCoverageResultSchema`, `TranslationDiffItemSchema`. + +These schemas back the **optional** contract methods `getCoverage()` and +`suggestTranslations()`, which **no shipped adapter implements** — point coverage +workflows at `os i18n check` / `os lint --i18n-strict` instead. + +--- + +## AI-Powered Translation Suggestions + +`II18nService.suggestTranslations(locale, items)` is an optional contract method +that enriches diff items with `aiSuggested` / `aiConfidence`. It is +**contract-only today**: no shipped adapter implements it, and there is no CLI +command for it. Implement it on a custom adapter to integrate: + +- Translation Management Systems (TMS) like Phrase, Crowdin, Lokalise +- Machine translation APIs (Google Translate, DeepL) +- Internal translation memory databases + +> **Best Practice:** Review and approve machine suggestions before committing them. --- @@ -408,21 +441,34 @@ i18n: { messageFormat: 'icu' } ### Service Contract -`II18nService` is the kernel service that loads bundles, resolves keys with -fallback chains, reports coverage, and (optionally) generates AI -suggestions. Methods: +`II18nService` is the kernel service (name `'i18n'`) that loads bundles and +resolves keys with fallback: + +```typescript +import type { II18nService } from '@objectstack/spec/contracts'; +``` + +(The contract's source `.ts` is not part of the published package — only +`src/**/*.zod.ts` ships — so import the type from `@objectstack/spec/contracts` +rather than reading `node_modules` source.) -- **`t(key, locale, params?)`** — resolve a single key with interpolation +Methods implemented by both shipped adapters (`FileI18nAdapter` from +`@objectstack/service-i18n`, and the in-memory fallback `@objectstack/core` +registers when no i18n plugin is present): + +- **`t(key, locale, params?)`** — dot-path resolution (e.g. `objects.account.label`) + with `{{param}}` interpolation and fallback-locale lookup - **`getTranslations(locale)`** — full snapshot for a locale -- **`loadTranslations(locale, bundle)`** — programmatic load +- **`loadTranslations(locale, data)`** — programmatic load; deep-merges, so multiple + plugins can each contribute their own `objects.*` slice - **`getLocales()`** / **`getDefaultLocale()`** / **`setDefaultLocale()`** -- **`getAppBundle(locale)`** / **`loadAppBundle(locale, bundle)`** — object-first format -- **`getCoverage(locale, objectName?)`** — diff vs metadata -- **`suggestTranslations(locale, items)`** — AI-fill missing keys -Full TypeScript signature lives in -`node_modules/@objectstack/spec/src/contracts/i18n-service.ts` — read it -when you need exact parameter shapes. +The in-memory fallback additionally resolves locale codes +(exact → case-insensitive → base language `zh-CN` → `zh` → variant `zh` → `zh-CN`). + +The contract also declares optional methods — `getAppBundle`, `loadAppBundle`, +`getCoverage`, `suggestTranslations` — that **no shipped implementation provides**. +Treat them as extension points for a custom workbench or TMS adapter. ### Plugin Setup @@ -444,39 +490,45 @@ await kernel.bootstrap(); const i18n = kernel.getService('i18n'); ``` +> `localesDir` loads only flat, top-level `{locale}.json` files from the directory +> (subdirectories are skipped). `registerRoutes: true` (the default) self-registers +> `GET {basePath}/locales`, `/translations/:locale`, and `/labels/:object/:locale` +> once an HTTP server is available. + --- ## Translation Workflow Best Practices -### 1. Extract Keys from Metadata +### 1. Extract Skeletons from Metadata -Use the CLI or API to extract all translatable keys from your metadata: +Scaffold ready-to-edit translation files from your stack config: ```bash -objectstack i18n extract --locale zh-CN --output i18n/zh-CN.json +os i18n extract --locales=zh-CN --out=./src/translations ``` -This generates a skeleton bundle with all required keys. +This writes `.objects.generated.ts` TypeScript modules (not JSON) — the +default locale is filled from schema labels, other locales follow `--fill` +(`empty | default | todo`). Other flags: `--default-locale`, `--filter` (regex +over object/app names or key paths), `--dry-run`, `--json`. ### 2. Translate -Fill in the translations manually, or use AI suggestions: +Fill in the values manually. (AI suggestion is a contract-only concept — +`suggestTranslations()` has no CLI and no shipped implementation.) -```bash -objectstack i18n suggest --locale zh-CN --input i18n/zh-CN.json -``` - -### 3. Validate Coverage - -Run coverage analysis to detect missing or stale translations: +### 3. Verify Coverage ```bash -objectstack i18n coverage --locale zh-CN +os i18n check --locales=zh-CN ``` -### 4. Commit & Deploy +Add `--strict` / `--threshold=95` in CI to fail on locale gaps. -Commit translation files to version control. ObjectStack automatically loads them at runtime. +### 4. Commit & Register + +Commit the translation files, import them into your bundle, and register it via +`defineStack({ translations: [...] })`. --- @@ -492,65 +544,30 @@ Use this structure for metadata apps: | Layer | CRM Pattern | |:--|:--| | Stack config | `i18n.fileOrganization = 'per_locale'` with explicit locale list | -| Translation assembly | One `TranslationBundle` that imports per-locale files | -| Locale content | Object-scoped translations (`objects.account.fields.*`, `_views`, `_actions`) + global app/nav/messages | +| Translation assembly | One `defineTranslationBundle` call that imports per-locale files | +| Locale content | Object-scoped translations (`objects.account.fields.*`, `_views`, `_actions`) + global app/messages | | Naming integrity | Translation object/field keys exactly match metadata machine names | -For new locales, copy one locale file as a baseline, then run coverage before release. +For new locales, copy one locale file as a baseline, then run `os i18n check` +before release. --- -## Advanced Patterns +## Common Pitfalls -### Namespace Isolation (Multi-Plugin) +### ❌ Studio Shape in Runtime Bundles -When multiple plugins contribute translations, use namespaces to avoid collisions: +The runtime resolvers read `objects.*` — the `o.*` shape belongs to the +secondary `AppTranslationBundle` format only: ```typescript -const crmBundle: AppTranslationBundle = { - namespace: 'crm', - o: { - account: { label: '客户' }, - }, -}; +// Registered via defineStack({ translations }) — WRONG +{ o: { account: { label: '客户' } } } -const helpdeskBundle: AppTranslationBundle = { - namespace: 'helpdesk', - o: { - ticket: { label: '工单' }, - }, -}; -``` - -Keys are prefixed: `crm.o.account.label`, `helpdesk.o.ticket.label`. - -### Right-to-Left (RTL) Support - -```typescript -const ar: AppTranslationBundle = { - _meta: { - locale: 'ar', - direction: 'rtl', - }, - o: { - account: { label: 'حساب' }, - }, -}; +// CORRECT (TranslationData) +{ objects: { account: { label: '客户' } } } ``` -UI frameworks can use `_meta.direction` to apply RTL CSS. - -### Translation Memory Integration - -Implement custom `II18nService.suggestTranslations()` to integrate with: -- Translation Management Systems (TMS) like Phrase, Crowdin, Lokalise -- Machine translation APIs (Google Translate, DeepL) -- Internal translation memory databases - ---- - -## Common Pitfalls - ### ❌ Mismatched Object Names Translation keys must match metadata exactly: @@ -560,10 +577,10 @@ Translation keys must match metadata exactly: { name: 'project_task' } // Translation (WRONG) -{ o: { projectTask: { label: '项目任务' } } } +{ objects: { projectTask: { label: '项目任务' } } } // Translation (CORRECT) -{ o: { project_task: { label: '项目任务' } } } +{ objects: { project_task: { label: '项目任务' } } } ``` ### ❌ Hardcoded Option Values @@ -585,23 +602,23 @@ options: { in_progress: '进行中' } ### ❌ Ignoring Coverage Reports -Stale translations can cause confusion. Always run coverage analysis before releases. +Stale translations can cause confusion. Always run `os i18n check` before releases. --- ## Quick-Start Template -```typescript -// i18n/zh-CN.ts -import type { AppTranslationBundle } from '@objectstack/spec'; +One compact per-locale file — assemble locales with `defineTranslationBundle` and +register via `defineStack({ translations: [...] })` as shown in +"Authoring Translation Bundles" above: -export default { - _meta: { - locale: 'zh-CN', - direction: 'ltr', - }, + +```typescript +// src/translations/zh-CN.ts +import type { TranslationData } from '@objectstack/spec/system'; - o: { +export const zhCN: TranslationData = { + objects: { account: { label: '客户', pluralLabel: '客户', @@ -622,20 +639,15 @@ export default { }, }, - app: { + apps: { crm: { label: '客户关系管理' }, }, - nav: { - home: '首页', - settings: '设置', - }, - messages: { 'common.save': '保存', 'common.cancel': '取消', }, -} satisfies AppTranslationBundle; +}; ``` --- diff --git a/skills/objectstack-i18n/evals/README.md b/skills/objectstack-i18n/evals/README.md index d6e9bf6314..e059e2d2f5 100644 --- a/skills/objectstack-i18n/evals/README.md +++ b/skills/objectstack-i18n/evals/README.md @@ -12,16 +12,15 @@ When implemented, evals will follow this structure: ``` evals/ -├── naming/ -│ ├── test-object-names.md -│ ├── test-field-keys.md -│ └── test-option-values.md -├── relationships/ -│ ├── test-lookup-vs-master-detail.md -│ └── test-junction-patterns.md -├── validation/ -│ ├── test-script-inversion.md -│ └── test-state-machine.md +├── bundle-shape/ +│ ├── test-objects-vs-o-keys.md # runtime `objects.*` vs secondary `o.*` format +│ ├── test-snake-case-keys.md # object/field keys match metadata machine names +│ └── test-option-machine-values.md # lowercase option values, not display labels +├── interpolation/ +│ └── test-double-brace-params.md # {{userName}}, not {userName}; ICU is experimental +├── coverage-workflow/ +│ ├── test-extract-command.md # os i18n extract --locales/--out flags & TS output +│ └── test-check-command.md # os i18n check --strict/--threshold CI gate └── ... ``` @@ -42,5 +41,5 @@ Each eval file will contain: When adding evals: 1. Each eval should test a single, specific rule or pattern 2. Include both positive (correct) and negative (incorrect) examples -3. Reference the corresponding rule file in `rules/` +3. Reference the corresponding section of `SKILL.md` 4. Use realistic scenarios from actual ObjectStack projects From d884ced0c82551888a28398e02cf5db9248c0aa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:15:45 +0000 Subject: [PATCH 05/12] skills(query): label schema-reserved features; teach only executed behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found ~a third of the skill documenting QueryAST surface the ObjectQL engine and drivers never execute, mostly failing silently with wrong results. A consistent 'Schema-reserved — NOT executed by the engine yet' caveat style now separates spec from runtime: - P0 cursor pagination: engine never reads cursor (silently returns page 1 forever) — replaced the recommendation with manual keyset pagination (where + orderBy + limit). - joins, window functions (SQL builder even drops the field argument), HAVING, per-aggregation FILTER (won_deals silently equals total_deals), $field cross-field comparisons, most full-text options (only query+fields execute; terms AND-ed), top-level distinct — all caveated with working alternatives; KPI pattern rewritten as separate aggregate calls. - Expand: select + filter only; per-parent limit/offset/orderBy are not applied — paginate the related object directly. - SQL vs in-memory aggregation support matrix added (SQL throws on count_distinct/array_agg/string_agg); fabricated 'groupBy fields MUST appear in fields[]' rule deleted. - Documented two features that DO work and were missing: structured groupBy date bucketing ({ field, dateGranularity, alias }) and date-macro tokens ({today}, {30_days_ago}) with their real client-side resolution scope. - Description stops headlining joins/window functions; evals README rewritten for the query domain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-query/SKILL.md | 282 +++++++++++------- skills/objectstack-query/evals/README.md | 40 ++- skills/objectstack-query/rules/aggregation.md | 232 +++++++------- skills/objectstack-query/rules/filters.md | 47 ++- skills/objectstack-query/rules/pagination.md | 79 +++-- 5 files changed, 412 insertions(+), 268 deletions(-) diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index ecb0c7de23..05f7d24a7c 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -2,16 +2,16 @@ name: objectstack-query description: > Construct ObjectQL queries — filters, sorting, pagination, aggregation, - joins/expansion, window functions, and full-text search. Use when the - user is writing a query DSL expression, picking pagination strategy, or - designing a list view's filter spec. Do not use for defining objects / - fields / relationships (see objectstack-data) or for designing the API - endpoint that exposes a query (see objectstack-api). + relation expansion, and full-text search. Use when the user is writing a + query DSL expression, picking pagination strategy, or designing a list + view's filter spec. Do not use for defining objects / fields / + relationships (see objectstack-data) or for designing the API endpoint + that exposes a query (see objectstack-api). license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.1" + version: "1.2" domain: query tags: query, filter, sort, paginate, aggregate, ObjectQL, full-text --- @@ -20,8 +20,16 @@ metadata: Expert instructions for constructing data queries using the ObjectStack Query DSL. This skill covers filter expressions, sorting, pagination, -aggregation, joins, window functions, full-text search, and the expand -system for related records. +aggregation, full-text search, and the expand system for related records. + +**Schema vs. runtime:** the `QueryAST` schema declares more than the engine +currently executes. Sections below marked + +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** + +describe properties that validate against the schema but are silently +ignored (or rejected) at runtime. Never emit them in production queries — +each caveat shows the working alternative. --- @@ -43,8 +51,7 @@ system for related records. - You are writing **aggregation queries** (count, sum, avg, group by) - You need to **expand related records** through lookups - You are implementing **full-text search** across fields -- You need **window functions** for analytical queries -- You are choosing between **offset vs cursor pagination** +- You are choosing between **offset vs keyset pagination** --- @@ -73,9 +80,9 @@ Every ObjectStack query follows the `QuerySchema` structure: For comprehensive documentation with incorrect/correct examples: -- **[Filters](./rules/filters.md)** — All operators, logical combinations, nested relations -- **[Aggregation](./rules/aggregation.md)** — GroupBy, aggregation functions, HAVING, window functions -- **[Pagination](./rules/pagination.md)** — Offset vs cursor, best practices, performance +- **[Filters](./rules/filters.md)** — All operators, logical combinations, nested relations, date macros +- **[Aggregation](./rules/aggregation.md)** — GroupBy, date bucketing, aggregation functions, driver support +- **[Pagination](./rules/pagination.md)** — Offset vs keyset, best practices, performance --- @@ -210,10 +217,13 @@ Filter through relationships without an explicit join: ### Field References (Cross-Field Comparisons) -Compare two fields using `$field`: +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** `$field` exists +> only in the filter schema. No engine or driver code interprets it — the +> `{ $field: '...' }` object binds as a **literal value**, so the query +> silently returns zero rows. Do not use it. ```typescript -// Where actual_revenue > estimated_revenue +// ❌ Schema-valid but NOT executed — matches nothing { where: { actual_revenue: { $gt: { $field: 'estimated_revenue' } } @@ -221,6 +231,12 @@ Compare two fields using `$field`: } ``` +**Working alternatives:** +- Define a **formula field** on the object that computes the comparison + (e.g. `exceeds_estimate` as a boolean), then filter on it: + `{ where: { exceeds_estimate: true } }` (see **objectstack-data**). +- Fetch both fields and compare in **application code**. + --- ## Sorting @@ -260,21 +276,35 @@ Sort with `orderBy` — an array of sort nodes: **Pitfall:** Offset pagination degrades on large offsets — the database still scans skipped rows. -### Cursor Pagination (Performant) +### Keyset Pagination (Performant) + +> ⚠️ **`cursor` is schema-reserved — NOT executed by the engine yet.** The +> `cursor` property validates against `QuerySchema`, but no engine or driver +> code reads it — a query carrying `cursor` silently returns **page 1 +> forever**. Do keyset pagination manually with `where` + `orderBy` + `limit`: ```typescript +// First page { object: 'account', + orderBy: [{ field: 'created_at', order: 'desc' }], + limit: 20, +} + +// Next page — filter past the last record you've seen +{ + object: 'account', + where: { created_at: { $lt: lastSeenCreatedAt } }, + orderBy: [{ field: 'created_at', order: 'desc' }], limit: 20, - cursor: { id: 'last-seen-id' }, - orderBy: [{ field: 'id', order: 'asc' }], } ``` **When to use:** Infinite scroll, APIs, large datasets, real-time feeds. -**Rule:** The cursor fields must match `orderBy` fields. The engine uses them -to generate `WHERE id > ?` instead of `OFFSET`. +**Rule:** The keyset `where` field must match the `orderBy` field (use a +unique or near-unique column such as `created_at` or `id`) so +`WHERE created_at < ?` picks up exactly where the previous page ended. ### OData Compatibility @@ -302,6 +332,13 @@ to generate `WHERE id > ?` instead of `OFFSET`. | `array_agg` | Collect into array | `ARRAY_AGG(field)` | | `string_agg` | Concatenate strings | `STRING_AGG(field, ',')` | +> ⚠️ **Driver support varies.** On SQL datasources the driver executes only +> `count` / `sum` / `avg` / `min` / `max` and **throws** on `count_distinct`, +> `array_agg`, and `string_agg`; the per-aggregation `distinct: true` flag is +> also ignored there. The in-memory fallback path (driver-rest, driver-memory, +> timezone/date-bucket fallbacks) supports all 8 functions plus `distinct`. +> For portable queries, stick to the first five. + ### GroupBy + Aggregation ```typescript @@ -320,38 +357,53 @@ to generate `WHERE id > ?` instead of `OFFSET`. // FROM deal GROUP BY region ORDER BY total_revenue DESC ``` +`groupBy` entries can also be structured objects for **date bucketing** — +`{ field: 'closed_at', dateGranularity: 'quarter' }` — see +[Aggregation rules](./rules/aggregation.md) for the full pattern. + ### HAVING Clause -Filter groups after aggregation: +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** `having` +> validates against `QuerySchema`, but `EngineAggregateOptions` has no +> `having` property and nothing implements it — the clause is silently +> dropped. **Working alternative:** post-filter the aggregated rows in +> application code: ```typescript -{ - object: 'deal', - fields: ['region'], +// ❌ having is silently ignored — do NOT rely on it +// { ..., having: { total_revenue: { $gt: 100000 } } } + +// ✅ Aggregate, then filter the result rows in app code +const rows = await engine.aggregate('deal', { + groupBy: ['region'], aggregations: [ { function: 'sum', field: 'amount', alias: 'total_revenue' }, ], - groupBy: ['region'], - having: { total_revenue: { $gt: 100000 } }, -} -// SQL: ... HAVING SUM(amount) > 100000 +}); +const bigRegions = rows.filter((r) => r.total_revenue > 100000); ``` ### Filtered Aggregation -Apply a filter to a specific aggregation only: +> ⚠️ **Per-aggregation `filter` is schema-reserved — NOT executed by the +> engine yet.** The SQL driver ignores it and the in-memory path ignores it +> too, so a `filter`-carrying aggregation returns the **unfiltered** number — +> silently wrong results. **Working alternative:** issue one aggregate call +> per condition, moving the condition into the query-level `where`: ```typescript -{ - object: 'order', - aggregations: [ - { function: 'count', alias: 'total_orders' }, - { function: 'count', alias: 'high_value_orders', - filter: { amount: { $gt: 1000 } } }, - ], -} -// SQL: COUNT(*) AS total_orders, -// COUNT(*) FILTER (WHERE amount > 1000) AS high_value_orders +// ❌ filter on the aggregation is silently ignored +// { function: 'count', alias: 'high_value_orders', +// filter: { amount: { $gt: 1000 } } } + +// ✅ Separate aggregate calls, condition in `where` +const [totals] = await engine.aggregate('order', { + aggregations: [{ function: 'count', alias: 'total_orders' }], +}); +const [highValue] = await engine.aggregate('order', { + where: { amount: { $gt: 1000 } }, + aggregations: [{ function: 'count', alias: 'high_value_orders' }], +}); ``` --- @@ -384,111 +436,107 @@ Load related records through lookup/master_detail fields: - Max expand depth is **3** by default - The engine resolves expands via batch `$in` queries (not N+1) - Keys in `expand` must be lookup or master_detail field names -- Each expand value is a full `QueryAST` — you can filter, sort, and paginate within it +- Each expand value is a nested `QueryAST`, but the engine applies **select + (`fields`) and filter (`where`) only** — per-parent `limit` / `offset` / + `orderBy` are NOT applied on this path. To paginate or sort related + records, query the related object directly. --- -## Joins (Advanced) +## Joins + +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** `joins` (and the +> `JoinStrategy` hints) exist only in the `QueryAST` schema — no engine or +> driver code consumes them, regardless of a driver advertising +> `supports.joins`. A query carrying `joins` behaves as if they were absent. -For cross-object queries beyond what `expand` provides: +**Working alternatives** (both implemented): +- **`expand`** — load related records through lookup / master_detail fields + (see previous section). +- **Nested relation filters** — filter a parent by conditions on a related + object without an explicit join: ```typescript +// Orders whose customer is in the US — no join needed { object: 'order', fields: ['id', 'amount'], - joins: [ - { - type: 'inner', // 'inner' | 'left' | 'right' | 'full' - object: 'customer', - alias: 'c', - on: { 'order.customer_id': { $eq: { $field: 'c.id' } } }, - } - ], + where: { customer: { country: 'US' } }, } ``` -### Join Strategy Hints - -| Strategy | When to use | -|:---------|:------------| -| `auto` | Default — engine decides | -| `database` | Both objects on same datasource | -| `hash` | Cross-datasource, moderate data | -| `loop` | Small right-side lookup table | - --- ## Full-Text Search +Only the **`query` + `fields`** subset of the search schema executes. The +engine expands the search string into a driver-agnostic filter: each term +becomes an `$or` of `$contains` predicates across the resolved searchable +fields, and multiple whitespace-separated terms are **AND-ed** (every term +must hit some field). Matching is case-insensitive; `select`/`status` +fields match by option *label*, mapped to stored values. + ```typescript { object: 'article', search: { query: 'machine learning', fields: ['title', 'content'], - fuzzy: true, - boost: { title: 2.0 }, - highlight: true, }, limit: 10, } +// Executes as: +// { $and: [ +// { $or: [{ title: { $contains: 'machine' } }, { content: { $contains: 'machine' } }] }, +// { $or: [{ title: { $contains: 'learning' } }, { content: { $contains: 'learning' } }] }, +// ]} ``` -**Options:** -- `fuzzy: true` — tolerates typos -- `boost` — field-specific relevance weighting -- `operator: 'and' | 'or'` — match all terms or any term -- `minScore` — minimum relevance threshold -- `language` — text analysis language +Omit `fields` to search the object's declared `searchableFields` (or an +auto-default of name/title + short-text fields), resolved server-side. + +> ⚠️ **Schema-reserved — NOT executed by the engine yet:** `fuzzy`, `boost`, +> `operator`, `minScore`, `language`, and `highlight` validate against the +> schema but are never read. Terms are always AND-ed; there is no relevance +> scoring or highlighting. --- ## Window Functions (Analytics) -Window functions compute values across row sets without collapsing results: +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** `windowFunctions` +> exists in the `QueryAST` schema, but the engine never routes it to any +> driver — the property is silently dropped from ordinary queries. (Even the +> SQL driver's internal builder drops the `field` argument, so `lag(revenue)` +> would render as `LAG()`.) Do not emit `windowFunctions`. -```typescript -// Rank products by sales within each category -{ - object: 'product', - fields: ['name', 'category', 'sales'], - windowFunctions: [ - { - function: 'row_number', - alias: 'category_rank', - over: { - partitionBy: ['category'], - orderBy: [{ field: 'sales', order: 'desc' }], - } - } - ], -} -``` +**Working alternatives:** +- **Ranking / top-N per group and running totals:** model them in + report/dashboard metadata (groupings, measures, `dateGranularity` + bucketing, `compareTo` for period-over-period) — see **objectstack-ui**. +- **Ad-hoc analysis:** fetch the ordered rows (`orderBy` + `limit`) and + compute ranks or running sums in application code. -### Available Window Functions +### Window Function Enum (schema-reserved) -| Function | Purpose | -|:---------|:--------| -| `row_number` | Sequential number within partition | -| `rank` | Rank with gaps for ties | -| `dense_rank` | Rank without gaps | -| `lag` / `lead` | Access previous/next row value | -| `first_value` / `last_value` | First/last value in window | -| `sum` / `avg` / `count` / `min` / `max` | Running aggregates | +For completeness, the full `WindowFunction` enum declared by the schema: +`row_number`, `rank`, `dense_rank`, `percent_rank`, `lag`, `lead`, +`first_value`, `last_value`, `sum`, `avg`, `count`, `min`, `max`. +None of these execute today. --- ## Common Patterns -### Expand vs Join: Which to Use? +### Cross-Object Queries: Which Tool to Use? | Scenario | Use | |:---------|:----| | Load lookup fields for display | `expand` | | Filter parent by child conditions | Nested relation filter | -| Cross-datasource joins | `joins` with `strategy: 'hash'` | -| Analytical queries across tables | `joins` | | Simple parent→child navigation | `expand` | +| Paginate/sort a parent's related records | Query the related object directly | +| Analytical queries across objects | Report/dashboard metadata, or separate queries combined in app code (`joins` is schema-reserved — see above) | ### Pagination Pattern for APIs @@ -506,33 +554,41 @@ Window functions compute values across row sets without collapsing results: ### Dashboard Aggregation Pattern +Unconditional KPIs can share one aggregate call; a KPI with its own +condition needs a **separate call** with the condition in `where` +(per-aggregation `filter` is schema-reserved — see Filtered Aggregation): + ```typescript -// KPI dashboard: multiple aggregations on same object -{ - object: 'deal', +// KPI dashboard: unconditional aggregations share one call +const [kpis] = await engine.aggregate('deal', { aggregations: [ { function: 'count', alias: 'total_deals' }, { function: 'sum', field: 'amount', alias: 'pipeline_value' }, { function: 'avg', field: 'amount', alias: 'avg_deal_size' }, - { function: 'count', alias: 'won_deals', - filter: { stage: 'closed_won' } }, ], -} +}); + +// Conditional KPI: separate call, condition in `where` +const [won] = await engine.aggregate('deal', { + where: { stage: 'closed_won' }, + aggregations: [{ function: 'count', alias: 'won_deals' }], +}); ``` --- ## CRM Analytics Query Blueprint -Use dashboards/reports metadata as the practical query pattern source: +Model analytics in dashboard/report metadata rather than hand-written query +code — the renderer issues the queries for you: -| Query Need | CRM Reference | Pattern | -|:--|:--|:--| -| KPI widgets | `dashboards/sales.dashboard.ts` | Filtered aggregates (`sum`, `count`, `avg`) over `opportunity`. Add `compareTo: 'previousPeriod' \| 'previousYear'` on the widget for a one-line period-over-period delta. | -| Time-series chart | `dashboards/sales.dashboard.ts` | Date filters + `categoryGranularity: 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'` for server-side bucketing — never bucket by hand on the client. Pair with `compareTo` for an aligned YoY overlay. | -| Matrix report | `reports/opportunity.report.ts` | `groupingsDown` + `groupingsAcross` + `dateGranularity: 'quarter'` | -| Funnel summary | `reports/opportunity.report.ts` | Multi-level grouping (`owner -> stage`) + aggregated measures | -| Operational filter | dashboard/report filters | Prefer declarative operators (`$ne`, `$nin`, `$gte`) over hardcoded SQL | +| Query Need | Pattern | +|:--|:--| +| KPI widgets | Aggregates (`sum`, `count`, `avg`) over the object, each conditional KPI scoped by the widget/dataset filter. Add `compareTo: 'previousPeriod' \| 'previousYear'` on the widget for a one-line period-over-period delta. | +| Time-series chart | Date filters + `categoryGranularity: 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'` for server-side bucketing — never bucket by hand on the client. Pair with `compareTo` for an aligned YoY overlay. | +| Matrix report | `groupingsDown` + `groupingsAcross` + `dateGranularity: 'quarter'` | +| Funnel summary | Multi-level grouping (`owner -> stage`) + aggregated measures | +| Operational filter | Prefer declarative operators (`$ne`, `$nin`, `$gte`) over hardcoded SQL | For metadata app development, model analytics in report/dashboard metadata first; only fall back to custom query code when schema limits require it. diff --git a/skills/objectstack-query/evals/README.md b/skills/objectstack-query/evals/README.md index 87f9184610..e270f31daa 100644 --- a/skills/objectstack-query/evals/README.md +++ b/skills/objectstack-query/evals/README.md @@ -1,11 +1,39 @@ # objectstack-query — Evals -Test cases for the objectstack-query skill. +Test cases for the objectstack-query skill. Each eval gives the model a +natural-language query need and checks the emitted ObjectQL against the +subset the engine actually executes. ## Planned Evals -1. **Simple filter** — "Filter accounts where status is active" -2. **Nested relation filter** — "Find orders where the customer's country is US" -3. **Aggregation** — "Count deals by region and show total revenue" -4. **Pagination** — "Implement cursor-based pagination for a list API" -5. **Full-text search** — "Search articles by keyword with fuzzy matching" +1. **Operator choice** — "Filter accounts where status is active or pending, + excluding deleted ones." Expect `$in` (or `$or`) plus `$ne`/`$not`; + penalize string operators on non-string fields and `deleted_at: null` + instead of `$null`. +2. **Nested relation filter** — "Find orders where the customer's country is + US." Expect a nested relation filter (`customer: { country: 'US' }`), + not a `joins` array (schema-reserved). +3. **Pagination pattern** — "Implement infinite scroll for a feed." Expect + manual keyset pagination (`where` on the sort key + `orderBy` + `limit`); + fail if the answer uses the schema-reserved `cursor` property. +4. **Aggregation correctness** — "Count deals by region and show total + revenue." Expect `groupBy` + `count`/`sum` with aliases; on SQL targets + the answer must stay within `count`/`sum`/`avg`/`min`/`max`. +5. **The FILTER-WHERE trap** — "One call: total orders and count of orders + over $1000." The correct answer is **two** aggregate calls with the + condition in `where`; fail if the answer puts `filter` on an aggregation + (silently returns the unfiltered number). +6. **Post-aggregation filtering** — "Customers with more than 5 orders." + Expect aggregate + app-code filter of the group rows; fail on `having` + (schema-reserved, silently dropped). +7. **Date-bucketed time series** — "Monthly revenue for the last year." + Expect structured `groupBy` with `dateGranularity: 'month'`, not + client-side bucketing and not window functions (schema-reserved). +8. **Expand vs direct query** — "Show a task list with assignee names; page + through one project's tasks." Expect `expand` for the lookup display and + a direct query on the related object for pagination (nested + limit/offset are not applied inside `expand`). +9. **Search subset** — "Keyword search over articles." Expect + `search: { query, fields }` only; fail if the answer relies on `fuzzy`, + `boost`, `operator`, `minScore`, `language`, or `highlight` (all + schema-reserved). diff --git a/skills/objectstack-query/rules/aggregation.md b/skills/objectstack-query/rules/aggregation.md index 88e6381e8d..eacc8b2974 100644 --- a/skills/objectstack-query/rules/aggregation.md +++ b/skills/objectstack-query/rules/aggregation.md @@ -15,6 +15,14 @@ Guide for building ObjectStack aggregation queries. | `array_agg` | `ARRAY_AGG(field)` | Collect values into array | Yes | | `string_agg` | `STRING_AGG(field, ',')` | Concatenate string values | Yes | +> ⚠️ **Driver support varies.** On SQL datasources the driver executes only +> `count` / `sum` / `avg` / `min` / `max` and **throws** (`Unsupported +> aggregate function`) on `count_distinct`, `array_agg`, and `string_agg`; +> the per-aggregation `distinct: true` flag is also ignored there. The +> in-memory aggregation path (driver-rest, driver-memory, timezone/ +> date-bucket fallbacks) supports all 8 functions plus `distinct`. For +> portable queries, stick to the first five. + ## Basic Aggregation ```typescript @@ -43,72 +51,96 @@ Guide for building ObjectStack aggregation queries. } ``` -**⚠️ CRITICAL:** When using `groupBy`, you MUST include the grouped fields in `fields` array. +Note: you do NOT need to repeat `groupBy` fields in `fields` — drivers +auto-select every grouped field into the result rows. Listing them in +`fields` (as above) is a readability convention, not a requirement. -```typescript -// ❌ Wrong: groupBy field not in fields -{ - object: 'sale', - aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], - groupBy: ['region'] // region not in fields! -} +## Date-Bucketed Grouping (dateGranularity) + +`groupBy` entries can be structured objects that bucket a date/timestamp +field into uniform periods — this is the supported way to build time-series +aggregations (never bucket by hand in app code): -// ✅ Correct: groupBy field included in fields +```typescript +// Revenue per quarter per region { - object: 'sale', - fields: ['region'], - aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], - groupBy: ['region'] + object: 'deal', + groupBy: [ + 'region', + { field: 'closed_at', dateGranularity: 'quarter' } + ], + aggregations: [ + { function: 'sum', field: 'amount', alias: 'revenue' } + ] } +// Result rows: { region: 'us', closed_at: '2025-Q1', revenue: 42000 }, ... ``` +- Granularities: `day`, `week`, `month`, `quarter`, `year` (weeks are + ISO-8601, starting Monday). +- Optional `alias` renames the projected group value: + `{ field: 'closed_at', dateGranularity: 'quarter', alias: 'quarter' }`. +- The engine pushes bucketing down to the driver (`DATE_TRUNC` etc.) when + the dialect supports that granularity, and transparently falls back to + in-memory bucketing otherwise — results are correct either way. + ## HAVING Clause -Filter aggregated results (post-aggregation filtering): +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** `having` +> validates against `QuerySchema`, but `EngineAggregateOptions` has no +> `having` property and nothing implements it — the clause is silently +> dropped and every group is returned. **Working alternative:** post-filter +> the aggregated rows in application code: ```typescript -// SQL: SELECT customer_id, COUNT(*) AS order_count -// FROM order GROUP BY customer_id HAVING COUNT(*) > 5 -{ - object: 'order', - fields: ['customer_id'], - aggregations: [ - { function: 'count', alias: 'order_count' } - ], +// ❌ having is silently ignored +// { ..., having: { order_count: { $gt: 5 } } } + +// ✅ Aggregate, then filter the result rows in app code +const rows = await engine.aggregate('order', { groupBy: ['customer_id'], - having: { - order_count: { $gt: 5 } - } -} + aggregations: [{ function: 'count', alias: 'order_count' }], +}); +const frequentCustomers = rows.filter((r) => r.order_count > 5); ``` -**Key difference:** `where` filters rows BEFORE aggregation; `having` filters groups AFTER aggregation. +Aggregated result sets are one row per group — usually small enough that an +app-side filter is cheap. Use `where` to shrink the input rows first. ## Filtered Aggregation (FILTER WHERE) -Apply a condition to a single aggregation without affecting others: +> ⚠️ **Per-aggregation `filter` is schema-reserved — NOT executed by the +> engine yet.** The SQL driver never reads it and the in-memory path ignores +> it, so the aggregation returns the **unfiltered** number — silently wrong +> results. **Working alternative:** one aggregate call per condition, with +> the condition in the query-level `where`: ```typescript -// SQL: SELECT -// COUNT(*) AS total, -// COUNT(*) FILTER (WHERE status = 'active') AS active_count -// FROM user -{ - object: 'user', - aggregations: [ - { function: 'count', alias: 'total' }, - { - function: 'count', - alias: 'active_count', - filter: { status: 'active' } - } - ] -} +// ❌ filter on the aggregation is silently ignored — active_count +// would equal total! +// { function: 'count', alias: 'active_count', filter: { status: 'active' } } + +// ✅ Separate aggregate calls, condition in `where` +const [totals] = await engine.aggregate('user', { + aggregations: [{ function: 'count', alias: 'total' }], +}); +const [active] = await engine.aggregate('user', { + where: { status: 'active' }, + aggregations: [{ function: 'count', alias: 'active_count' }], +}); ``` ## DISTINCT Aggregation +> ⚠️ **Not available on SQL datasources.** `count_distinct` **throws** on the +> SQL driver, and the `distinct: true` flag is silently ignored there (see +> the driver-support caveat above). Both forms work only on the in-memory +> aggregation path. On SQL, get a distinct count by grouping on the field +> and counting the result rows in app code: +> `(await engine.aggregate('employee', { groupBy: ['department'], aggregations: [{ function: 'count', alias: 'n' }] })).length`. + ```typescript +// In-memory drivers only: // SQL: SELECT COUNT(DISTINCT department) FROM employee { object: 'employee', @@ -117,7 +149,7 @@ Apply a condition to a single aggregation without affecting others: ] } -// Alternative: use distinct flag +// Alternative (also in-memory only): use distinct flag { object: 'employee', aggregations: [ @@ -128,80 +160,55 @@ Apply a condition to a single aggregation without affecting others: ## Window Functions -Window functions compute values across row sets WITHOUT collapsing results. +> ⚠️ **Schema-reserved — NOT executed by the engine yet.** The `QueryAST` +> schema declares `windowFunctions` (enum: `row_number`, `rank`, +> `dense_rank`, `percent_rank`, `lag`, `lead`, `first_value`, `last_value`, +> `sum`, `avg`, `count`, `min`, `max`), but the engine never routes the +> property to any driver — it is silently dropped. Even the SQL driver's +> internal builder drops the `field` argument (`lag(revenue)` would render +> as `LAG()`). Do not emit `windowFunctions` in queries. + +**Working alternatives:** -### ROW_NUMBER +### Ranking / Top-N per Group + +Model rankings in report/dashboard metadata (groupings + measures + sort), +or fetch the ordered rows and rank in app code: ```typescript -// Rank products within each category by sales -{ - object: 'product', +// Top products per category — order the rows, rank in app code +const rows = await engine.find('product', { fields: ['name', 'category', 'sales'], - windowFunctions: [ - { - function: 'row_number', - alias: 'category_rank', - over: { - partitionBy: ['category'], - orderBy: [{ field: 'sales', order: 'desc' }] - } - } - ] -} + orderBy: [ + { field: 'category', order: 'asc' }, + { field: 'sales', order: 'desc' }, + ], +}); +// Assign category_rank while iterating: reset the counter when category changes. ``` ### Running Total +Fetch the ordered rows and accumulate in app code: + ```typescript -// Cumulative sum of transactions -{ - object: 'transaction', +const txns = await engine.find('transaction', { fields: ['date', 'amount'], - windowFunctions: [ - { - function: 'sum', - field: 'amount', - alias: 'running_total', - over: { - orderBy: [{ field: 'date', order: 'asc' }], - frame: { - type: 'rows', - start: 'UNBOUNDED PRECEDING', - end: 'CURRENT ROW' - } - } - } - ] -} + orderBy: [{ field: 'date', order: 'asc' }], +}); +let runningTotal = 0; +const withTotals = txns.map((t) => ({ ...t, running_total: (runningTotal += t.amount) })); ``` -### LAG / LEAD (Period-over-Period) +### Period-over-Period -> **For dashboard widgets**, prefer the higher-level `compareTo: -> 'previousPeriod' | 'previousYear' | { offset }` field on the widget -> schema (see *objectstack-ui* → *Period-over-period — `compareTo`*). -> The renderer issues the shifted query for you and aligns the result -> bucket-for-bucket with `categoryGranularity`. Reach for the raw -> `lag` / `lead` window functions below when you need the comparison -> in a custom query result (reports, ad-hoc SQL, cube measures). - -```typescript -// Month-over-month comparison -{ - object: 'monthly_revenue', - fields: ['month', 'revenue'], - windowFunctions: [ - { - function: 'lag', - field: 'revenue', - alias: 'prev_month_revenue', - over: { - orderBy: [{ field: 'month', order: 'asc' }] - } - } - ] -} -``` +For dashboard widgets, use the higher-level `compareTo: +'previousPeriod' | 'previousYear' | { offset }` field on the widget +schema (see *objectstack-ui* → *Period-over-period — `compareTo`*). +The renderer issues the shifted query for you and aligns the result +bucket-for-bucket with `categoryGranularity`. For ad-hoc comparisons, +run two date-bucketed aggregations (see *Date-Bucketed Grouping* above) +over the two periods and join the buckets in app code. ## Common Mistakes @@ -234,14 +241,15 @@ Window functions compute values across row sets WITHOUT collapsing results. groupBy: ['customer_id'] } -// ✅ Use having to filter AFTER aggregation -{ - object: 'order', - fields: ['customer_id'], - aggregations: [{ function: 'count', alias: 'order_count' }], +// ❌ Also wrong: having is schema-reserved and silently ignored (see above) +// { ..., having: { order_count: { $gt: 5 } } } + +// ✅ Aggregate, then filter the group rows in app code +const rows = await engine.aggregate('order', { groupBy: ['customer_id'], - having: { order_count: { $gt: 5 } } -} + aggregations: [{ function: 'count', alias: 'order_count' }], +}); +const result = rows.filter((r) => r.order_count > 5); ``` ### ❌ Wrong: sum/avg on non-numeric fields diff --git a/skills/objectstack-query/rules/filters.md b/skills/objectstack-query/rules/filters.md index a999d7811d..b74a791928 100644 --- a/skills/objectstack-query/rules/filters.md +++ b/skills/objectstack-query/rules/filters.md @@ -99,20 +99,24 @@ where: { ## Field References -Compare a field against another field (not a literal value): +> ⚠️ **`$field` is schema-reserved — NOT executed by the engine yet.** It +> exists only in the filter schema; no engine or driver code interprets it, +> so the `{ $field: '...' }` object binds as a **literal value** and the +> query silently returns zero rows. ```typescript -// ✅ Find records where actual exceeds budget +// ❌ Schema-valid but NOT executed — matches nothing where: { actual_cost: { $gt: { $field: 'budget' } } } - -// ✅ Find overdue tasks (due_date before today is handled by runtime) -where: { - end_date: { $lt: { $field: 'start_date' } } -} ``` +**Working alternatives:** +- Define a **formula field** that computes the cross-field comparison + (e.g. a boolean `over_budget`), then filter on it: + `where: { over_budget: true }` (see **objectstack-data**). +- Fetch both fields and compare in **application code**. + ## Nested Relation Filters Filter by a related object's fields: @@ -200,6 +204,8 @@ where: { ## Date Filtering Patterns +For **ad-hoc queries in application code**, compute the date yourself: + ```typescript // Records created in the last 7 days (compute date in application code) where: { @@ -213,3 +219,30 @@ where: { } } ``` + +### Date Macros (declarative filter metadata) + +Filters that travel as JSON metadata — list views, dashboards, reports, +pages — cannot run code, so they use **date macro tokens** instead +(defined in `@objectstack/spec` `data/date-macros.zod.ts`): + +```typescript +// List-view / dashboard filter values +where: { + signal_at: { $gte: '{30_days_ago}' }, + published_at: { $gte: '{last_quarter_start}' } +} +``` + +- **Fixed tokens:** `{today}`, `{yesterday}`, `{tomorrow}`, `{now}`, plus + period boundaries like `{current_month_start}`, `{last_quarter_end}`, + `{next_year_start}` (and bare aliases like `{month_start}`). +- **Parameterised tokens:** `{N__ago}` / `{N__from_now}` with + units `minute|hour|day|week|month|year` — e.g. `{30_days_ago}`, + `{2_weeks_from_now}`. + +**Scope:** the tokens are expanded **client-side** (by `resolveDateMacros()` +in `@object-ui/core`) immediately before the filter reaches the data +engine — the engine only ever sees ISO date/timestamp strings. Use macros in +UI-rendered filter metadata; for queries you issue directly against the +engine, keep computing dates in application code as above. diff --git a/skills/objectstack-query/rules/pagination.md b/skills/objectstack-query/rules/pagination.md index 82334e57e9..1100575dd5 100644 --- a/skills/objectstack-query/rules/pagination.md +++ b/skills/objectstack-query/rules/pagination.md @@ -7,7 +7,13 @@ Guide for implementing pagination in ObjectStack queries. | Strategy | Best For | Pros | Cons | |:---------|:---------|:-----|:-----| | Offset | UI page navigation, small datasets | Simple, random page access | Slow on large offsets, drift on inserts | -| Cursor | Infinite scroll, real-time feeds | Consistent results, O(1) performance | No random page access | +| Keyset (manual `where`) | Infinite scroll, real-time feeds | Consistent results, O(1) performance | No random page access | + +> ⚠️ **The `cursor` query property is schema-reserved — NOT executed by the +> engine yet.** It validates against `QuerySchema`, but no engine or driver +> code reads it: a query carrying `cursor` silently returns **page 1 +> forever**. Implement keyset pagination manually with a `where` filter on +> the sort key (pattern below). ## Offset Pagination @@ -41,9 +47,11 @@ Guide for implementing pagination in ObjectStack queries. { top: 20 } ``` -## Cursor Pagination +## Keyset Pagination (Manual) -Cursor pagination uses the last record's sort key values to fetch the next page. +Keyset pagination uses the last record's sort key value to fetch the next +page. Because the `cursor` property is not executed (see above), express the +keyset as a `where` filter on the sort field: ```typescript // First page @@ -53,34 +61,38 @@ Cursor pagination uses the last record's sort key values to fetch the next page. limit: 20 } -// Next page — pass the last record's values as cursor +// Next page — filter past the last record's sort key value { object: 'post', + where: { created_at: { $lt: '2025-01-15T10:30:00Z' } }, // last seen value orderBy: [{ field: 'created_at', order: 'desc' }], - limit: 20, - cursor: { - created_at: '2025-01-15T10:30:00Z', - id: 'post_abc123' - } + limit: 20 } ``` -**⚠️ CRITICAL:** Cursor keys MUST match the `orderBy` fields for correct pagination. +**⚠️ CRITICAL:** The keyset `where` field MUST match the `orderBy` field, and +the comparison direction must match the sort order (`$lt` for `desc`, `$gt` +for `asc`). ```typescript -// ❌ Wrong: cursor fields don't match orderBy +// ❌ Wrong: keyset filter doesn't match orderBy { + where: { name: { $gt: 'John' } }, // name is not the sort key! orderBy: [{ field: 'created_at', order: 'desc' }], - cursor: { name: 'John' } // name is not in orderBy! + limit: 20 } -// ✅ Correct: cursor fields match orderBy +// ✅ Correct: keyset filter matches the orderBy field and direction { + where: { created_at: { $lt: '2025-01-15T10:30:00Z' } }, orderBy: [{ field: 'created_at', order: 'desc' }], - cursor: { created_at: '2025-01-15T10:30:00Z' } + limit: 20 } ``` +Prefer a unique (or near-unique) sort key such as `created_at` or `id`; +duplicate key values can skip or repeat rows at page boundaries. + ## Sorting with Pagination **⚠️ CRITICAL:** Always combine `orderBy` with pagination for stable results. @@ -144,22 +156,22 @@ When building paginated REST endpoints: ## Common Mistakes -### ❌ Wrong: Mixing cursor and offset +### ❌ Wrong: Using the schema-reserved `cursor` property ```typescript -// ❌ Don't use both cursor and offset +// ❌ cursor is never read — this returns page 1 forever { object: 'post', limit: 20, - offset: 40, cursor: { created_at: '2025-01-15T10:30:00Z' } } -// ✅ Use one or the other +// ✅ Express the keyset as a where filter on the sort key { object: 'post', - limit: 20, - cursor: { created_at: '2025-01-15T10:30:00Z' } + where: { created_at: { $lt: '2025-01-15T10:30:00Z' } }, + orderBy: [{ field: 'created_at', order: 'desc' }], + limit: 20 } ``` @@ -173,11 +185,12 @@ When building paginated REST endpoints: offset: 100000 // Very slow on large tables } -// ✅ Use cursor pagination for deep pagination +// ✅ Use manual keyset pagination for deep pagination { object: 'post', - limit: 20, - cursor: { created_at: '2024-06-01T00:00:00Z', id: 'post_xyz' } + where: { created_at: { $lt: '2024-06-01T00:00:00Z' } }, + orderBy: [{ field: 'created_at', order: 'desc' }], + limit: 20 } ``` @@ -201,13 +214,19 @@ When building paginated REST endpoints: ## DISTINCT Queries -Remove duplicate rows from results: +> ⚠️ **The top-level `distinct: true` flag is schema-reserved — NOT executed +> by the engine yet.** Neither the engine nor the SQL driver reads it from a +> `QueryAST`; the query returns duplicate rows as if the flag were absent. +> **Working alternative:** group by the fields — each unique combination +> becomes one result row: ```typescript -{ - object: 'order', - fields: ['customer_id', 'product_category'], - distinct: true, - orderBy: [{ field: 'customer_id', order: 'asc' }] -} +// ❌ distinct is silently ignored +// { object: 'order', fields: ['customer_id', 'product_category'], distinct: true } + +// ✅ groupBy collapses duplicates +const rows = await engine.aggregate('order', { + groupBy: ['customer_id', 'product_category'], + aggregations: [{ function: 'count', alias: 'n' }], +}); ``` From a77221fc9ad93a0c30004c038b63f57c9fed5d0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:16:19 +0000 Subject: [PATCH 06/12] skills(api): regenerate API shapes from the real spec and route table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found the skill's core payload — the shapes a developer would copy — substantially fabricated: - P0: generated CRUD routes live under /api/v1/data/{object} (the /data prefix was missing everywhere); there is no /bulk and no GET /aggregate — batch is POST /data/{object}/batch (+ cross-object POST /api/v1/batch), aggregation goes through POST .../query. - P0: the custom-endpoint example matched no schema — rewritten to RestApiEndpointSchema (method/path/handler/category/public/ permissions/requestSchema string refs/handlerStatus) with ApiEndpointSchema as the declarative alternative. - API-methods table: upsert/history/restore/purge are enum gates with no generated route; search is the global /api/v1/search. - Service-info, health (/api/v1/health + /ready, envelope shape), auth config (public/permissions/authRequired + real RateLimitConfigSchema), datasource (defineDatasource, driver ids postgres/mysql/mongo/sqlite/memory), inter-service (kernel.getService), and error-envelope guidance all rewritten against the actual Zod sources; five examples now carry os:check markers and type-check. - Added the missing realtime-subscription and auth-provider sections the description promised; documented environment-scoped route mounting; dropped unresolvable ADR/issue tokens and stale 9.x/4.0.1 version framing; evals README rewritten for the API domain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-api/SKILL.md | 309 ++++++++++++++++--------- skills/objectstack-api/evals/README.md | 22 +- 2 files changed, 212 insertions(+), 119 deletions(-) diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index 2a5a5049fb..3f919c69ea 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -11,10 +11,10 @@ description: > syntax (see objectstack-query). CEL expressions in route guards or auth predicates: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.1" + version: "1.2" domain: api tags: rest, graphql, endpoint, auth, realtime, server --- @@ -47,15 +47,21 @@ Every ObjectStack object with `apiEnabled: true` (the default) automatically gets a full REST API: ``` -GET /api/v1/{object} # List records (with filter, sort, pagination) -GET /api/v1/{object}/:id # Get single record -POST /api/v1/{object} # Create record -PATCH /api/v1/{object}/:id # Update record -DELETE /api/v1/{object}/:id # Delete record (soft-delete if trash enabled) -POST /api/v1/{object}/bulk # Bulk operations -GET /api/v1/{object}/aggregate # Aggregation queries +GET /api/v1/data/{object} # List records (with filter, sort, pagination) +GET /api/v1/data/{object}/:id # Get single record +POST /api/v1/data/{object} # Create record +PATCH /api/v1/data/{object}/:id # Update record +DELETE /api/v1/data/{object}/:id # Delete record (soft-delete if trash enabled) +POST /api/v1/data/{object}/query # Complex queries + aggregation (QueryAST in body) +POST /api/v1/data/{object}/batch # Per-object batch operations +POST /api/v1/batch # Cross-object atomic batch ``` +Data CRUD lives under the `/data` prefix. There is no `/bulk` route and no +`GET .../aggregate` route — batch writes go through the `batch` endpoints, and +aggregation goes through `POST /api/v1/data/{object}/query` with +`groupBy`/`aggregations` in the body. + > **Key rule:** If your object defines `apiMethods`, only those operations are > exposed. For example, `apiMethods: ['get', 'list']` creates a read-only API. @@ -69,16 +75,16 @@ GET /api/v1/meta/:type # List metadata items of a type (object, view, GET /api/v1/meta/:type/:name # Read a single metadata item ``` -Three query-param contracts gained in 9.x: +Three query-param contracts: -- **`?preview=draft`** (#1763) — overlay pending **draft** metadata instead of the +- **`?preview=draft`** — overlay pending **draft** metadata instead of the published copy, on both list and get. The draft path is **cache-bypassed**, so - it always reflects the latest unpublished edit (ADR-0033/0037 authoring loop). -- **`?package=`** (ADR-0048, #1816/#1819) — **package-scope** a read so + it always reflects the latest unpublished edit (the authoring loop). +- **`?package=`** — **package-scope** a read so two installed packages that share a bare metadata name disambiguate by owning package; prefer-local resolution. A package-scoped read **bypasses the meta cache**. The layered / Studio-editor read is package-scoped the same way. -- **`/meta/doc`** (ADR-0046, #1790) — docs-as-metadata. The **list** response omits +- **`/meta/doc`** — docs-as-metadata. The **list** response omits each doc's `content` by default (use `?include=content` to include it); the **single-item** `GET /meta/doc/:name` always returns the full body. @@ -98,45 +104,49 @@ are intended for Web-to-Lead / Web-to-Case style flows. The framework strips fields outside the form's `sections[].fields[]` list; a `beforeInsert` hook on the target object should stamp safe defaults (`status='new'`, `lead_source='web'`, …) and `delete` privileged keys -(`owner`, `internal_notes`, …). See `content/docs/ui/forms.mdx` -for the full contract. +(`owner`, `internal_notes`, …). For the full contract, read +`node_modules/@objectstack/spec/src/ui/view.zod.ts` (`FormViewSchema`) and +`node_modules/@objectstack/spec/src/ui/sharing.zod.ts` (`SharingConfigSchema` +with `allowAnonymous` / `publicLink`). ### Custom Endpoints For business logic beyond CRUD, define custom endpoints via the REST API -plugin: +plugin (`RestApiEndpointSchema`): + ```typescript -{ - name: 'close_case', - path: '/api/v1/cases/:id/close', +import { RestApiEndpointSchema, type RestApiEndpoint } from '@objectstack/spec/api'; + +export const closeCase: RestApiEndpoint = RestApiEndpointSchema.parse({ method: 'POST', + path: '/api/v1/cases/:id/close', + handler: 'closeCase', // protocol method / handler identifier + category: 'data', description: 'Close a support case with resolution notes.', + public: false, // auth required (the default) + permissions: ['support_agent'], + requestSchema: 'CloseCaseRequest', // schema *name* reference, not an inline shape + responseSchema: 'SupportCase', handlerStatus: 'implemented', - request: { - params: { id: { type: 'string', required: true } }, - body: { - resolution: { type: 'string', required: true }, - satisfaction: { type: 'number', min: 1, max: 5 }, - }, - }, - response: { - 200: { description: 'Case closed successfully', schema: 'SupportCase' }, - 404: { description: 'Case not found' }, - 409: { description: 'Case already closed' }, - }, - auth: { required: true, permissions: ['support_agent'] }, -} +}); ``` +There is no `name`, `request`, `response`, or `auth` field on this schema — +request/response schemas are referenced **by name** (`requestSchema` / +`responseSchema`), and auth is the flat `public` + `permissions` pair. The +alternative declarative surface is `ApiEndpointSchema` (`endpoint.zod.ts`): +`type: 'flow' | 'script' | 'object_operation' | 'proxy'` plus a `target` +(Flow ID, script name, or proxy URL) and `authRequired: boolean`. + --- ## Endpoint Naming Conventions | Pattern | Use Case | Example | |:--------|:---------|:--------| -| `/api/v1/{object}` | Auto-generated collection | `/api/v1/accounts` | -| `/api/v1/{object}/:id` | Auto-generated record | `/api/v1/accounts/abc123` | +| `/api/v1/data/{object}` | Auto-generated collection | `/api/v1/data/accounts` | +| `/api/v1/data/{object}/:id` | Auto-generated record | `/api/v1/data/accounts/abc123` | | `/api/v1/{object}/:id/{action}` | Custom action on record | `/api/v1/cases/:id/close` | | `/api/v1/{domain}/{action}` | Domain-level action | `/api/v1/ai/chat` | @@ -147,28 +157,36 @@ plugin: - Use **verbs** only for actions, not for CRUD (`/close`, `/approve`). - Always prefix with `/api/v1/` for versioning. +Depending on deployment configuration, routes may also be mounted +**environment-scoped** under `/api/v1/environments/:environmentId/...` +(project scoping in the REST server). With `projectResolution: 'required'` +only the scoped routes are registered; with `optional`/`auto` the bare +`/api/v1/...` routes remain available alongside them. + --- ## API Methods (Operations) -The full set of operations an object can expose: - -| Method | HTTP | Purpose | -|:-------|:-----|:--------| -| `get` | `GET /:id` | Retrieve a single record | -| `list` | `GET /` | List records with filter/sort/pagination | -| `create` | `POST /` | Create a new record | -| `update` | `PATCH /:id` | Update an existing record | -| `delete` | `DELETE /:id` | Delete a record | -| `upsert` | `PUT /` | Create or update by external ID | -| `bulk` | `POST /bulk` | Batch create/update/delete | -| `aggregate` | `GET /aggregate` | Count, sum, avg, min, max | -| `history` | `GET /:id/history` | Audit trail access | -| `search` | `GET /search` | Full-text search | -| `restore` | `POST /:id/restore` | Restore from trash | -| `purge` | `DELETE /:id/purge` | Permanent deletion | -| `import` | `POST /import` | Bulk data import | -| `export` | `GET /export` | Data export | +The full set of operations an object can expose (the `ApiMethod` enum, 14 +values). Not every enum value has its own generated route — some only gate +access: + +| Method | HTTP surface today | Purpose | +|:-------|:-------------------|:--------| +| `get` | `GET /data/{object}/:id` | Retrieve a single record | +| `list` | `GET /data/{object}` | List records with filter/sort/pagination | +| `create` | `POST /data/{object}` | Create a new record | +| `update` | `PATCH /data/{object}/:id` | Update an existing record | +| `delete` | `DELETE /data/{object}/:id` | Delete a record | +| `upsert` | Enum value gating access — no dedicated generated route in `@objectstack/rest` today | Create or update by external ID | +| `bulk` | `POST /data/{object}/batch` | Batch create/update/delete | +| `aggregate` | No dedicated route — use `POST /data/{object}/query` with `groupBy`/`aggregations` | Count, sum, avg, min, max | +| `history` | Enum value gating access — no dedicated generated route today | Audit trail access | +| `search` | Global `GET /api/v1/search` (cross-object), not per-object | Full-text search | +| `restore` | Enum value gating access — no dedicated generated route today | Restore from trash | +| `purge` | Enum value gating access — no dedicated generated route today | Permanent deletion | +| `import` | `POST /data/{object}/import` | Bulk data import | +| `export` | `GET /data/{object}/export` | Data export | --- @@ -179,35 +197,51 @@ metadata. ### Service Info Schema +The discovery response (`GET /api/v1/discovery`) reports each registered +service in a `services` **record** — the record key is the service name, and +there is no `endpoints` array on a service entry: + + ```typescript -{ - name: 'service-rest-api', +import type { ServiceInfo } from '@objectstack/spec/api'; + +// In the discovery response: services: { data: { ... }, ... } +const dataService: ServiceInfo = { + enabled: true, // required + status: 'available', // 'available' | 'registered' | 'unavailable' | 'degraded' | 'stub' + handlerReady: true, // HTTP handler verified mounted (omitted = unknown) + route: '/api/v1/data', + provider: 'objectql', version: '1.0.0', - status: 'healthy', // 'healthy' | 'degraded' | 'unhealthy' | 'registered' - handlerReady: true, // HTTP handler verified and operational - endpoints: [ - { path: '/api/v1/accounts', methods: ['GET', 'POST'] }, - { path: '/api/v1/accounts/:id', methods: ['GET', 'PATCH', 'DELETE'] }, - ], -} +}; ``` +Optional fields also include `message` (human-readable reason if unavailable) +and `rateLimit` (per-service quota info). There is no `healthy`/`unhealthy` +status — `available` is the fully-operational state. + ### Health Endpoint -Every ObjectStack deployment exposes `/health`: +Every ObjectStack deployment exposes `GET /api/v1/health`, which returns the +standard success envelope (no per-service map): ```json { - "status": "healthy", - "version": "4.0.1", - "services": { - "objectql": { "status": "healthy" }, - "rest-api": { "status": "healthy" }, - "auth": { "status": "healthy" } + "success": true, + "data": { + "status": "ok", + "timestamp": "2026-07-20T12:00:00.000Z", + "version": "1.0.0", + "uptime": 42.7 } } ``` +A readiness probe also exists at `GET /ready` on the same base path — it +returns 200 only when the kernel is fully running, and 503 while booting or +shutting down. For per-service status, use `GET /api/v1/discovery` (the +`services` record above). + --- ## Dispatcher & Routing @@ -239,23 +273,62 @@ Every endpoint has a handler status: --- +## Realtime Subscriptions + +Realtime contracts are pointer-style — read the spec source for exact shapes: + +- `node_modules/@objectstack/spec/src/api/realtime.zod.ts` — `TransportProtocol` + (`websocket` | `sse` | `polling`), `SubscriptionSchema` (`id`, `events[]`, + `transport`, optional `channel`), `RealtimeEventSchema`, and + `RealtimeConfigSchema`. Note: the `RealtimeEventType` enum is declared but + not yet enforced — the runtime emits `data.record.*` event names instead. +- `node_modules/@objectstack/spec/src/api/websocket.zod.ts` — the WebSocket + message protocol: subscribe/unsubscribe messages, event delivery with + filters, presence, cursor and collaborative-edit messages, and + ack/error/ping/pong frames. + +--- + ## Authentication & Authorization ### Auth Configuration +There is no nested `auth` block on endpoints. Auth is declared with flat +fields on the endpoint itself: + ```typescript +// RestApiEndpointSchema (plugin-rest-api) endpoints: { - auth: { - required: true, // Require authentication - permissions: ['admin'], // Required permission profiles - rateLimit: { - requests: 100, - window: '1m', // per minute - }, - }, + public: false, // false (the default) = auth required + permissions: ['admin'], // required permissions + rateLimit: 'default', // named rate-limit policy (a string reference) } ``` +Declarative `ApiEndpointSchema` endpoints (and the dispatcher) instead use +`authRequired: boolean` (default `true`). Rate-limit policies themselves are +shaped by `RateLimitConfigSchema`: + + +```typescript +import { RateLimitConfigSchema, type RateLimitConfig } from '@objectstack/spec/shared'; + +const limit: RateLimitConfig = RateLimitConfigSchema.parse({ + enabled: true, + windowMs: 60_000, // time window in milliseconds + maxRequests: 100, // max requests per window +}); +``` + +### Auth Providers + +Provider and login contracts live in +`node_modules/@objectstack/spec/src/api/auth.zod.ts`: `AuthProvider` is +`'local' | 'google' | 'github' | 'microsoft' | 'ldap' | 'saml'`, and +`LoginRequestSchema` carries `type` (login method), plus optional `email`, +`username`, `password`, `provider`, and `redirectTo`. Read that file for the +session and token response shapes before wiring an auth flow. + ### Security Layers | Layer | Scope | Description | @@ -273,60 +346,71 @@ Every endpoint has a handler status: ## Datasource Configuration -Connect to external data sources for virtualised data access: +Connect to external data sources for virtualised data access. +`DatasourceSchema` has no `type`, `connection`, or `readOnly` fields — the +connection settings live in the driver-specific `config` record, and +read-only safety comes from `schemaMode` plus the `external` write gate: + ```typescript -{ +import { defineDatasource } from '@objectstack/spec'; + +export const legacyErp = defineDatasource({ name: 'legacy_erp', - type: 'sql', - driver: 'postgresql', - connection: { + driver: 'postgres', + config: { host: 'erp.internal.example.com', port: 5432, database: 'erp_production', - ssl: true, }, - readOnly: true, // Safety for legacy systems -} + ssl: { enabled: true }, + schemaMode: 'external', // DDL forbidden; schema mismatch fails boot + external: { allowWrites: false }, // required when schemaMode != 'managed' +}); ``` ### Supported Drivers +Registered driver ids in the datasource driver catalog: + | Driver | Use Case | |:-------|:---------| -| `postgresql` | Primary production database | +| `postgres` | Primary production database | | `mysql` | Legacy systems, WordPress integration | +| `mongo` | Document store (MongoDB) | | `sqlite` | Local development, embedded apps | -| `turso` | Edge SQLite (Turso/libSQL) — serverless | | `memory` | Unit tests, development | +Edge SQLite (Turso/libSQL) is available via the separate +`@objectstack/driver-turso` package (driver name `turso`). + --- ## Inter-Service Communication ### Service Contracts -ObjectStack uses typed service contracts defined in `@objectstack/spec/contracts`: - -```typescript -// Service contract interface -interface DataService { - find(object: string, query: QueryOptions): Promise; - findOne(object: string, id: string): Promise; - create(object: string, data: object): Promise; - update(object: string, id: string, data: object): Promise; - delete(object: string, id: string): Promise; -} -``` +ObjectStack uses typed service contracts defined in `@objectstack/spec/contracts`. +The data contract is `IDataEngine` (`find(objectName, query?: EngineQueryOptions)`, +`findOne`, `insert`, `update`, `delete`, `count`, `aggregate`, plus optional +`vectorFind`/`batch`/`execute`) — there is no `DataService` contract. ### Kernel Service Resolution -Services are resolved through the microkernel: +Services are resolved through the microkernel with `kernel.getService(name)` +(there is no `kernel.resolve()`); an async variant `kernel.getServiceAsync` +supports factory-created services: + ```typescript -const dataService = kernel.resolve('data'); -const authService = kernel.resolve('auth'); -const aiService = kernel.resolve('ai'); +import type { IDataEngine } from '@objectstack/spec/contracts'; + +declare const kernel: { getService(name: string): T }; + +async function firstTenAccounts() { + const data = kernel.getService('data'); + return data.find('account', { limit: 10 }); +} ``` --- @@ -337,14 +421,23 @@ const aiService = kernel.resolve('ai'); a new version (`v2`). 2. **Use auto-generated APIs** whenever possible. Only create custom endpoints for business logic that cannot be expressed through CRUD + triggers. -3. **Return consistent error shapes.** Use the `DispatcherErrorResponseSchema` - format with `type`, `message`, and `hint`. +3. **Return consistent error shapes.** The dispatcher envelope is + `DispatcherErrorResponseSchema`: `{ success: false, error: { code, message, + type?, route?, service?, hint? } }`, where `code` is the **numeric** HTTP + status and `code`/`message` are required. General API errors use + `ErrorResponseSchema` (`errors.zod.ts`). Be aware the shipped data routes + return flat `{ error, code }` bodies instead (e.g. `CONCURRENT_UPDATE` → + 409, `VALIDATION_FAILED` → 400) — do not assume every error arrives in the + `success: false` envelope. 4. **Document every endpoint** with `description` and response schemas. 5. **Set `handlerStatus`** to communicate implementation progress to consumers. 6. **Apply least-privilege auth.** Every endpoint should declare its required permissions explicitly. -7. **Use `upsert` for idempotent writes.** External integrations should prefer - `upsert` over `create` to avoid duplicates. +7. **Design idempotent writes deliberately.** `upsert` exists as an + `apiMethods` enum value, but `@objectstack/rest` generates no upsert route + today. External integrations should query by a unique external ID and then + branch to create or update (the per-object + `POST /api/v1/data/{object}/batch` endpoint can group those writes). --- diff --git a/skills/objectstack-api/evals/README.md b/skills/objectstack-api/evals/README.md index d6e9bf6314..c375c7124d 100644 --- a/skills/objectstack-api/evals/README.md +++ b/skills/objectstack-api/evals/README.md @@ -12,16 +12,16 @@ When implemented, evals will follow this structure: ``` evals/ -├── naming/ -│ ├── test-object-names.md -│ ├── test-field-keys.md -│ └── test-option-values.md -├── relationships/ -│ ├── test-lookup-vs-master-detail.md -│ └── test-junction-patterns.md -├── validation/ -│ ├── test-script-inversion.md -│ └── test-state-machine.md +├── routes/ +│ ├── test-data-prefix.md # CRUD routes live under /api/v1/data/{object} +│ ├── test-query-vs-aggregate.md # aggregation via POST /data/{object}/query, no GET /aggregate +│ └── test-batch-routes.md # per-object /data/{object}/batch vs cross-object /batch +├── errors/ +│ ├── test-dispatcher-envelope.md # { success: false, error: { code, message, ... } } +│ └── test-data-route-errors.md # flat { error, code } bodies (CONCURRENT_UPDATE 409, VALIDATION_FAILED 400) +├── endpoints/ +│ ├── test-rest-endpoint-shape.md # RestApiEndpointSchema fields (public/permissions, schema name refs) +│ └── test-api-endpoint-types.md # ApiEndpointSchema type/target/authRequired └── ... ``` @@ -42,5 +42,5 @@ Each eval file will contain: When adding evals: 1. Each eval should test a single, specific rule or pattern 2. Include both positive (correct) and negative (incorrect) examples -3. Reference the corresponding rule file in `rules/` +3. Validate expected outputs against the Zod schemas in `node_modules/@objectstack/spec/src/api/` 4. Use realistic scenarios from actual ObjectStack projects From a56cd6a041fb39c2f606df98d8c2715775093948 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:20:59 +0000 Subject: [PATCH 07/12] skills(data): purge pre-16 schema surface; teach the shapes create() accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found SKILL.md's security/advanced sections and rules/field-types.md carrying a thick stratum of pre-16 material — generated code would either throw at ObjectSchema.create() or ship silently dead metadata: - 49 field types (not 48); added the missing secret/user/composite/ repeater/record types; password row corrected (generic path stores plaintext at rest — recommend type:'secret', ADR-0100). - referenceFilters (removed #2377/ADR-0049) → structured lookupFilters across SKILL.md and all rules files. - summary fields use summaryOperations {object, field, function}; formula fields use expression + returnType (no 'currency') with record.-prefixed CEL; vector fields use flat dimensions. - enable.feeds/activities default TRUE (opt-out, not opt-in). - tenancy is strict { enabled, tenantField } — the shared/isolated/ hybrid mode table was a retired key (#2763); dropped softDelete/versioning/partitioning/cdc checklist rows (retired keys that throw) and the pruned encryptionConfig/maskingRule sections in favor of type:'secret' + field requiredPermissions (ADR-0066 D3). - Object-level permissions:{read:[roles]} shape doesn't exist — definePermissionSet boolean CRUD bits; RLS policies take singular operation + required object; current_user.roles → current_user.positions (ADR-0090 D3). - defineObject/defineHook are not exported — ObjectSchema.create + Hook objects via defineStack({ hooks }); object extensions via defineObjectExtension registered in objectExtensions. - rules/field-types.md Config columns purged of ~20 nonexistent knobs (silently-stripped dead metadata); hooks reference corrected (onError default abort, after-hooks in-transaction unless async, update(..., { multi: true }), CEL conditions, no aggregate in api.read); broken monorepo links replaced with plain text. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-data/SKILL.md | 216 +++++++++++------- skills/objectstack-data/evals/README.md | 53 +---- .../objectstack-data/references/data-hooks.md | 49 ++-- skills/objectstack-data/rules/datasources.md | 1 - skills/objectstack-data/rules/field-types.md | 121 ++++++---- skills/objectstack-data/rules/hooks.md | 3 +- skills/objectstack-data/rules/naming.md | 16 +- .../objectstack-data/rules/relationships.md | 2 +- 8 files changed, 255 insertions(+), 206 deletions(-) diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 3178678956..39d86a790c 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -15,10 +15,10 @@ description: > formulas / validations / sharing rules / dynamic seed values: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "4.2" + version: "4.3" domain: data tags: object, field, validation, index, relationship, hook, schema, permission, rls, security, seed, fixture --- @@ -45,7 +45,7 @@ relationship modelling, validation rules, index strategy, and lifecycle hooks. ## When to Use This Skill - You are creating a **new business object** (e.g., `account`, `project_task`) -- You need to **choose the right field type** from the 48 supported types +- You need to **choose the right field type** from the 49 supported types - You are configuring **lookup / master-detail relationships** between objects - You need to add **validation rules** (cross-field, state machine, format, etc.) - You are optimising **query performance with indexes** @@ -74,7 +74,7 @@ database table and exposes automatic CRUD APIs. |:---------|:--------|:------------| | `label` | Auto from `name` | Human-readable singular label | | `pluralLabel` | — | Plural form (e.g., "Accounts") | -| `namespace` | — | **Deprecated** — ignored by the runtime. Embed prefix directly in `name` instead (e.g. `name: 'crm_account'`) | +| `namespace` | — | **Not a schema key** — `ObjectSchema.create()` rejects unknown keys, so authoring it is a build error. Embed the prefix directly in `name` instead (e.g. `name: 'crm_account'`) | | `datasource` | `'default'` | Target datasource ID for virtualized data | | `nameField` | derived (e.g. `'name'`/`'title'`) | **Canonical** record-title field — the stored field used as the record's display name. Use a single text/email field, or a formula field (`returnType: 'text'`) for a composite title | | `displayNameField` | — | **Deprecated** alias for `nameField` (still honored as a fallback) | @@ -94,8 +94,8 @@ Toggle system behaviours per object: | `apiEnabled` | `true` | Expose via automatic REST / GraphQL APIs | | `apiMethods` | all | Whitelist specific operations (`get`, `list`, `create`, …) | | `files` | `false` | Attachments & document management | -| `feeds` | `false` | Social feed, comments, mentions | -| `activities` | `false` | Tasks & events tracking | +| `feeds` | `true` | Social feed, comments, mentions — **opt-out**: explicit `false` hides the feed UI and rejects new comments | +| `activities` | `true` | Activity timeline (`sys_activity` mirror of CRUD) — **opt-out**: explicit `false` stops mirroring and hides the timeline | | `trash` | `true` | Soft-delete with restore | | `mru` | `true` | Most Recently Used tracking | | `clone` | `true` | Record deep cloning | @@ -204,7 +204,7 @@ export const Invoice = ObjectSchema.create({ For comprehensive documentation with incorrect/correct examples: - **[Naming Conventions](./rules/naming.md)** — snake_case rules, option values, config properties -- **[Field Types](./rules/field-types.md)** — All 48 field types with decision tree and configs +- **[Field Types](./rules/field-types.md)** — All 49 field types with decision tree and configs - **[Relationships](./rules/relationships.md)** — lookup vs master_detail, junction patterns, delete behaviors - **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels - **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes @@ -303,23 +303,25 @@ see **objectstack-platform**. |:--------|:-----------|:--------| | Object `name` | `snake_case` | `project_task` | | Field keys | `snake_case` | `first_name`, `due_date` | -| Schema properties | `camelCase` | `maxLength`, `referenceFilters` | +| Schema properties | `camelCase` | `maxLength`, `lookupFilters` | | Option `value` | lowercase | `in_progress` | See [rules/naming.md](./rules/naming.md) for incorrect/correct examples. ### Field Type Selection -48 types available. Quick categories: +49 types available. Quick categories: -- **Text:** `text`, `textarea`, `email`, `url`, `phone`, `markdown`, `html`, `richtext` +- **Text:** `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext` — ⚠️ `password` on a generic object is **plaintext at rest** (masked on read, never hashed); prefer `secret` for credentials +- **Secret:** `secret` — reversible, **encrypted-at-rest** credential (DB password, API key, token) via the registered `ICryptoProvider`; masked on read, fail-closed (ADR-0100). The recommended type for credentials - **Numbers:** `number`, `currency`, `percent` - **Date/Time:** `date`, `datetime`, `time` - **Logic:** `boolean`, `toggle` - **Selection:** `select`, `multiselect`, `radio`, `checkboxes` -- **Relational:** `lookup`, `master_detail`, `tree` +- **Relational:** `lookup`, `master_detail`, `tree`, `user` — `user` is a person picker (a lookup specialized to `sys_user`; stored identically to `lookup`) - **Media:** `image`, `file`, `avatar`, `video`, `audio` -- **Calculated:** `formula`, `summary`, `autonumber` — `formula` fields take a CEL expression in `formula` (use `F\`...\`` from `@objectstack/spec`); see **objectstack-formula** skill +- **Calculated:** `formula`, `summary`, `autonumber` — `formula` fields take a CEL expression in `expression` (use `F\`...\`` from `@objectstack/spec`); see **objectstack-formula** skill +- **Embedded:** `composite`, `repeater`, `record` — embedded JSON sub-objects stored on the parent row (no separate table / FK) - **Enhanced:** `location`, `address`, `code`, `json`, `color`, `rating`, `slider`, `signature`, `qrcode`, `progress`, `tags`, `vector` See [rules/field-types.md](./rules/field-types.md) for full reference. @@ -421,9 +423,9 @@ Mirror these CRM-style patterns when designing enterprise metadata objects: | Object layout via field groups | `src/objects/*.object.ts` | Use `fieldGroups[]` + per-field `group` for deterministic form structure | | Capability gating | `src/objects/*.object.ts` | Use `enable` flags (`trackHistory`, `apiMethods`, `files`, `feeds`, `activities`) per object | | Index + validation pairing | `src/objects/*.object.ts` | Keep `indexes[]` aligned to common filters and enforce invariants with `validations[]` | -| Relationship constraints | `src/objects/*.object.ts` | Use `lookup` + `referenceFilters` for constrained child selection | -| Lifecycle automation | `src/objects/*.hook.ts` | Use a lifecycle **hook** (`defineHook()`) or a top-level `record_change` flow for field updates triggered by record changes. There is **no** object-level `workflows[]` field — authoring one is a build error (#1535). | -| State transitions | `src/objects/*.state.ts` | Prefer explicit `stateMachines` for lifecycle-heavy objects | +| Relationship constraints | `src/objects/*.object.ts` | Use `lookup` + `lookupFilters` (`[{ field, operator, value }]`) for constrained child selection | +| Lifecycle automation | `src/objects/*.hook.ts` | Use a lifecycle **hook** (a `Hook` object registered via `defineStack({ hooks })`) or a top-level `record_change` flow for field updates triggered by record changes. There is **no** object-level `workflows[]` field — authoring one is a build error (#1535). | +| State transitions | `src/objects/*.object.ts` | Prefer explicit `state_machine` validation rules (one per state field) — there is **no** separate `stateMachines` map | For metadata authoring, keep expressions in CEL (`P\`...\``, `F\`...\``, `cel\`...\``) and avoid legacy formula-string syntax. @@ -432,40 +434,56 @@ For metadata authoring, keep expressions in CEL (`P\`...\``, `F\`...\``, ## Object Extension Model -When extending an object you do not own: +When extending an object you do not own, author an extension with +`defineObjectExtension()` and register it on the stack's `objectExtensions` +array: ```typescript -{ - ownership: 'extend', - extend: 'crm.account', // target object FQN +import { defineObjectExtension } from '@objectstack/spec'; + +export const accountExtension = defineObjectExtension({ + extend: 'account', // target object name fields: { custom_score: { type: 'number' } }, priority: 300, // higher = applied later -} +}); + +// objectstack.config.ts +// defineStack({ objectExtensions: [accountExtension], ... }) ``` - `priority` controls merge order (default `200`; range `0–999`) - Extensions can add fields, validations, and indexes — but cannot remove them +- Do **not** author `ownership: 'extend'` on an object schema — the object-level + `ownership` property is the *record-ownership* enum (`'user' | 'org' | 'none'`), + unrelated to extensions --- ## Security & Access Control -Per-object access control is part of the schema, not a separate layer. -Configure these alongside `fields` / `validations` / `hooks`: +Per-object access control is authored in **permission sets**, not on the object +schema. There is no object-level `permissions` key (and no `hooks` key either) — +`ObjectSchema.create()` **rejects** both as unknown keys. ### Object-level permissions (RBAC) -Bind CRUD operations to roles: +Grant CRUD access per object with boolean bits on a permission set: ```typescript -permissions: { - read: ['authenticated'], - create: ['sales', 'admin'], - update: ['record_owner', 'sales_manager', 'admin'], - delete: ['admin'], -} +import { definePermissionSet } from '@objectstack/spec'; + +export const salesUser = definePermissionSet({ + name: 'sales_user', + objects: { + account: { allowRead: true, allowCreate: true, allowEdit: true }, + contact: { allowRead: true }, + }, +}); ``` +- Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus + `allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords` + (super-user, bypass sharing). - Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` - Combine with `enable.apiMethods` to also restrict the HTTP surface. @@ -517,17 +535,19 @@ every read for users carrying that set; `check` gates writes. (`@objectstack/plu re-reads the target row through the write filter before single-id `update`/`delete`.) ```typescript -// in a *.profile.ts / permission-set +// in a permission set (definePermissionSet) rowLevelSecurity: [ { name: 'own_records', - operations: ['select', 'update', 'delete'], - using: 'owner_id == current_user.id', // read scope - check: 'owner_id == current_user.id', // write scope + object: 'account', // REQUIRED per policy + operation: 'all', // singular: select|insert|update|delete|all + using: 'owner_id == current_user.id', // read scope + check: 'owner_id == current_user.id', // write scope }, { name: 'org_isolation', - operations: ['all'], + object: 'contact', + operation: 'select', using: 'organization_id == current_user.organization_id', }, ] @@ -547,67 +567,78 @@ A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated* | `current_user.email` | the caller's email (ADR-0056 #2054) | | `current_user.organization_id` | the caller's tenant | | `current_user.org_user_ids` | ids of users in the same org (for `IN`) | -| `current_user.roles` | the caller's roles (for `IN`) | +| `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) | - Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape), `node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar). - Owner-scoping shortcut: the built-in `member_default` set already owner-scopes writes via `owner_only_writes` / `owner_only_deletes`, and an object's - `sharingModel` (`private` / `public_read` / `controlled_by_parent`, ADR-0056 D1) + `sharingModel` (`private` / `public_read` / `public_read_write` / `controlled_by_parent`, ADR-0056 D1) is the declarative way to set the org-wide default — prefer those over hand-written policies for the common cases. -> **Experimental:** a separate object-level `rls` config with a free-form CEL -> `predicate` exists in `rls.zod.ts` but is marked experimental (ADR-0056 D8) and -> is **not** the path the runtime compiles/enforces. Author RLS as -> `rowLevelSecurity` policies as shown above. +> **Removed:** a former object-level `rls` config (`RLSConfigSchema`, a free-form +> CEL `predicate` on the object) was **removed** from the spec (ADR-0056 D8, +> "design+enforce or remove"). Permission-set `rowLevelSecurity` policies are the +> only RLS surface — author them as shown above. + +### Sensitive fields — `secret` type + `requiredPermissions` -### Field-level encryption +The former `encryptionConfig` and `maskingRule` field keys were **pruned from +`FieldSchema`** — they had no runtime consumer (dead surface; setting them +protected nothing). The real channels are: -Encrypt sensitive columns at rest. Decryption is automatic for callers with -permission; raw bytes are stored otherwise. +**Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible +machine credentials (DB passwords, API keys, tokens): the engine encrypts the +value on write via the registered `ICryptoProvider`, stores the ciphertext +handle in `sys_secret`, persists only an opaque ref on the row, and masks the +value on read. **Fail-closed:** with no crypto provider registered, writes +throw rather than persist cleartext. ```typescript fields: { - ssn: { - type: 'text', - encryptionConfig: { algorithm: 'aes-256-gcm', keyRef: 'pii_key_v1' }, - }, + api_key: { type: 'secret', label: 'API Key' }, } ``` -- Source: `node_modules/@objectstack/spec/src/system/encryption.zod.ts` -- Key rotation: bump `keyRef` and let the migration re-encrypt. - -### PII masking - -Show partial values (`****-****-1234`) to roles that can read but should not -see the full value. Applied after RLS, before serialization. +**Per-field access gating — `requiredPermissions` (ADR-0066 D3).** Capabilities +required to READ/EDIT the field. A field declaring `requiredPermissions` is +**masked on read and denied on write** unless the caller holds ALL listed +capabilities — an AND-gate that is strictest-wins over permission-set field +grants. Enforced by plugin-security's FieldMasker. ```typescript fields: { - credit_card: { + ssn: { type: 'text', - maskingRule: { - pattern: 'last4', // built-in: last4 | first2 | email | custom - visibleToRoles: ['billing_admin'], - }, + requiredPermissions: ['view_pii'], // mask on read / deny on write without it }, } ``` -- Source: `node_modules/@objectstack/spec/src/system/masking.zod.ts` +- Source: `node_modules/@objectstack/spec/src/data/field.zod.ts` + (`secret` field type, `requiredPermissions`) ### Multi-tenancy -For SaaS, set `tenancy` on the object schema. Combined with RLS, this -enforces per-tenant data isolation: +For SaaS, set `tenancy` on the object schema for row-level tenant isolation +(the tenant field is injected on write and enforced on read). The block is +**strict** — exactly two keys: + +```typescript +tenancy: { + enabled: true, // enable row-level tenant isolation + tenantField: 'tenant_id', // default: 'tenant_id' +} +``` -| Mode | Storage | When to use | -|:-----|:--------|:------------| -| `shared` | Single table, `tenant_id` column + RLS | Default — most cost-efficient | -| `isolated` | Separate database per tenant | Regulatory isolation / large tenants | -| `hybrid` | Shared schema, tenant-specific sharding | High-volume multi-tenant | +- The former `shared` / `isolated` / `hybrid` mode key (`tenancy.strategy`) was + **retired** (#2763) — an unknown `tenancy` key is now a loud parse error with + upgrade guidance, never silently stripped. +- **Database-per-tenant isolation is not object metadata** — it is an + environment/deployment choice (each environment carries its own database URL). +- Platform/env-global objects declare `tenancy: { enabled: false }` to opt out + of org row-scoping (see the visibility-posture recipe below). ### Platform-global / admin-only objects (visibility posture) @@ -637,7 +668,7 @@ requiredPermissions: ['manage_platform_settings'], // object-level gate → memb > row to all authenticated users**. `access.default:'private'` *by itself* opts > the admin's `'*'` grant out too, so the **admin sees nothing**. The > `tenancy.enabled:false` + `requiredPermissions` pair is the correct combo -> (admin sees all, non-admins 403). Posture model: [ADR-0066](../../docs/adr/0066-unified-authorization-model.md). +> (admin sees all, non-admins 403). Posture model: ADR-0066. ### Cross-skill notes @@ -652,7 +683,7 @@ requiredPermissions: ['manage_platform_settings'], // object-level gate → memb ## Metadata Protection (`protection`) Package authors can lock shipped metadata against Studio edits / overlays / deletes. -See [ADR-0010](../../docs/adr/0010-metadata-protection-model.md) for the full model. +See ADR-0010 for the full model. The `protection` block is **declared on the source schema** (`*.object.ts`, `*.app.ts`, `*.view.ts`, …) and stripped at load time — it never appears in @@ -666,13 +697,16 @@ and the lock banner reads. protection?: { /** Lock level — controls what Studio can do to this item. */ lock: 'none' | 'no-overlay' | 'no-delete' | 'full'; - /** Human-readable reason shown in the Studio lock banner. */ - reason?: string; - /** Optional doc URL — renders as "查看文档 →" link in the banner. */ + /** REQUIRED — reason shown in the Studio lock banner (1–500 chars). */ + reason: string; + /** Optional doc URL — renders as a "View docs" link in the banner. */ docsUrl?: string; } ``` +The block is `.strict()`: `reason` is **required** (min 1 / max 500 chars) and +unknown keys are rejected. + | `lock` | Edit (overlay) | Delete | Typical use | |:---|:---:|:---:|:---| | `none` (default) | ✅ | ✅ | Normal authored metadata | @@ -683,10 +717,10 @@ protection?: { ### Example — fully locked platform object ```ts -// packages/platform-objects/src/identity/sys-user.object.ts -import { defineObject } from '@objectstack/spec'; +// src/objects/sys-user.object.ts +import { ObjectSchema } from '@objectstack/spec/data'; -export const SysUserObject = defineObject({ +export const SysUserObject = ObjectSchema.create({ name: 'sys_user', label: 'User', protection: { @@ -694,15 +728,17 @@ export const SysUserObject = defineObject({ reason: 'Core identity object — see ADR-0010.', docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, - fields: [ /* ... */ ], + fields: { /* ... */ }, }); ``` ### Example — schema-locked but deletable ```ts -// packages/platform-objects/src/security/sys-role.object.ts -export const SysRoleObject = defineObject({ +// src/objects/sys-role.object.ts +import { ObjectSchema } from '@objectstack/spec/data'; + +export const SysRoleObject = ObjectSchema.create({ name: 'sys_role', label: 'Role', protection: { @@ -710,7 +746,7 @@ export const SysRoleObject = defineObject({ reason: 'RBAC schema is platform-defined — see ADR-0010.', docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, - fields: [ /* ... */ ], + fields: { /* ... */ }, }); ``` @@ -720,7 +756,7 @@ The same block works on non-object metadata (apps, views, dashboards, flows, agents, tools, skills, reports, email-templates): ```ts -// packages/plugin-auth/src/apps/setup.app.ts +// src/apps/setup.app.ts import { defineApp } from '@objectstack/spec'; export const SetupApp = defineApp({ @@ -742,7 +778,8 @@ export const SetupApp = defineApp({ (`GET ?layers=true`) include `lock`, `lockReason`, `lockDocsUrl`, `lockSource`, and `packageId` so Studio can render the banner. - **Studio**: `ResourceEditPage` renders a banner with the lock reason and the - "查看文档 →" link; edit + delete buttons are hidden according to the lock. + "View docs" link (from `docsUrl`); edit + delete buttons are hidden according + to the lock. - **Package vs Artifact source**: `_lockSource: 'package'` when the lock comes from a code-shipped schema, `'artifact'` when set by a workspace artifact. Artifact locks override package locks (workspace wins). @@ -763,13 +800,16 @@ export const SetupApp = defineApp({ | Feature | When to Consider | |:--------|:-----------------| -| `tenancy` | Multi-tenant SaaS — choose `shared`, `isolated`, or `hybrid` | -| `softDelete` | Regulatory requirement for data retention | -| `versioning` | Audit / compliance — `snapshot`, `delta`, or `event-sourcing` | -| `partitioning` | Tables > 100M rows — `range`, `hash`, or `list` | -| `cdc` | Real-time sync to Kafka, webhooks, or data lakes | -| `encryptionConfig` | GDPR / HIPAA / PCI-DSS field-level encryption | -| `maskingRule` | PII masking for non-privileged users | +| `tenancy` | Multi-tenant SaaS — `{ enabled: true, tenantField: 'tenant_id' }` row-level isolation (DB-per-tenant is an environment/deployment choice, not object metadata) | +| `lifecycle` | Append-only / high-write-rate objects — retention / rotation / archival contract (ADR-0057); see [rules/lifecycle.md](./rules/lifecycle.md) | +| `enable.trash` | Soft-delete with restore — defaults on; explicit `false` disables the recycle bin | +| per-field `trackHistory` | Render a field's value changes as human-readable activity-timeline entries (pair with `enable.trackHistory`, ADR-0052 §5b) | + +> The former `softDelete` / `versioning` object keys were **removed** from the +> spec (#2377, ADR-0049 enforce-or-remove) — authoring them is now a build +> error with upgrade guidance. `partitioning` / `cdc` were never schema keys, +> and the `encryptionConfig` / `maskingRule` field keys were pruned (see +> [Sensitive fields](#sensitive-fields--secret-type--requiredpermissions)). --- diff --git a/skills/objectstack-data/evals/README.md b/skills/objectstack-data/evals/README.md index d6e9bf6314..7d003a00ab 100644 --- a/skills/objectstack-data/evals/README.md +++ b/skills/objectstack-data/evals/README.md @@ -1,46 +1,11 @@ # Evaluation Tests (evals/) -This directory is reserved for future skill evaluation tests. - -## Purpose - -Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. - -## Structure - -When implemented, evals will follow this structure: - -``` -evals/ -├── naming/ -│ ├── test-object-names.md -│ ├── test-field-keys.md -│ └── test-option-values.md -├── relationships/ -│ ├── test-lookup-vs-master-detail.md -│ └── test-junction-patterns.md -├── validation/ -│ ├── test-script-inversion.md -│ └── test-state-machine.md -└── ... -``` - -## Format - -Each eval file will contain: -1. **Scenario** — Description of the task -2. **Expected Output** — Correct implementation -3. **Common Mistakes** — Incorrect patterns to avoid -4. **Validation Criteria** — How to score the output - -## Status - -⚠️ **Not yet implemented** — This is a placeholder for future development. - -## Contributing - -When adding evals: -1. Each eval should test a single, specific rule or pattern -2. Include both positive (correct) and negative (incorrect) examples -3. Reference the corresponding rule file in `rules/` -4. Use realistic scenarios from actual ObjectStack projects +⚠️ **Not yet implemented** — placeholder for future skill evals. + +Candidate data-domain scenarios: naming (snake_case names, lowercase option +values), field-type selection (`secret` vs `password`, `lookup` vs +`master_detail`), relationships (junction object vs multi-value lookup, +`deleteBehavior`), validation (script inversion, `state_machine` transitions, +unique **index** not a validation type), hooks (`before*` vs `after*`, +sandboxed `body` capabilities), and seeds (`externalId` choice, natural-key +lookups, CEL dynamic values). diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 2aeeb83df0..866b473cb2 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -70,9 +70,9 @@ ObjectStack provides **8 lifecycle events** organized by operation type: |:-------|:----------------|:---------------| | **Purpose** | Validation, enrichment, transformation | Side effects, notifications, logging | | **Can modify** | `ctx.input` (mutable) | `ctx.result` (mutable) | -| **Can abort** | Yes (throw error → rollback) | No (operation already committed) | -| **Transaction** | Within transaction | After transaction (unless async: false) | -| **Error handling** | Aborts operation by default | Logged by default (configurable) | +| **Can abort** | Yes (throw error → rollback) | A sync after-hook's throw still rolls back the transaction (see error handling) | +| **Transaction** | Within transaction | **Within the transaction** (unless `async: true` — then in the background, after commit) | +| **Error handling** | Aborts by default (`onError: 'abort'`) | **Also aborts by default** — `onError` defaults to `'abort'` unconditionally; set `onError: 'log'` on side-effect hooks | --- @@ -81,6 +81,7 @@ ObjectStack provides **8 lifecycle events** organized by operation type: Every hook must conform to the `HookSchema`: ```typescript +import { P } from '@objectstack/spec'; import { Hook, HookContext } from '@objectstack/spec/data'; const myHook: Hook = { @@ -104,8 +105,8 @@ const myHook: Hook = { // Optional: Run in background (after* events only) async: false, - // Optional: Conditional execution - condition: "status = 'active' AND amount > 1000", + // Optional: Conditional execution (CEL predicate over `record`) + condition: P`record.status == 'active' && record.amount > 1000`, // Optional: Human-readable description description: 'Validates account data before save', @@ -210,26 +211,32 @@ async: true #### `condition` — Declarative Filtering -Skip handler execution if condition is false: +Skip handler execution if the condition is false. Author it as a **CEL +predicate** over `record` (use `P\`...\`` from `@objectstack/spec`; SQL-style +`=` / `AND` / `IN (...)` is not CEL): ```typescript // Only run for high-value accounts -condition: "annual_revenue > 1000000" +condition: P`record.annual_revenue > 1000000` // Only run for specific statuses -condition: "status IN ('pending', 'in_review')" +condition: P`record.status in ['pending', 'in_review']` // Complex conditions -condition: "type = 'enterprise' AND region = 'APAC' AND is_active = true" +condition: P`record.type == 'enterprise' && record.region == 'APAC' && record.is_active == true` ``` #### `onError` — Error Handling +The default is `'abort'` **unconditionally** — for `after*` hooks too, a sync +after-hook that throws rolls back the transaction. Set `onError: 'log'` +explicitly on `after*` side-effect hooks: + ```typescript -// Abort operation on error (default for before* hooks) +// Abort operation on error — the default for ALL hooks (before* AND after*) onError: 'abort' -// Log error and continue (default for after* hooks) +// Log error and continue — set this on after* side-effect hooks onError: 'log' ``` @@ -347,7 +354,7 @@ set of legal tokens (`HookBodyCapability`) is exactly six: | Token | Unlocks | |:--|:--| -| `api.read` | `ctx.api.object(n).find` / `findOne` / `count` / `aggregate` | +| `api.read` | `ctx.api.object(n).find` / `findOne` / `count` | | `api.write` | `ctx.api.object(n).insert` / `update` / `delete` / `upsert` | | `api.transaction` | `ctx.api.transaction(async () => { … })` — runs the callback's `ctx.api` ops in **one driver transaction** (commit on return, rollback on throw). Pair it with `api.write`. | | `crypto.uuid` | `ctx.crypto.randomUUID()` | @@ -871,12 +878,13 @@ const cascadeAccountUpdate: Hook = { object: 'account', events: ['afterUpdate'], handler: async (ctx) => { - // If account industry changed, update all contacts + // If account industry changed, update all contacts. + // There is NO `updateMany` — bulk updates use update(data, { where, multi: true }). if (ctx.input.industry && ctx.previous?.industry !== ctx.input.industry) { - await ctx.api?.object('contact').updateMany({ - filter: { account_id: ctx.input.id }, - data: { account_industry: ctx.input.industry }, - }); + await ctx.api?.object('contact').update( + { account_industry: ctx.input.industry }, + { where: { account_id: ctx.input.id }, multi: true }, + ); } }, }; @@ -889,8 +897,8 @@ const highValueAccountAlert: Hook = { name: 'high_value_alert', object: 'account', events: ['afterInsert'], - // Only run for high-value accounts - condition: "annual_revenue > 10000000", + // Only run for high-value accounts (CEL) + condition: P`record.annual_revenue > 10000000`, async: true, handler: async (ctx) => { console.log(`🚨 High-value account created: ${ctx.result.name}`); @@ -1309,8 +1317,7 @@ const conditionalHook: Hook = { ## References -- [`@objectstack/spec/src/data/hook.zod.ts`](../../../node_modules/@objectstack/spec/src/data/hook.zod.ts) — Hook schema definition, HookContext interface -- [Examples: app-todo](../../examples/app-todo/src/objects/task.hook.ts) — Simple task hook +- `node_modules/@objectstack/spec/src/data/hook.zod.ts` — Hook schema definition, HookContext interface - [Project hooks pattern](../SKILL.md#lifecycle-hooks) — Hook integration in the data skill --- diff --git a/skills/objectstack-data/rules/datasources.md b/skills/objectstack-data/rules/datasources.md index 9eced69291..eab276f736 100644 --- a/skills/objectstack-data/rules/datasources.md +++ b/skills/objectstack-data/rules/datasources.md @@ -6,7 +6,6 @@ connection to a data store. Objects route to one via their `datasource` field to read/write a **separate** or **external** database. Full field reference: `node_modules/@objectstack/spec/src/data/datasource.zod.ts`. -Narrative guide: `content/docs/data-modeling/external-datasources.mdx`. ## `schemaMode` — who owns the schema diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 7ab8611b35..8e057e787a 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -1,26 +1,33 @@ # Field Types Reference -Quick reference for choosing the right field type from 48 available options. +Quick reference for choosing the right field type from 49 available options. + +> **Config columns list only real `FieldSchema` keys.** Per-type display knobs +> beyond these do **not** exist — an unknown field key is silently stripped at +> parse (dead metadata), so don't invent config like `theme`, `rows`, or +> `fileAttachmentConfig`. Source of truth: +> `node_modules/@objectstack/spec/src/data/field.zod.ts`. ## Text & Content | Type | When to Use | Config | |:-----|:------------|:-------| | `text` | Single-line strings (names, codes, titles) | `maxLength`, `minLength`, `defaultValue` | -| `textarea` | Multi-line plain text (notes, descriptions) | `maxLength`, `rows` | +| `textarea` | Multi-line plain text (notes, descriptions) | `maxLength`, `minLength` | | `email` | Email addresses — built-in format validation | `required`, `unique` | | `url` | Web URLs — built-in format validation | `required` | -| `phone` | Phone numbers | `format` (custom regex) | -| `password` | Masked / hashed input | `minLength`, `hashAlgorithm` | +| `phone` | Phone numbers | `format` | +| `password` | ⚠️ Masked-on-read input. On a generic object the value is stored **PLAINTEXT at rest** (never hashed — one-way hashing applies only inside the auth subsystem's own identity tables). Prefer `secret` for credentials; a generic `password` field triggers a build warning | `minLength`, `maxLength` | +| `secret` | Reversible **encrypted-at-rest** credential (DB password, API key, token) — encrypted on write via `ICryptoProvider`, masked on read, fail-closed (ADR-0100). **The recommended type for credentials** | — | | `markdown` | Markdown-formatted content | `maxLength` | -| `html` | Raw HTML content | `maxLength`, `sanitize` | +| `html` | Raw HTML content | `maxLength` | | `richtext` | WYSIWYG rich text editor | `maxLength` | ## Numbers | Type | When to Use | Config | |:-----|:------------|:-------| -| `number` | Generic numeric value | `min`, `max`, `precision`, `step` | +| `number` | Generic numeric value | `min`, `max`, `precision`, `scale` | | `currency` | Monetary amounts | `currencyConfig` (precision, currencyMode, defaultCurrency) | | `percent` | Percentage values (0-100) | `min`, `max`, `precision` | @@ -28,8 +35,8 @@ Quick reference for choosing the right field type from 48 available options. | Type | When to Use | Config | |:-----|:------------|:-------| -| `date` | Date only (no time component) | `defaultValue`, `min`, `max` | -| `datetime` | Full date + time | `defaultValue`, `timezone` | +| `date` | Date only (no time component) | `defaultValue` | +| `datetime` | Full date + time | `defaultValue` | | `time` | Time only (no date component) | `defaultValue`, `format` | ## Logic @@ -61,48 +68,63 @@ options: [ | Type | When to Use | Key Config | |:-----|:------------|:-----------| -| `lookup` | Reference another object (independent) | `reference`, `referenceFilters`, `multiple` | +| `lookup` | Reference another object (independent) | `reference`, `lookupFilters`, `multiple` | | `master_detail` | Parent–child with lifecycle control | `reference`, `deleteBehavior` (cascade/restrict/set_null) | | `tree` | Hierarchical self-reference | `reference` | +| `user` | Person picker — a lookup specialized to `sys_user` (assignee, watchers). Stored identically to `lookup` | `multiple` (collaborators), `defaultValue: 'current_user'` | -Set `multiple: true` on lookup for many-to-many via junction. +> **`multiple: true` lookup ≠ junction object.** A multi-value lookup is stored +> and read as an **array of ids** on the record — it is NOT a junction table. +> Reach for a **junction object** (two lookups) only when the relationship +> itself carries attributes (role, added_at, …). (#1872) ## Media | Type | When to Use | Config | |:-----|:------------|:-------| -| `image` | Image files (PNG, JPG, GIF, WebP) | `fileAttachmentConfig` (maxSize, allowedTypes, storage) | -| `file` | Generic file attachments | `fileAttachmentConfig`, `allowedExtensions` | -| `avatar` | User/profile picture | `fileAttachmentConfig`, `cropAspectRatio` | -| `video` | Video files | `fileAttachmentConfig`, `maxDuration` | -| `audio` | Audio files | `fileAttachmentConfig`, `maxDuration` | +| `image` | Image files (PNG, JPG, GIF, WebP) | `multiple` | +| `file` | Generic file attachments | `multiple` | +| `avatar` | User/profile picture | — | +| `video` | Video files | — | +| `audio` | Audio files | — | + +There is no per-field attachment config (size limits, allowed types, storage) — +storage concerns live outside the field schema. + +## Embedded (JSON sub-objects) -All use `fileAttachmentConfig` for size limits, allowed types, virus scanning, and storage provider. +Stored as JSON on the parent row — no separate table / FK: + +| Type | When to Use | +|:-----|:------------| +| `composite` | Single embedded sub-object with declared sub-fields | +| `repeater` | Repeating embedded sub-object **array** | +| `record` | Name-keyed **map** of embedded sub-objects (insertion order = display order; ADR-0007) | ## Calculated | Type | When to Use | Config | |:-----|:------------|:-------| -| `formula` | Computed from an expression referencing other fields | `expression`, `resultType` | -| `summary` | Roll-up aggregation from child records | `summaryType` (count/sum/min/max/avg), `summaryField`, `reference` | -| `autonumber` | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | `format` (e.g., `"CASE-{0000}"`, `"AD{YYYYMMDD}{0000}"`) | +| `formula` | Computed from an expression referencing other fields | `expression` (CEL, `record.` prefixes), `returnType` (`'number' \| 'text' \| 'boolean' \| 'date'`) | +| `summary` | Roll-up aggregation from child records | `summaryOperations` ({ object, field, function, relationshipField?, filter? }) | +| `autonumber` | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | `format` (shorthand) / `autonumberFormat` (canonical) — e.g., `"CASE-{0000}"`, `"AD{YYYYMMDD}{0000}"` | ## Enhanced Types | Type | When to Use | Config | |:-----|:------------|:-------| -| `location` | Geographic coordinates (lat/lng) | `defaultZoom`, `enableSearch` | -| `address` | Structured address (street, city, country) | `countryFilter`, `autocomplete` | -| `code` | Syntax-highlighted code editor | `language`, `theme` | -| `json` | JSON data | `schema` (JSON Schema for validation) | -| `color` | Color picker | `format` (hex/rgb/hsl), `alpha` | -| `rating` | Star/heart rating | `max` (default 5), `icon` | +| `location` | Geographic coordinates (lat/lng) | — | +| `address` | Structured address (street, city, country) | — | +| `code` | Syntax-highlighted code editor | `language` | +| `json` | JSON data (untyped escape hatch) | — (validate with a `json_schema` validation rule on the object) | +| `color` | Color picker | — | +| `rating` | Star/heart rating | `max` (default 5) | | `slider` | Numeric slider | `min`, `max`, `step` | -| `signature` | Digital signature pad | `signatureConfig` | -| `qrcode` | QR code generator | `qrConfig` | -| `progress` | Progress bar | `min`, `max`, `showPercentage` | -| `tags` | Free-form tag input | `max`, `delimiter`, `caseSensitive` | -| `vector` | AI/ML embeddings (semantic search, RAG) | `vectorConfig` (dimensions, distanceMetric, indexType) | +| `signature` | Digital signature pad | — | +| `qrcode` | QR code / barcode | — | +| `progress` | Progress bar | `min`, `max` | +| `tags` | Free-form tag input | — | +| `vector` | AI/ML embeddings (semantic search, RAG) | `dimensions` (flat sibling, e.g. `1536`) | ## Field Type Decision Tree @@ -116,6 +138,7 @@ What kind of data? │ ├── Email → email │ ├── URL → url │ ├── Phone → phone +│ ├── Credential (API key, token, DB password) → secret (encrypted at rest — NOT password) │ └── Code → code │ ├── Number? @@ -141,6 +164,7 @@ What kind of data? ├── Reference another object? │ ├── Independent → lookup │ ├── Owned child → master_detail +│ ├── A person (assignee, watcher) → user │ └── Hierarchy → tree │ ├── File/Media? @@ -155,6 +179,11 @@ What kind of data? │ ├── Roll-up → summary │ └── Auto-number → autonumber │ +├── Embedded sub-object (no separate table)? +│ ├── Single → composite +│ ├── Array → repeater +│ └── Name-keyed map → record +│ └── Special? ├── Location → location ├── Address → address @@ -231,9 +260,11 @@ grouped number (never a hardcoded `$`). The same chain backs analytics measures type: 'lookup', reference: 'account', required: true, - referenceFilters: { - status: 'active', - }, + // Structured, picker-honoured filter — the former string[] `referenceFilters` + // was removed (#2377, ADR-0049): it filtered nothing. + lookupFilters: [ + { field: 'status', operator: 'eq', value: 'active' }, + ], } ``` @@ -262,10 +293,12 @@ grouped number (never a hardcoded `$`). The same chain backs analytics measures ### Formula ```typescript +import { F } from '@objectstack/spec'; + { type: 'formula', - expression: 'amount * tax_rate', - resultType: 'currency', + expression: F`record.amount * record.tax_rate`, // CEL — `record.` prefixes required + returnType: 'number', // 'number' | 'text' | 'boolean' | 'date' (no 'currency') } ``` @@ -274,9 +307,12 @@ grouped number (never a hardcoded `$`). The same chain backs analytics measures ```typescript { type: 'summary', - reference: 'invoice_line_item', - summaryType: 'sum', - summaryField: 'amount', + summaryOperations: { + object: 'invoice_line_item', // child object to aggregate + field: 'amount', // child field (ignored for count) + function: 'sum', // 'count' | 'sum' | 'min' | 'max' | 'avg' + // relationshipField / filter — optional (see rules/relationships.md) + }, } ``` @@ -311,14 +347,13 @@ The `format` is literal text interleaved with `{...}` tokens: ```typescript { type: 'vector', - vectorConfig: { - dimensions: 1536, // OpenAI ada-002 - distanceMetric: 'cosine', - indexType: 'hnsw', - }, + dimensions: 1536, // flat sibling — OpenAI ada-002 } ``` +There is **no** `vectorConfig` block — an authored `vectorConfig` is silently +stripped (dead metadata). `dimensions` is the flat field-level key. + ## Incorrect vs Correct ### ❌ Incorrect — Wrong Type for Email diff --git a/skills/objectstack-data/rules/hooks.md b/skills/objectstack-data/rules/hooks.md index 5cf710451a..679db3b267 100644 --- a/skills/objectstack-data/rules/hooks.md +++ b/skills/objectstack-data/rules/hooks.md @@ -27,6 +27,7 @@ The canonical reference includes: ### Hook Definition ```typescript +import { P } from '@objectstack/spec'; import { Hook, HookContext } from '@objectstack/spec/data'; const hook: Hook = { @@ -38,7 +39,7 @@ const hook: Hook = { }, priority: 100, // Optional: execution order async: false, // Optional: background execution (after* only) - condition: "status = 'active'", // Optional: conditional execution + condition: P`record.status == 'active'`, // Optional: conditional execution (CEL) }; ``` diff --git a/skills/objectstack-data/rules/naming.md b/skills/objectstack-data/rules/naming.md index 76ddad3b9c..0e78ddaf77 100644 --- a/skills/objectstack-data/rules/naming.md +++ b/skills/objectstack-data/rules/naming.md @@ -8,7 +8,7 @@ ObjectStack enforces strict naming conventions to ensure consistency and machine |:--------|:-----------|:--------|:--------| | Object `name` | `snake_case` | `/^[a-z_][a-z0-9_]*$/` | `project_task` | | Field keys | `snake_case` | `/^[a-z_][a-z0-9_]*$/` | `first_name`, `due_date` | -| Schema property keys (TS config) | `camelCase` | Standard JS | `maxLength`, `referenceFilters` | +| Schema property keys (TS config) | `camelCase` | Standard JS | `maxLength`, `lookupFilters` | | Option `value` | lowercase machine ID | lowercase | `in_progress` | | Option `label` | Any case | — | `"In Progress"` | @@ -56,9 +56,10 @@ fields: { ```typescript { - type: 'text', - max_length: 255, // ❌ snake_case not allowed for TS config - reference_filters: {}, // ❌ snake_case not allowed for TS config + type: 'lookup', + reference: 'account', + lookup_filters: [], // ❌ snake_case not allowed for TS config + max_length: 255, // ❌ snake_case not allowed for TS config } ``` @@ -66,9 +67,10 @@ fields: { ```typescript { - type: 'text', - maxLength: 255, // ✅ camelCase for TS config - referenceFilters: {}, // ✅ camelCase for TS config + type: 'lookup', + reference: 'account', + lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }], // ✅ camelCase + maxLength: 255, // ✅ camelCase } ``` diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index f627cecc64..a6bb227277 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -405,7 +405,7 @@ export default ObjectSchema.create({ 4. **deleteBehavior on master_detail** — Always specify cascade/restrict/set_null 5. **Required on master_detail** — Child should always require parent 6. **Roll-ups for aggregation** — Use summary fields on parent for counts/sums -7. **referenceFilters for scoping** — Limit lookup options to relevant records +7. **lookupFilters for scoping** — Limit lookup options to relevant records (`lookupFilters: [{ field, operator: 'eq', value }]`) ## Performance Considerations From cdfc4925bfdfc859afc8becff03b2f00ca4e5970 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:22:06 +0000 Subject: [PATCH 08/12] skills(automation): make every example parse and actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found the five load-bearing examples broken against the schemas the skill itself cites: - Cron cadence lives in the START NODE's config.schedule ({ type: 'cron', expression } or bare string) — the top-level schedule: key is silently stripped and the flow never binds to the scheduler. - update_record/get_record filters are ObjectQL where MAPS ($in/$lt), not {field,operator,value} triples (that's the UI view-filter dialect); date values use {TODAY() + N} template tokens — cel envelopes are not evaluated in CRUD filter values. - Decision routing is edge-condition driven; unguarded out-edges all run in parallel — the old pattern executed both branches. Edges now carry mutually-exclusive cel conditions. - FlowSchema requires label on every flow and node, and variables is an array of { name, type, isInput } — all examples now pass FlowSchema.parse (runtime-validated via defineFlow). - http_request → http (retired alias), query_record → get_record (NO_EXECUTOR throw), object: → objectName:; node table regenerated from the 20-value FlowNodeAction enum (adds notify/map; script row = registered callables only, inline JS never executes). - Approval behavior enum completed (quorum/per_group + minApprovals + per-approver group); waitEventConfig documented as canonical; unset http timeoutMs = no timeout at all; removed the false claim that bare field refs in start/edge conditions resolve to null; description stops advertising the retired workflow metadata type; evals fixed (expected output now passes its own criterion). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- skills/objectstack-automation/SKILL.md | 203 +++++++++++------- skills/objectstack-automation/evals/README.md | 43 ++-- .../evals/approvals/test-revise-loop.md | 9 +- 3 files changed, 154 insertions(+), 101 deletions(-) diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index f8e3621503..d2b141cfc0 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -1,18 +1,18 @@ --- name: objectstack-automation description: > - Design ObjectStack automation — Flows (visual logic), Workflows - (declarative rules), Triggers, Approvals, scheduled jobs, and webhooks. - Use when the user is adding `*.flow.ts` / `*.workflow.ts`, wiring an + Design ObjectStack automation — Flows (visual logic), Triggers, + Approvals, state machines, scheduled jobs, and webhooks. + Use when the user is adding `*.flow.ts`, wiring an event-driven rule, or modelling an approval chain. Do not use for data lifecycle hooks at the object layer (see objectstack-data) or for kernel / plugin events (see objectstack-platform). CEL expressions in flow - conditions / workflow predicates: load objectstack-formula alongside. + conditions / edge guards: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.1" + version: "1.2" domain: automation tags: flow, workflow, trigger, approval, state-machine, scheduled, webhook --- @@ -20,8 +20,8 @@ metadata: # Automation Design — ObjectStack Automation Protocol Expert instructions for designing business automation using the ObjectStack -specification. This skill covers Flows (visual logic orchestration), Workflows -(state machines & approvals), Triggers (event-driven automation), and ETL +specification. This skill covers Flows (visual logic orchestration), state +machines & approvals, Triggers (event-driven automation), and ETL pipelines. --- @@ -55,13 +55,14 @@ parallel. Flows are the primary automation building block in ObjectStack. |:-----|:------------| | `autolaunched` | Runs without user interaction — triggered by events, APIs, or other flows | | `screen` | Interactive — presents UI screens to the user (wizards, forms) | -| `schedule` | Runs on a cron schedule (daily cleanup, weekly reports) — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* | +| `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** (daily cleanup, weekly reports) — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* | | `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`) | | `api` | Invoked explicitly via the API / `engine.execute()`, **or** bound as an inbound **webhook**: `POST /api/v1/automation/hooks/:flowName/:hookId` (see *Inbound webhook triggers* below) | ### Flow Node Types -Flows are built from **19 node types**: +Flows are built from **20 built-in node types** (the `FlowNodeAction` seed set — +plugins register more via `registerNodeExecutor`, e.g. `approval` below): #### Control Flow @@ -69,11 +70,12 @@ Flows are built from **19 node types**: |:-----|:--------| | `start` | Entry point — every flow has exactly one | | `end` | Exit point — can have multiple (early exit, error exit) | -| `decision` | Conditional branching (if/else/switch) | -| `loop` | Iterate over a collection | +| `decision` | Conditional branching — routed by **edge `condition` predicates**, not node config (see the approval example below) | +| `loop` | Iterate a bounded body region over a collection | +| `map` | Sequential multi-instance — invoke a subflow once per item of a collection; each iteration may pause (batch approvals) | | `parallel_gateway` | Fork execution into parallel branches | | `join_gateway` | Synchronise parallel branches back together | -| `wait` | Pause execution until a condition or time elapses | +| `wait` | Pause execution until a timer elapses or a named signal arrives | | `boundary_event` | Attach to another node — fires on timeout or error | | `subflow` | Invoke another flow (reusable composition) | @@ -85,15 +87,16 @@ Flows are built from **19 node types**: | `create_record` | Insert a new record | | `update_record` | Modify existing records | | `delete_record` | Remove records | -| `query_record` | Fetch records with filters | +| `get_record` | Fetch records with filters — there is **no `query_record`** node (that name has no executor and throws) | #### External Integration | Node | Purpose | |:-----|:--------| -| `http_request` | Call an external HTTP API | +| `http` | Call an external HTTP API — canonical since protocol 11.0; `http_request` survives only as a deprecation-window alias | +| `notify` | Send a notification through the messaging service (inbox channel by default) | | `connector_action` | Invoke a pre-built integration connector | -| `script` | Execute custom JavaScript/TypeScript logic | +| `script` | Dispatch to a **registered** callable — `config.actionType` (`email`/`slack`) or a registered `config.function`. Inline `config.script` JS is **not** executed (see pitfall 9) | | `screen` | Display a UI form to the user (screen flows only) | #### Human Decision @@ -104,63 +107,82 @@ Flows are built from **19 node types**: ### Flow Variables -Every flow defines input/output variables: +Every flow defines input/output variables. `variables` is an **array** of +`{ name, type, isInput, isOutput }` entries — not a name-keyed map, and there +is no `label` property on a variable: ```typescript -variables: { - case_id: { +variables: [ + { + name: 'case_id', type: 'text', - label: 'Case ID', isInput: true, // passed in when flow is invoked isOutput: false, }, - approval_result: { + { + name: 'approval_result', type: 'boolean', - label: 'Approved?', isInput: false, isOutput: true, // returned when flow completes }, -} +], ``` ### Flow Example — Auto-Escalate Overdue Cases > **Nodes connect via `edges`, not a `next` property.** The engine traverses > `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored. -> `update_record` selects rows with **`filter`** and writes with **`fields`** +> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map** +> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter +> `[{ field, operator, value }]` triples — and writes with **`fields`** > (a single call updates *every* matching row — no per-row loop needed). +> `label` is **required** on the flow and on every node. ```typescript { name: 'escalate_overdue_cases', + label: 'Escalate Overdue Cases', type: 'schedule', - schedule: cron`0 9 * * *`, // daily at 09:00 + runAs: 'system', // a scheduled run has no trigger user — elevate explicitly nodes: [ - { id: 'start', type: 'start' }, + { + id: 'start', + type: 'start', + label: 'Daily at 09:00', + // The cadence lives HERE, on the start node's config — FlowSchema has NO + // top-level `schedule` key (one there is silently stripped and the flow + // never binds). A bare cron string also works: schedule: '0 9 * * *'. + // Do NOT use the cron`…` tagged template — its envelope is not a + // recognized schedule shape. + config: { schedule: { type: 'cron', expression: '0 9 * * *' } }, + }, { id: 'escalate_overdue', type: 'update_record', + label: 'Escalate Overdue Cases', config: { - object: 'support_case', - // which rows to update — `filter`, not `recordId` - filter: [ - { field: 'status', operator: 'in', value: ['new', 'open'] }, - { field: 'due_date', operator: 'less_than', value: cel`today()` }, - ], + objectName: 'support_case', + // which rows to update — `filter` is a `where` map, not filter triples + filter: { + status: { $in: ['new', 'open'] }, + due_date: { $lt: '{TODAY()}' }, // template token → today's date at run time + }, // what to write — `fields`, not `values` fields: { status: 'escalated' }, }, }, { id: 'notify_manager', - type: 'http_request', + type: 'http', + label: 'Notify Manager', config: { url: 'https://hooks.slack.com/services/...', method: 'POST', body: { text: 'Escalated overdue support cases.' }, + timeoutMs: 10000, // unset = NO timeout at all — always set one }, }, - { id: 'end', type: 'end' }, + { id: 'end', type: 'end', label: 'End' }, ], edges: [ { id: 'e1', source: 'start', target: 'escalate_overdue' }, @@ -196,6 +218,7 @@ dead-end. label: 'Case Lifecycle', field: 'status', // the field that holds the state message: 'Invalid status transition.', + initialStates: ['new'], // states a record may be CREATED in (#3165) transitions: { new: ['open'], open: ['escalated', 'resolved'], @@ -209,11 +232,15 @@ dead-end. Notes: - **One rule per field.** Parallel lifecycles (e.g. `status` + `payment_status`) are N separate `state_machine` rules, one per field. +- **`initialStates`** (optional, #3165) gates INSERT: a record created with its + state field outside this list is rejected. `transitions` only governs + updates, so without it a record can be born mid-flow (e.g. created already + `resolved`). Omit to keep the legacy no-check-on-insert behavior. - **Conditional transitions / side effects are NOT part of the machine.** A guard is expressed as a sibling `script` / `conditional` validation rule; "do something when the state changes" is a **record-triggered Flow** - (ADR-0019) — see the migrated `high-value-deal` / `stale-opportunity` flows in - `examples/app-crm`. + (ADR-0019) — a `record_change` flow whose start-node condition gates on the + transition, e.g. `previous.status != 'escalated' && record.status == 'escalated'`. - **Introspection:** `GET /metadata/objects/:name/state/:field?from=:state` returns the legal next states so UIs/agents can read the transition table instead of hard-coding it (`next: null` when no FSM governs the field). @@ -249,6 +276,7 @@ is one diagram a reviewer (or AI) can read end-to-end. { id: 'start', type: 'start', + label: 'On Opportunity Update', config: { objectName: 'opportunity', triggerType: 'record-after-update', @@ -261,12 +289,16 @@ is one diagram a reviewer (or AI) can read end-to-end. label: 'Sales Manager Review', config: { approvers: [{ type: 'position', value: 'sales_manager' }], - behavior: 'first_response', // or 'unanimous' + behavior: 'first_response', // or 'unanimous' / 'quorum' / 'per_group' lockRecord: true, // lock the record while pending approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row }, }, - { id: 'needs_director', type: 'decision', config: { condition: cel`record.amount > 500000` } }, + // Decision routing lives on the OUT-EDGES, not in node config: the engine + // evaluates each out-edge's `condition` and follows every match — and an + // out-edge with NO condition ALWAYS runs (all such edges execute in + // PARALLEL). Guard every branch with a condition — see e4/e5 below. + { id: 'needs_director', type: 'decision', label: 'Needs Director?' }, { id: 'director_signoff', type: 'approval', @@ -277,10 +309,10 @@ is one diagram a reviewer (or AI) can read end-to-end. approvalStatusField: 'approval_status', }, }, - { id: 'mark_won', type: 'update_record', + { id: 'mark_won', type: 'update_record', label: 'Mark Won', config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } }, - { id: 'approved', type: 'end' }, - { id: 'rejected', type: 'end' }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, ], edges: [ { id: 'e1', source: 'start', target: 'manager_review', @@ -288,8 +320,12 @@ is one diagram a reviewer (or AI) can read end-to-end. condition: cel`record.amount > 100000` }, { id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' }, { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' }, - { id: 'e4', source: 'needs_director', target: 'director_signoff', label: 'true' }, - { id: 'e5', source: 'needs_director', target: 'mark_won', label: 'false' }, + // Decision branches: mutually-exclusive edge `condition` predicates. + // Without them BOTH branches would execute (unguarded edges run in parallel). + { id: 'e4', source: 'needs_director', target: 'director_signoff', label: 'true', + condition: cel`record.amount > 500000` }, + { id: 'e5', source: 'needs_director', target: 'mark_won', label: 'false', + condition: cel`record.amount <= 500000` }, { id: 'e6', source: 'director_signoff', target: 'mark_won', label: 'approve' }, { id: 'e7', source: 'director_signoff', target: 'rejected', label: 'reject' }, { id: 'e8', source: 'mark_won', target: 'approved' }, @@ -329,9 +365,12 @@ Three pieces author it: ```typescript { - id: 'manager_review', type: 'approval', + id: 'manager_review', type: 'approval', label: 'Manager Review', config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 }, }, +// The signal keys may also live in the spec-canonical node-level +// `waitEventConfig` block (FlowNodeSchema); the wait executor reads +// `waitEventConfig` first and falls back to these loose `config` keys. { id: 'wait_revision', type: 'wait', label: 'Awaiting Revision', config: { eventType: 'signal', signalName: 'budget_revision' } }, // …among the approval's edges… @@ -344,8 +383,8 @@ Three pieces author it: > submitter nowhere to resubmit), and a resubmit edge left **without** > `type: 'back'` (an unmarked cycle `registerFlow` rejects). Resubmit is an > explicit verb (`POST /api/v1/approvals/requests/:id/resubmit`), never a -> record-save. See `examples/app-showcase` → `showcase_budget_approval` for the -> canonical shape. +> record-save. See the `showcase_budget_approval` flow in the showcase app in +> the framework repo for the canonical shape. ### Re-homing the old process model @@ -388,8 +427,9 @@ branch — you never resume the flow by hand. | Field | Purpose | |:------|:--------| -| `approvers` | Who may act (≥ 1 — see Approver Types above) | -| `behavior` | `first_response` (first approver decides) or `unanimous` (all must approve). Default `first_response` | +| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own | +| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` | +| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock | | `lockRecord` | Lock the triggering record from edits while pending. Default `true` | | `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) | | `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) | @@ -402,7 +442,7 @@ These are wired on the **graph**, not in node config: - **Conditional step** — put a `decision` node before the Approval node, or a `condition` on the edge entering it (the old per-step `entryCriteria`). - **On approve / on reject** — wire downstream nodes (`update_record`, - `http_request`, an email node, …) to the `approve` / `reject` out-edge. + `http`, a `notify` node, …) to the `approve` / `reject` out-edge. - **Roll back on reject** — route the `reject` edge as a **back-edge** to an earlier node so the submitter can revise (the old `back_to_previous`). - **Send back for revision (ADR-0044)** — distinct from a plain reject: an @@ -487,11 +527,13 @@ read at runtime, not Zod-validated): ```typescript { name: 'notify_on_escalation', + label: 'Notify on Escalation', type: 'record_change', nodes: [ { id: 'start', type: 'start', + label: 'On Case Escalated', config: { objectName: 'support_case', triggerType: 'record-after-update', @@ -525,30 +567,36 @@ day, a threshold is never missed. ```typescript { name: 'renewal_alert', + label: 'Renewal Alert', type: 'schedule', runAs: 'system', // a sweep has no trigger user — elevate explicitly - nodes: [{ - id: 'start', type: 'start', - config: { - timeRelative: { - object: 'contracts', - dateField: 'end_date', - offsetDays: [60, 30, 7], // fire exactly at T-60 / T-30 / T-7 - // — or — withinDays: 30 // "expiring within 30 days" (negative = overdue lookback) - filter: { status: 'active' }, // optional, ANDed with the date window - // maxRecords: 1000 // optional per-sweep cap (default 1000) + nodes: [ + { + id: 'start', type: 'start', label: 'Daily Sweep', + config: { + timeRelative: { + object: 'contracts', + dateField: 'end_date', + offsetDays: [60, 30, 7], // fire exactly at T-60 / T-30 / T-7 + // — or — withinDays: 30 // "expiring within 30 days" (negative = overdue lookback) + filter: { status: 'active' }, // optional, ANDed with the date window + // maxRecords: 1000 // optional per-sweep cap (default 1000) + }, + // Optional sweep cadence; omit for daily 08:00 UTC. Plain shape only: + // schedule: { type: 'cron', expression: '0 8 * * *' } }, - // schedule: cron`0 8 * * *` // optional sweep cadence; omit for daily 08:00 UTC }, - }, /* …downstream nodes, connected via `edges` */], + // …downstream nodes (notify, update_record, …) + ], + edges: [ /* start → downstream */ ], } ``` Exactly one of `offsetDays` (discrete T-minus days) or `withinDays` (a range; negative = overdue) is required. Ships in `@objectstack/trigger-schedule` — needs `requires: ['automation', 'triggers']` **plus `'job'`** (the sweep cadence -runs on the job service). See the -[Time Relative Trigger reference](/docs/references/automation/time-relative-trigger). +runs on the job service). Full descriptor schema: +`node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts`. --- @@ -562,10 +610,10 @@ runs on the job service). See the scenarios. 3. **Use variables for all dynamic values.** Never hard-code record IDs or API keys in node config. -4. **Prefer `query_record` over multiple `http_request` calls** when the data +4. **Prefer `get_record` over multiple `http` calls** when the data lives in ObjectStack. -5. **Set `timeoutMs` on HTTP nodes.** Default is generous; tighten it for - critical paths. +5. **Always set `timeoutMs` on `http` nodes.** Unset means **no timeout at + all** — a hung endpoint stalls the run indefinitely. ### State Machine Design (ADR-0020) @@ -637,11 +685,14 @@ them right the first time: **window** (`$gte`/`$lt`), never an equality: ```ts filter: { status: 'active', $or: [ - { end_date: { $gte: cel`daysFromNow(7)`, $lt: cel`daysFromNow(8)` } }, - { end_date: { $gte: cel`daysFromNow(30)`, $lt: cel`daysFromNow(31)` } }, - { end_date: { $gte: cel`daysFromNow(60)`, $lt: cel`daysFromNow(61)` } }, + { end_date: { $gte: '{TODAY() + 7}', $lt: '{TODAY() + 8}' } }, + { end_date: { $gte: '{TODAY() + 30}', $lt: '{TODAY() + 31}' } }, + { end_date: { $gte: '{TODAY() + 60}', $lt: '{TODAY() + 61}' } }, ] } ``` + (Use `{TODAY() + N}` template tokens in CRUD-node filter values — a + `cel\`…\`` envelope is not evaluated there and would be compared as a + literal object.) Abutting windows tile the timeline so each record matches exactly one tier — fires once, idempotent, no guard field. For "days remaining" in the message, `daysBetween(today(), record.end_date)`. @@ -705,11 +756,13 @@ metadata first; reserve custom code for edge-case integrations. ## Verify your work -Flow/workflow predicates fail **silently at runtime** when malformed: a bare -field ref in a `start`/`decision` condition or an edge guard (`status == 'open'` -instead of `record.status == 'open'`) resolves to `null`/`false`, so the flow -"fires" but does nothing — and nothing errors at edit time. Catch it at author -time before reporting a flow done: +Flow predicates fail **silently at runtime** when malformed: a typo'd field +name, an unknown function, or a `{…}`-wrapped reference in a condition +evaluates to `null`/`false`, so the flow "fires" but does nothing — and nothing +errors at edit time. (Bare field refs like `status == 'open'` DO resolve in +start/decision/edge conditions — the engine flattens the trigger record's +fields into scope — but `record.status == 'open'` remains the canonical style.) +Catch it at author time before reporting a flow done: ```bash os validate # CEL/predicate validation (record. existence) + schema @@ -717,7 +770,7 @@ os validate # CEL/predicate validation (record. existence) + schema ``` This runs the ADR-0032 expression gate over every flow condition, edge guard, -workflow predicate, validation rule and sharing rule, exiting non-zero with a +validation rule and sharing rule, exiting non-zero with a located, corrective message. Remember conditions are **bare CEL** (`record.status == 'x'`); only string node fields use `{…}` templates — see objectstack-formula. In a scaffolded project this is `npm run validate`. diff --git a/skills/objectstack-automation/evals/README.md b/skills/objectstack-automation/evals/README.md index d6e9bf6314..c41534a1dc 100644 --- a/skills/objectstack-automation/evals/README.md +++ b/skills/objectstack-automation/evals/README.md @@ -1,46 +1,43 @@ # Evaluation Tests (evals/) -This directory is reserved for future skill evaluation tests. +Evaluation tests (evals) validate that AI assistants correctly understand and +apply the rules defined in this skill when generating automation metadata — +flows, approval chains, triggers, and scheduled sweeps. -## Purpose +## Current evals -Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance. +| Eval | Covers | +|:-----|:-------| +| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` | -## Structure +## Planned structure -When implemented, evals will follow this structure: +Future evals extend the same layout, one folder per automation concern: ``` evals/ -├── naming/ -│ ├── test-object-names.md -│ ├── test-field-keys.md -│ └── test-option-values.md -├── relationships/ -│ ├── test-lookup-vs-master-detail.md -│ └── test-junction-patterns.md -├── validation/ -│ ├── test-script-inversion.md -│ └── test-state-machine.md -└── ... +├── approvals/ +│ ├── test-revise-loop.md ← implemented +│ └── test-quorum-behaviors.md (planned — quorum / per_group sign-off) +├── flows/ +│ ├── test-schedule-binding.md (planned — cadence on the start node, never top-level) +│ └── test-decision-edges.md (planned — edge-condition branching; unguarded edges run in parallel) +└── triggers/ + └── test-time-relative.md (planned — timeRelative sweep vs record-change date-equality) ``` ## Format -Each eval file will contain: +Each eval file contains: 1. **Scenario** — Description of the task 2. **Expected Output** — Correct implementation 3. **Common Mistakes** — Incorrect patterns to avoid 4. **Validation Criteria** — How to score the output -## Status - -⚠️ **Not yet implemented** — This is a placeholder for future development. - ## Contributing When adding evals: -1. Each eval should test a single, specific rule or pattern +1. Each eval should test a single, specific rule or pattern from `SKILL.md` 2. Include both positive (correct) and negative (incorrect) examples -3. Reference the corresponding rule file in `rules/` +3. Name the `SKILL.md` section the eval enforces 4. Use realistic scenarios from actual ObjectStack projects diff --git a/skills/objectstack-automation/evals/approvals/test-revise-loop.md b/skills/objectstack-automation/evals/approvals/test-revise-loop.md index aab57df76d..7cac2bfb27 100644 --- a/skills/objectstack-automation/evals/approvals/test-revise-loop.md +++ b/skills/objectstack-automation/evals/approvals/test-revise-loop.md @@ -23,12 +23,13 @@ revision window, and a **declared back-edge** closing the loop: ```typescript { name: 'budget_approval', + label: 'Budget Approval', // `label` is REQUIRED on the flow and every node type: 'autolaunched', nodes: [ - { id: 'start', type: 'start', + { id: 'start', type: 'start', label: 'On Budget Increase', config: { objectName: 'project', triggerType: 'record-after-update', condition: 'budget > 100000 && budget != previous.budget' } }, - { id: 'manager_review', type: 'approval', + { id: 'manager_review', type: 'approval', label: 'Manager Review', config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 } }, // send-back budget { id: 'wait_revision', type: 'wait', label: 'Awaiting Revision', @@ -47,12 +48,14 @@ revision window, and a **declared back-edge** closing the loop: } ``` -Mirrors `examples/app-showcase` -> `showcase_budget_approval`. +Mirrors the canonical `showcase_budget_approval` flow in the showcase app in +the framework repo. ## Common Mistakes | Mistake | Why it is wrong | Caught by | |---|---|---| +| Missing `label` on the flow or on a node | `label` is required by `FlowSchema` — `FlowSchema.parse` / `registerFlow` rejects the definition before any graph validation runs | `registerFlow` (schema parse) | | Resubmit edge **without** `type: 'back'` | `registerFlow` validates the graph-minus-back-edges as a DAG, so it rejects the cycle as un-declared | `registerFlow`; lint `flow-approval-revise-unmarked-backedge` | | `revise` edge to a wait node that **never loops back** | A valid DAG (registerFlow accepts it), but the submitter has nowhere to resubmit — the branch dead-ends | lint `flow-approval-revise-dead-end` | | `maxRevisions: 0` together with a `revise` edge | Send-back is disabled, so every revise auto-rejects and the branch never runs | lint `flow-approval-revise-disabled` | From 57f2e2c741ef41c566e107f7ef61e7dfb24f813f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:22:06 +0000 Subject: [PATCH 09/12] skills(ui): fix schema-enum errors and broken pointers; correct contract generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review found a dense cluster of P0 schema errors that made generated UI metadata fail os validate or render unknown components: - Page types are record|home|app|utility|list — record_detail/ app_launcher/utility_bar don't exist; page examples fixed (type: 'record', object: not objectName:) and all definePage/defineDataset examples now carry the required label. - Interface-page rule updated to the revised ADR-0047 model: the page IS the view definition (page-owned columns/sort/filterBy); sourceView is a deprecated fallback. - Report types = tabular|summary|matrix|joined (charts are the embedded chart: config); dashboard widget type is the 18-value ChartTypeSchema taxonomy; widget placement is optional layout (not required position). - Action locations: global → global_nav (+record_related/ record_section); nav divider → separator; requiresCapability → requiresService; component names record:related_list, line_items, object-metric/object-chart, — all verified against the objectui block registry. - DATE_MACRO_TOKENS/isDateMacroToken import from @objectstack/spec/data (not root); monorepo-relative ADR/examples/content pointers replaced with plain text or shipped zod paths; stale dated objectui coverage scan removed. - build-react-blocks-contract.ts no longer marks .default()-carrying props as required (Zod v4 output-io artifact); contract + reference regenerated; provenance now cites the @objectstack/spec/ui module instead of a non-shipping src path; evals cleaned of internal issue refs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Kj2MJEPXoUXC4LiC3XYJc --- .../scripts/build-react-blocks-contract.ts | 10 +- skills/objectstack-ui/SKILL.md | 202 ++++++++++-------- .../contracts/react-blocks.contract.json | 52 ++--- skills/objectstack-ui/evals/README.md | 53 ++--- .../evals/analytics-inline-vs-dataset.json | 5 +- .../objectstack-ui/references/react-blocks.md | 16 +- 6 files changed, 171 insertions(+), 167 deletions(-) diff --git a/packages/spec/scripts/build-react-blocks-contract.ts b/packages/spec/scripts/build-react-blocks-contract.ts index fd92e18496..d90aab3afa 100644 --- a/packages/spec/scripts/build-react-blocks-contract.ts +++ b/packages/spec/scripts/build-react-blocks-contract.ts @@ -77,7 +77,11 @@ function dataProps(schema: any, allow?: string[]): Prop[] { name, type: renderType(node), kind: 'data', - required: required.includes(name), + // z.toJSONSchema emits output-io semantics, where a `.default()`-carrying + // prop is always present — so it lands in `required` even though an author + // may omit it. A prop with a default is optional to WRITE, which is what + // this contract documents; only default-less required props stay required. + required: required.includes(name) && node?.default === undefined, description: clip(node?.description), })); } @@ -101,7 +105,7 @@ const blocks = REACT_BLOCKS.map((b) => { const contract = { version: 2, adr: 'ADR-0081', - source: 'GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.', + source: "GENERATED from the REACT_BLOCKS definition in the '@objectstack/spec/ui' module — data props from the spec zod schemas, binding/controlled/callback from the React overlay.", note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.", blocks, }; @@ -112,7 +116,7 @@ const esc = (s: string) => String(s).replace(/\|/g, '\\|'); const L: string[] = []; L.push('---'); L.push('title: React-tier component contract'); -L.push("description: Props each injected component accepts in kind:'react' page source (ADR-0081). GENERATED from packages/spec/src/ui/react-blocks.ts — do not edit by hand."); +L.push("description: Props each injected component accepts in kind:'react' page source (ADR-0081). GENERATED from the REACT_BLOCKS definition in the '@objectstack/spec/ui' module — do not edit by hand."); L.push('---'); L.push(''); L.push('{/* GENERATED by packages/spec/scripts/build-react-blocks-contract.ts — do not edit. */}'); diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 5a460fce5e..e01c40472e 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -16,10 +16,10 @@ description: > ships with the platform). CEL expressions in visibility/conditional rules: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec Zod schemas (v4+) +compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.1" + version: "1.2" domain: ui tags: view, app, page, dashboard, report, chart, action, widget, doc --- @@ -38,7 +38,7 @@ App navigation, Dashboards, Reports, and Actions. - You are designing a **form layout** (simple, tabbed, wizard). - You are building an **app** with structured navigation menus. - You need a **dashboard** with widget grids. -- You are adding **reports** (tabular, summary, matrix, chart). +- You are adding **reports** (tabular, summary, matrix, joined). - You are configuring **actions** (buttons, URL jumps, screen flows). - You are writing **package documentation** (`src/docs/*.md`) that ships with the package and renders at `/docs/`. @@ -58,6 +58,8 @@ App navigation, Dashboards, Reports, and Actions. | `timeline` | Chronological activity stream | | `gantt` | Project management with dependency tracking | | `map` | Geospatial records with `location` fields | +| `chart` | Aggregate visualisation over the object (mini chart view) | +| `tree` | Self-referencing hierarchy (tree-grid) | ### Form Views @@ -165,7 +167,7 @@ app-level default override, not an object key. For arrangements the relationship layer can't express — filtered splits (e.g. Open vs Closed tabs), a chart/report tab, exact tab ordering — assign the object a **custom record Page** and lay it out explicitly with `record:related_list` (or inline-editable -`record:line_items`) blocks. +`line_items`) blocks. ### Field Conditional Rules in Forms @@ -213,8 +215,7 @@ export const CaseViews = defineView({ > **Never export a bare flat view object** (`{ name, label, type, columns }` > at top level). It is not a valid view container — nothing registers and no > view appears in the switcher. Every view lives under `list` / `listViews` / -> `formViews`. Full canonical reference: -> `examples/app-showcase/src/ui/views/task.view.ts`. +> `formViews`, exactly as in the `defineView` example above. ### Data Source (`data`) @@ -520,12 +521,12 @@ export const CrmApp = App.create({ | `object` | `objectName`, `viewName?`, `recordId?`, `filters?`, `label`, `icon` | Link to an object list, a named view, a record deep-link, or a `filters` slice on the bare data surface. Target precedence: `recordId` → `filters` → `viewName` | | `dashboard` | `dashboardName`, `label`, `icon` | Link to a dashboard | | `report` | `reportName`, `label`, `icon` | Link to a report | -| `page` | `pageName`, `label`, `icon` | Link to a custom Page (`type: 'home' | 'app_launcher' | ...`) | +| `page` | `pageName`, `label`, `icon` | Link to a custom Page (`type: 'home' | 'list' | ...`) | | `url` | `url`, `label`, `icon` | External or custom URL | -| `divider` | — | Visual separator | +| `separator` | — | Visual separator | -> **`requiresObject` / `requiresCapability`:** Use these on any item that -> depends on an optional system object or capability so the nav item is +> **`requiresObject` / `requiresService`:** Use these on any item that +> depends on an optional system object or kernel service so the nav item is > automatically hidden when missing — never hard-code conditional UI. --- @@ -539,13 +540,19 @@ selects named `dimensions` + `values`, picks a chart `type`, and sets a ### Widget Types -| Type | Purpose | -|:-----|:--------| -| `metric` | Single KPI number (count, sum, avg) | -| `chart` | Bar, line, pie, donut, area chart | -| `list` | Embedded list view (mini table) | -| `calendar` | Embedded calendar widget | -| `custom` | Custom component (HTML / React) | +A widget's `type` is its **chart type** (`ChartTypeSchema`; defaults to +`metric`) — there are no separate `list` / `calendar` / `custom` widget kinds: + +| Family | `type` values | +|:-------|:--------------| +| Single value | `metric`, `kpi`, `gauge`, `solid-gauge`, `bullet` (all render the number today; gauge variants gain a dial when a gauge renderer lands) | +| Comparison | `bar`, `horizontal-bar`, `column` | +| Trend | `line`, `area` | +| Distribution | `pie`, `donut`, `funnel` | +| Relationship | `scatter` | +| Composition | `treemap`, `sankey` | +| Advanced | `radar` | +| Tabular | `table`, `pivot` | See the **Production Pattern** section below for the full `Dashboard` shape with `refreshInterval`, header actions, date range, @@ -562,8 +569,8 @@ allowed joins, intrinsic filter, dimensions, and certified measures. The legacy per-widget inline query (`object` + `categoryField` + `valueField` + `aggregate`) **was removed** — a widget now requires `dataset` + `values`; the inline fields are dropped and a widget lacking `dataset` fails `os validate`. Reports bind the same -way (`dataset` + `rows` + `values` + `runtimeFilter`). Full guide: **Guides → -Analytics Datasets** (`content/docs/data-modeling/analytics.mdx`). +way (`dataset` + `rows` + `values` + `runtimeFilter`). The dataset shape is +`DatasetSchema` — see `node_modules/@objectstack/spec/src/ui/dataset.zod.ts`. A widget's presentation-scope `filter` flows into the query as the runtime filter; keep `filter` on the widget when binding a dataset. @@ -649,9 +656,11 @@ dataset, so Level B only surfaces in Studio previews and hand-coded react-page | `tabular` | Flat data table with columns and filters | | `summary` | Grouped data with subtotals (e.g., revenue by region) | | `matrix` | Cross-tab / pivot table (`rows` down × `columns` across) | -| `chart` | Visual chart report | | `joined` | Multi-block analytic surface (combines several sub-reports) | +There is no `chart` report type — a report *visualizes* via its embedded +`chart:` config (see the example below). + ### Report Configuration @@ -695,7 +704,7 @@ Object list UI has **three run modes**, selected by the navigation item shape: | | Data mode (`type: 'object'`) | Bare slice (`type: 'object'` + `filters`) | Interface mode (`type: 'page'`) | |:--|:--|:--|:--| -| What renders | ALL list views as switcher tabs | The URL-defined slice, no saved-view tabs | One curated page referencing ONE view | +| What renders | ALL list views as switcher tabs | The URL-defined slice, no saved-view tabs | One curated page with its own list definition | | Anchored to | Saved views | **The URL itself** (`/:objectName/data?filter[...]`) | Page config | | User-created views | Allowed | "Save as view" exit only | Never | | Quick filters | Auto-derived (or view `userFilters` — `dropdown` only) | Auto-derived + removable URL chips | Only what the author enabled | @@ -709,8 +718,8 @@ navigation pointing at objects. Escalate only on explicit signals: author a view for it; a slice graduates to a named view only when it is curated and reused. Values support `{current_user_id}` / `{current_org_id}`. Never treat it as security: the surface shows what row-level permissions - allow. (Canonical rules: objectui `skills/objectui/guides/app-composition.md` - + `docs/adr/0055-parameterized-bare-data-surface.md`.) + allow. (Canonical rules: objectui ADR-0055, "parameterized bare data + surface".) - **Interface page** — persona split ("sales reps see…", customer portal, 给业务部门的简化界面); capability narrowing ("users must not change views", "only filter by X"); curation language (workspace / 工作台 / "Airtable @@ -725,21 +734,29 @@ for a one-off slice) is a permanently-maintained duplicate asset. > prefer a named view over a page; use a page only for composition a single > object view cannot express. Every target appears exactly once. -**The iron rule:** an interface page REFERENCES a view (`interfaceConfig.source` -+ `sourceView`) and adds presentation policy only (`userFilters`, -`appearance.allowedVisualizations`, `userActions`). It has NO columns/filter/sort -of its own — never restate what the view already defines. +**The iron rule (revised):** an interface page **IS the view definition**. It +binds an object (`interfaceConfig.source`) and carries its **own** `columns` / +`sort` / `filterBy` directly (Airtable parity — there is no "inherit from a +named view" concept), plus presentation policy (`userFilters`, +`appearance.allowedVisualizations`, `userActions`). The old +`sourceView` ("inherit from a named object view") is **deprecated** legacy: it +is still honored at runtime as a fallback when the page defines no `columns` of +its own, but new pages define `columns`/`sort`/`filterBy` on the page. + ```typescript import { definePage } from '@objectstack/spec/ui'; export const TaskWorkbenchPage = definePage({ name: 'task_workbench', + label: 'Task Workbench', type: 'list', object: 'task', interfaceConfig: { source: 'task', - sourceView: 'default', // inherit columns/filter/sort + columns: ['subject', 'status', 'due_date'], // the page IS the view definition + sort: [{ field: 'due_date', order: 'asc' }], + filterBy: [{ field: 'status', operator: 'not_equals', value: 'done' }], userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }, appearance: { allowedVisualizations: ['grid'] }, // locked userActions: { sort: true, search: true, filter: false }, @@ -791,12 +808,25 @@ Register under `defineStack({ pages: [...] })`. ### Page Types -| `type` | Purpose | -|:-----------------|:--------| -| `home` | App home / landing page | -| `record_detail` | Object record detail layout (overrides the default form) | -| `app_launcher` | Tile grid for switching between apps | -| `utility_bar` | Persistent bottom-of-screen utilities (notes, tasks, calls) | +`PageTypeSchema` has exactly **five** values — only types with a dedicated +renderer are authorizable (ADR-0049 enforce-or-remove): + +| `type` | Purpose | +|:----------|:--------| +| `record` | Component-based record layout with regions (overrides the default record detail) | +| `home` | App home / landing page | +| `app` | App-level page with navigation context | +| `utility` | Floating utility panel (e.g. notes, phone dialer) | +| `list` | Record list/grid interface page — configured via `interfaceConfig` (see the iron rule above) | + +Disambiguation: there is **no** `record_detail`, `app_launcher`, or +`utility_bar` type — a record layout is `type: 'record'`, an app-level page is +`type: 'app'`, a utility panel is `type: 'utility'`. Likewise +grid/kanban/calendar/gallery/timeline are NOT page types — they are +*visualizations* of a `list` page +(`interfaceConfig.appearance.allowedVisualizations`). Former roadmap-only types +(`dashboard`, `form`, `record_detail`, `record_review`, `overview`, `blank`) +were removed from the enum because they never shipped a renderer. ### Templates & Regions @@ -816,10 +846,10 @@ which contain components. | `element:button` | Button — `properties.label` + `variant`/`size` + optional `action` | | `record:highlights` | Salesforce highlights panel — strip of key fields | | `record:path` | Stage progress bar driven by a status field | -| `record:related` | Related-list (child records via lookup) | +| `record:related_list` | Related-list (child records via lookup) | | `nav:menu` | Quick-create / nav menu bound to current context | -| `widget:metric` | Single KPI widget (count/sum/avg) | -| `widget:chart` | Embedded chart | +| `object-metric` | Single KPI widget (count/sum/avg) | +| `object-chart` | Embedded chart | ### Example — Record Detail Page @@ -830,8 +860,8 @@ import { ConvertLeadAction } from '../actions/lead.actions'; export const LeadDetailPage = definePage({ name: 'lead_detail_page', label: 'Lead Detail', - type: 'record_detail', - objectName: 'lead', + type: 'record', + object: 'lead', template: 'three-column', regions: [ { @@ -876,7 +906,7 @@ export const LeadDetailPage = definePage({ > the page root for any non-record value. For relative-date placeholders > (`{today}`, `{30_days_ago}`, `{N__(ago|from_now)}` …) see the > [Date Macros](#date-macros--filter-placeholders) reference below — the -> full token list is published as `DATE_MACRO_TOKENS` in `@objectstack/spec`. +> full token list is published as `DATE_MACRO_TOKENS` in `@objectstack/spec/data`. > **Actions in header** — pass full `Action` objects into > `page:header.properties.actions`; do **not** create a sibling action node. @@ -901,7 +931,7 @@ value `kind:'jsx'` is a deprecated alias for `kind:'html'`. #### `kind:'html'` — constrained JSX, parsed (safe by construction) Tags are the **registered components** (bare names: ``, ``, ``, -``, ``, ``, …) **plus the safe native HTML +``, ``, ``, …) **plus the safe native HTML set** (`

`–`

`, `

`, ``, `