Skip to content

Commit cbe20ab

Browse files
docs(skills): guard nullable validation operands, teach hook body, list mailbox / package-registry, drop os generate agent (#31127)
<!-- claude-projects-attribution: {"github_login":"huangyiirene"} --> _Requested by **Irene** · [project thread](https://claude.ai/code/project/chan_01TJXtQ1tVD26ecLDZxFS67x?thread=cmsg_01TJXtQ1tVD26ecLDZxFS67xAX4MMULyw2ed8nbbZASMh9)_ Fixes #31113 Clause-②: no Metadata-protocol: none `dispatch-gates --tier` reads a MANDATORY model tier for `skills/**` (clause ① of the model-tiering ruling); it is waived by the standing order of the maintainer in this project (2026-11-03), and this card ran on the project's default model. The dispatching seat runs the contract review. ## 维护者速读 - 改了什么:`objectstack-data` 的 `rules/validation.md` 六处比较/算术谓词全部加 `!= null` 守卫,并写明未守卫的可空字段在发布时被拒;两个钩子示例(`validation.md`、`examples-objects.md`)改写为沙箱 `body`,`SKILL.md` 索引只把 `handler` 称为已弃用;`objectstack-platform` 的 `requires:` 表补 `mailbox`、`package-registry`;`operations.md` 的 `os generate` 行去掉已退役的 `agent`。 - 为什么改:这四处把发布门会拒绝、或已退役/已弃用的写法教给 AI(#31113):裸可空比较被 `validate-null-guards` 拒绝;`handler` 已弃用;两个 token 在 `PLATFORM_CAPABILITY_TOKENS` 里却不在表里;`agent` 在 `RETIRED_GENERATORS` 里。 - 风险与回滚:只改技能文字与 eval;五个文件全部在 token 上限内,无上限上调(增长全部用同文件删除支付,见下文);发 `@objectstack/skills` patch;回滚即 revert。 - 先看:`skills/objectstack-data/rules/validation.md` 的 External / Custom Validation 一节(席位裁定 A:`body` 无外呼 HTTP,外部 API 校验走 Connector recipe)。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期退出 0,`57 authored bundle file(s) within their ceilings`。 - 需要你做的:`skills/**` 属 Tier H(人合),需要 `os-zhuang` 或 `hotlong` 的一次 approval,之后席位落地。 ## What changes ### 1. Nullable operands in `rules/validation.md` are guarded Code site: `packages/lint/src/validate-null-guards.ts` (L29–L43: an ordering or arithmetic operator on a declared field with no `required: true` / `defaultValue` is rejected unless a `!= null` / `== null` / `isBlank()` test dominates it), wired into `validateStackExpressions` for `validations[].condition` / `.when` / `.then` / `.otherwise` (`packages/lint/src/validate-expressions.ts` L44), which `os build`, `os validate`, `os lint` and the runtime publish gate run. `skills/objectstack-formula/SKILL.md` L112–L122 teaches the same rule. The card named three sites; the file held six. The new sentence under § Expression Syntax states the rule, so the other three would have contradicted it on the same page; all six are guarded: | Line | Before | After | |:--|:--|:--| | L60 script | `record.due_date < today()` | `record.due_date != null && record.due_date < today()` | | L72 pattern | `record.amount < 0` | `record.amount != null && record.amount < 0` | | L78 pattern | `record.end_date < record.start_date` | `record.start_date != null && record.end_date != null && record.end_date < record.start_date` | | L159 cross_field | `record.end_date <= record.start_date` | same guards, `<=` | | L166 cross_field | `record.discount_amount > record.subtotal * 0.5` | `record.discount_amount != null && record.subtotal != null && …` | | L226 conditional `when` | `record.order_total > 10000` | `record.order_total != null && record.order_total > 10000` | Measured, not assumed: `findUnguardedNullableOperands` (the gate's decision procedure, run through `tsx` with each field nullable) returns 1 or 2 findings for every "before" predicate and 0 for every "after" predicate. § Expression Syntax gains two lines (the line before them is byte-identical): "An ordering or arithmetic operator on a field with no `required: true` / `defaultValue` is refused at publish unless a `!= null` test guards it." `<!-- os:check -->`: that gate (`packages/spec/scripts/check-skill-examples.ts`) is a `tsc` type-check, not the null-guard lint. A trial run marked all four candidate blocks: the § Script Validation block (L52) and the rewritten hook block (L250) type-check and keep their markers; the § Common Script Patterns block (`condition: …` lines) and the § Cross-Field block (`validations: [` with no import) fail with `TS2304: Cannot find name 'P'`, because they are fragments, so they stay unmarked. ### 2. `requires:` table names `mailbox` and `package-registry` Code sites: `PLATFORM_CAPABILITY_TOKENS` (`packages/spec/src/kernel/platform-capabilities.ts` L58 `package-registry`, L64 `mailbox`; provider rows L216, L218) and `CAPABILITY_PROVIDERS` (`packages/core/src/capability-providers.ts` L174 `package-registry` → `@objectstack/service-package` / `PackageServicePlugin`, L186 `mailbox` → `@objectstack/service-mailbox` / `MailboxServicePlugin`). ```diff -| `marketplace` | `@objectstack/service-package` | +| `mailbox` | `@objectstack/service-mailbox` — inbound mail | +| `marketplace` `package-registry` | `@objectstack/service-package` | ``` `package-registry` shares the `marketplace` row because both keys resolve to the same package and plugin today (the comment at `capability-providers.ts` L164–L173). No count in prose (#27522). ### 3. `os generate <kind>` drops the retired `agent` Code site: `RETIRED_GENERATORS` in `packages/cli/src/commands/generate.ts` (L974, `agent` at L980: "agents are platform-internal (ADR-0063 §2)"; its refusal points at `os g skill`). ```diff -| `os generate <kind>` | Scaffold an object / view / flow / agent from a template | +| `os generate <kind>` | Scaffold an object / view / flow / skill from a template | ``` ### 4. Hooks are taught as `body` Code sites: `HookSchema.handler` (`packages/spec/src/data/hook.zod.ts` L485: "DEPRECATED, prefer `body`") and `HookSchema.body` (L487–L498: preferred; when both are present the runtime uses `body`). The body's capabilities are `HookBodyCapability` (`packages/spec/src/data/hook-body.zod.ts` L48–L54); there is no `http.fetch` (L38: "outbound calls go through Connector recipes"). The `ctx.api.object(name).findOne({ where })` shape is the one the body runner exposes (`packages/runtime/src/sandbox/body-runner.ts` L1119, gated as a READ by `api.read` in `quickjs-runner.ts` L705). - `references/examples-objects.md` § Lifecycle Hooks: the example is a `body` (`language: 'js'`, `source` = the function body). The prose after it says `body` is the metadata-native form and names the inline `handler` only as deprecated. - `rules/validation.md` § External / Custom Validation: the seat ruled option A. The example is a `body` that reads `blocked_tax_id` through `ctx.api` with `capabilities: ['api.read']` and throws. The lead now says "hitting another object or running arbitrary code", and adds that a `body` has no outbound HTTP, so an external-API check goes through a Connector recipe (home: `references/data-hooks.md` § Capabilities, L447). - `SKILL.md` L46 index: "the sandboxed `body` (ctx + capability contract; the inline `handler` is deprecated)". ## Token readings (`node scripts/check-skills-token-ratchet.mjs`, exit 0) | File | `main` | This branch | Ceiling | |:--|--:|--:|--:| | `skills/objectstack-platform/SKILL.md` | 6011 | 6007 | 6011 | | `skills/objectstack-data/SKILL.md` | 6071 | 6077 | 6078 (+6 inside existing headroom, no deletion owed) | | `skills/objectstack-data/references/examples-objects.md` | 1728 | 1710 | 1728 | | `skills/objectstack-data/rules/validation.md` | 2746 | 2748 | 2756 | | `skills/objectstack-platform/references/operations.md` | 3285 | 3285 | 3287 | No ceiling is raised, so the ruling path was not used. No line was re-wrapped. Every deletion, with the facts it held and where each survives: - **`objectstack-platform/SKILL.md` § The Template:** "The memory driver and the Hono server are NOT in the file — the CLI auto-registers both at boot". Facts: no driver and no server in the scaffolded config; the CLI registers both. Homes, same file: § Wiring a driver — you usually do not ("Under `os dev` / `os serve` / `os start` the CLI resolves the driver itself … Do not put a driver in your config's `plugins:` array") and § Runtime Boot Sequence step 4 (`DriverPlugin`, `HonoServerPlugin` auto-registered). The line before it loses its trailing "The". - **`objectstack-data/references/examples-objects.md`:** the `ctx.input.created_at = new Date().toISOString();` line. It wrote a column the platform stamps on insert itself (objectql's audit hook, `packages/objectql/src/plugin.ts` L1975), so it taught a redundant write. The old prose's facts (inline in-process `handler`; preferred `body` of `language` / `source` / `capabilities`; isolated VM; the shape AI/Studio hooks and build artifacts carry) all remain in the new prose or the example. - **`objectstack-data/rules/validation.md`:** - "Comprehensive guide for implementing validation rules in ObjectStack." Home: the H1 and the table of contents. - The `No async / custom type` bullet's tail ("external checks and arbitrary validation code belong in a `beforeInsert` / `beforeUpdate` lifecycle hook (see data-hooks.md)"). It now links § External / Custom Validation, which holds the hook rule and the `data-hooks.md` link. The "external checks" part also contradicted ruling A. - Common Script Patterns `// Conditional required field` (`record.type == 'enterprise' && isBlank(record.account_manager)`). Homes: the same block's "Require field when another field has value" pattern, and § Conditional Validation's `enterprise_requires_manager`, the same predicate. - "Lower numbers execute **first**." Home: the Priority block's comments "(run first)" / "(run last)". - Incorrect vs Correct "Script Logic Inverted" / "Correct — Script Logic". Homes: "⚠️ CRITICAL: … validation **fails** when it evaluates to `true`" (§ Expression Syntax) and `// ❌ Fails when this is TRUE` in § Script Validation; the correct amount check is the "Prevent negative values" pattern. The pair also held two unguarded `amount` comparisons. The "Validation Fires Too Often" pair stays, so § Incorrect vs Correct and its contents link remain. ## Eval cases - `objectstack-data/evals/objects-fields-relationships.json` case 3 (corrected): `must_contain` gains `!= null`; its `expected_output` already asked for explicit guards. (Site 1.) - `objectstack-data/evals/hooks-security-seeds-datasources.json` case 10 (new): block an `account` whose `tax_id` is in `blocked_tax_id`, and ask whether the hook can call a tax-authority API. Must contain `beforeInsert`, `beforeUpdate`, `language: 'js'`, `'api.read'`, `ctx.api.object(`; must not contain `handler:`, `HookContext`, `'http.fetch'`. (Site 4.) - `objectstack-platform/evals/config-plugins-ops.json` case 18 (new): inbound mail into mailboxes. Must contain `requires`, `'mailbox'`, `@objectstack/service-mailbox`; must not contain `MailboxServicePlugin(`. (Site 2.) - `objectstack-platform/evals/config-plugins-ops.json` case 19 (new): scaffold an object, a flow and an AI agent with the CLI. Must contain `os generate object`, `os generate flow`, `os generate skill`; must not contain `defineAgent(`. (Site 3.) ## Deviations (all three accepted by the seat before writing) - **Generator roster.** The card's "live roster" (`object fields view list action flow dashboard app skill picklist`) is wrong: `fields` and `list` are keys inside templates. `GENERATORS` in `generate.ts` keys `object view action flow dashboard app skill picklist permission dataset`. The fix is unchanged (drop `agent`); `skill` takes its place, since that is what the refusal points at. - **`os:check`.** Only the two blocks the gate accepts are marked, as § 1 says. The card's acceptance asked for all three validation examples. - **Site 4 in `validation.md`** follows the seat's ruling A (a `body` cannot call an external API), not a literal port of the `handler` example. ## Other sites found (not changed: outside this card's file surface) - `skills/objectstack-data/references/data-hooks.md` L219: hook ``condition: P`record.annual_revenue > 1000000` `` is an unguarded nullable operand. A hook `condition` is a covered surface (`validate-null-guards.ts` wiring table). - `skills/objectstack-data/references/data-hooks.md`: teaches the inline `handler` form first: 3 sites above § Sandboxed Hook Bodies (L309: L97, L161, L170) and 4 below (L739, L817, L845, L890), plus the comparison table at L319, so the reference itself still teaches the deprecated door first. - `skills/objectstack-formula/SKILL.md` L268: ``P`record.amount > 10000 && …` `` is an unguarded comparison in an `os:check` block. It has no object, so it is not refused by itself, but it is the shape that gets copied. ## Verification <details><summary>Local gates on 12dd0d0: 47 derived, 47 exit 0, 0 NOT MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 47 commands for the 9 changed paths; each was run with its exit code captured before any pipe. - `pnpm check:dual-build-cjs-loads` exited 3 (NOT MEASURED: no `dist/`) on the first run. After `pnpm build` (exit 0) it exited 0. The other 46 exited 0 on the first run, including `check:skill-identifier-liveness`, `check:skill-top-level-keys`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:skill-docs`, `check:skill-refs`, `check:doc-authoring`, `check:corpus-claim-drift` and the token ratchet. </details> - `pnpm check:skill-identifier-liveness`, `pnpm check:skill-top-level-keys`, `pnpm check:skill-compatibility`, `pnpm --filter @objectstack/spec check:skill-refs`, `node scripts/check-skills-token-ratchet.mjs`: exit 0 each. - `pnpm --filter @objectstack/spec check:skill-examples` (after building `@objectstack/spec`, `@objectstack/client` and `@objectstack/client-react`): exit 0, `285 prose examples type-check`. - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0 before the push. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No schema touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset (`'@objectstack/skills': patch`), and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched; nothing to regenerate. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 section is Chinese, per the lane template.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8VVAkfHzwVhtRa85nYA4e <!-- signed: domain:skills#3 · huangyiirene · session_01J8VVAj7CvjkGXvgXzd6RZ7 --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent e062399 commit cbe20ab

9 files changed

Lines changed: 92 additions & 62 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): validation examples guard nullable fields, hooks teach `body`, the `requires:` table names `mailbox` and `package-registry`, `os generate` drops `agent`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`objectstack-data` `rules/validation.md` now guards every ordering or arithmetic operand in its examples with `!= null` (the due-date, date-range, amount, discount and order-total predicates) and states the rule once: an unguarded `<` or `+` on a field with no `required: true` / `defaultValue` is refused at publish. Its external-validation hook and the `examples-objects.md` lifecycle hook are written as a sandboxed `body`, and the `SKILL.md` index names the inline `handler` only as the deprecated form; the validation section says a `body` has no outbound HTTP, so an external-API check goes through a Connector recipe. `objectstack-platform` lists `mailbox` (`@objectstack/service-mailbox`) and `package-registry` (`@objectstack/service-package`) in the `requires:` table, and `references/operations.md` no longer offers the retired `os generate agent`. The platform behaviour already shipped; this release only teaches it.

‎skills/objectstack-data/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Detailed rules, with incorrect/correct examples:
4343
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
4444
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
4545
- **[Data Lifecycle & Retention](./rules/lifecycle.md)** — `lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below); plus the **save notice** a hook hands the saver (`ctx.notice`)
46-
- **[Lifecycle Hooks](./references/data-hooks.md)** — the lifecycle events, `handler` vs sandboxed `body` (ctx + capability contract), registration, canonical patterns
46+
- **[Lifecycle Hooks](./references/data-hooks.md)** — the lifecycle events, the sandboxed `body` (ctx + capability contract; the inline `handler` is deprecated), registration, canonical patterns
4747
- **[Datasources & Federation](./rules/datasources.md)** — `defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
4848
- **[Security & Access Control](./rules/security.md)** — permission sets, assignment rows, RLS policies, `secret` / `requiredPermissions`, `tenancy`, platform-global posture
4949

‎skills/objectstack-data/evals/hooks-security-seeds-datasources.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@
9090
"must_contain": ["INVALID_FIELD", "is_not_null"],
9191
"must_not_contain": ["starts_with"]
9292
}
93+
},
94+
{
95+
"id": 10,
96+
"prompt": "An `account` whose `tax_id` appears in our `blocked_tax_id` object must not save, on create or edit. Our tax authority also offers a lookup API; can the same hook call it?",
97+
"expected_output": "A `beforeInsert` / `beforeUpdate` hook on `account` with a sandboxed `body: { language: 'js', source, capabilities: ['api.read'] }`: the source returns when the write carries no `tax_id`, reads `ctx.api.object('blocked_tax_id').findOne({ where: { tax_id: ctx.input.tax_id } })` and throws when it finds a row. Not a validation rule (there is no `custom` / `async` type) and not the deprecated inline `handler`. The tax-authority API is not called from the hook: a `body` has no outbound HTTP, so that check goes through a Connector recipe.",
98+
"files": [],
99+
"assertions": {
100+
"must_contain": ["beforeInsert", "beforeUpdate", "language: 'js'", "'api.read'", "ctx.api.object("],
101+
"must_not_contain": ["handler:", "HookContext", "'http.fetch'"]
102+
}
93103
}
94104
]
95105
}

‎skills/objectstack-data/evals/objects-fields-relationships.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
"unique: 'organization'",
5454
"state_machine",
5555
"transitions",
56-
"record."
56+
"record.",
57+
"!= null"
5758
],
5859
"must_not_contain": [
5960
"type: 'unique'",

‎skills/objectstack-data/references/examples-objects.md‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,25 +113,23 @@ Declared `indexes` are a separate decision — see
113113

114114
<!-- os:check -->
115115
```typescript
116-
import { defineHook, HookContext } from '@objectstack/spec/data';
116+
import { defineHook } from '@objectstack/spec/data';
117117

118118
export default defineHook({
119119
name: 'crm_account_defaults',
120120
object: 'account',
121121
events: ['beforeInsert'],
122-
handler: async (ctx: HookContext) => {
123-
if (!ctx.input.industry) {
124-
ctx.input.industry = 'Other';
125-
}
126-
ctx.input.created_at = new Date().toISOString();
122+
body: {
123+
language: 'js',
124+
source: `if (!ctx.input.industry) ctx.input.industry = 'Other';`,
127125
},
128126
});
129127
```
130128

131-
The `handler` above is the inline (in-process) form. The **preferred**,
132-
metadata-native form is a sandboxed `body` — `{ language: 'js', source, capabilities }`
133-
run in an isolated VM, the shape that AI/Studio-authored hooks and every build
134-
artifact carry. See [references/data-hooks.md](./data-hooks.md) for all 8
129+
`body` is the metadata-native form: `source` is a function body run against
130+
`ctx` in an isolated VM (`capabilities` gates `ctx.api`, `ctx.log`, …), the
131+
shape AI/Studio-authored hooks and every build artifact carry. The inline
132+
`handler` function is deprecated. See [references/data-hooks.md](./data-hooks.md) for all 8
135133
lifecycle events, both registration forms, the **sandboxed `body` ctx + capability
136134
contract**, and the canonical patterns.
137135

‎skills/objectstack-data/rules/validation.md‎

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
- [External / Custom Validation → Lifecycle Hooks](#external--custom-validation--lifecycle-hooks) · [Validation Properties](#validation-properties)
77
- [Incorrect vs Correct](#incorrect-vs-correct)
88

9-
Comprehensive guide for implementing validation rules in ObjectStack.
10-
119
## Available Rule Types
1210

1311
The **complete** set of `type` discriminators accepted by `ValidationRuleSchema`:
@@ -25,9 +23,7 @@ There is no other type. In particular:
2523

2624
- **No `unique` type** (removed from the spec) — enforce uniqueness
2725
with a **unique index** ([see below](#uniqueness--use-unique-indexes)).
28-
- **No `async` / `custom` type** — external checks and arbitrary validation
29-
code belong in a `beforeInsert` / `beforeUpdate` **lifecycle hook**
30-
(see [references/data-hooks.md](../references/data-hooks.md)).
26+
- **No `async` / `custom` type** — see [External / Custom Validation](#external--custom-validation--lifecycle-hooks).
3127

3228
## Expression Syntax
3329

@@ -36,6 +32,8 @@ There is no other type. In particular:
3632
as CEL. Record fields are addressed as `record.<field>`; on update the prior
3733
row is available as `previous.<field>`. CEL operator and null-handling rules:
3834
see **objectstack-formula**.
35+
An ordering or arithmetic operator on a field with no `required: true` /
36+
`defaultValue` is refused at publish unless a `!= null` test guards it.
3937

4038
**⚠️ CRITICAL:** For `script` **and** `cross_field`, the predicate expresses
4139
the **failure** condition — validation **fails** when it evaluates to `true`.
@@ -51,14 +49,15 @@ from a transition onward is not this tool: use `requiredWhen` or a field bound
5149
stored row (see **Choose by intent — invariant or transition gate** in
5250
`SKILL.md`).
5351

52+
<!-- os:check -->
5453
```typescript
5554
import { P } from '@objectstack/spec';
5655

5756
const validations = [
5857
{
5958
name: 'prevent_past_dates',
6059
type: 'script',
61-
condition: P`record.due_date < today()`, // ❌ Fails when this is TRUE
60+
condition: P`record.due_date != null && record.due_date < today()`, // ❌ Fails when this is TRUE
6261
message: 'Due date cannot be in the past',
6362
severity: 'error',
6463
events: ['insert', 'update'],
@@ -70,16 +69,13 @@ const validations = [
7069

7170
```typescript
7271
// Prevent negative values
73-
condition: P`record.amount < 0`
72+
condition: P`record.amount != null && record.amount < 0`
7473

7574
// Require field when another field has value
7675
condition: P`record.status == 'approved' && isBlank(record.approver_id)`
7776

7877
// Date range validation
79-
condition: P`record.end_date < record.start_date`
80-
81-
// Conditional required field
82-
condition: P`record.type == 'enterprise' && isBlank(record.account_manager)`
78+
condition: P`record.start_date != null && record.end_date != null && record.end_date < record.start_date`
8379
```
8480

8581
> On **insert**, an optional field omitted from the payload reads as `null`
@@ -160,14 +156,14 @@ validations: [
160156
{
161157
name: 'date_range',
162158
type: 'cross_field',
163-
condition: P`record.end_date <= record.start_date`, // ❌ TRUE = invalid
159+
condition: P`record.start_date != null && record.end_date != null && record.end_date <= record.start_date`, // ❌ TRUE = invalid
164160
message: 'End date must be after start date',
165161
fields: ['start_date', 'end_date'],
166162
},
167163
{
168164
name: 'discount_limit',
169165
type: 'cross_field',
170-
condition: P`record.discount_amount > record.subtotal * 0.5`,
166+
condition: P`record.discount_amount != null && record.subtotal != null && record.discount_amount > record.subtotal * 0.5`,
171167
message: 'Discount cannot exceed 50% of subtotal',
172168
fields: ['discount_amount', 'subtotal'],
173169
},
@@ -227,7 +223,7 @@ With an `otherwise` branch:
227223
{
228224
name: 'payment_validation',
229225
type: 'conditional',
230-
when: P`record.order_total > 10000`,
226+
when: P`record.order_total != null && record.order_total > 10000`,
231227
message: 'Order validation',
232228
then: {
233229
name: 'manager_approval_required',
@@ -246,21 +242,27 @@ With an `otherwise` branch:
246242

247243
## External / Custom Validation → Lifecycle Hooks
248244

249-
Calling an external API, hitting another object, or running arbitrary code is
250-
**not** a validation type. Implement it as a `beforeInsert` / `beforeUpdate`
251-
lifecycle hook and throw on failure:
245+
Hitting another object or running arbitrary code is **not** a validation type.
246+
Implement it as a `beforeInsert` / `beforeUpdate` lifecycle hook whose `body`
247+
throws on failure. A `body` has no outbound HTTP: an external-API check goes
248+
through a Connector recipe ([data-hooks.md](../references/data-hooks.md#capabilities--the-complete-list)).
252249

250+
<!-- os:check -->
253251
```typescript
254-
import { defineHook, HookContext } from '@objectstack/spec/data';
252+
import { defineHook } from '@objectstack/spec/data';
255253

256254
const taxIdCheck = defineHook({
257-
name: 'crm_tax_id_external_check',
255+
name: 'crm_tax_id_blocklist_check',
258256
object: 'account',
259257
events: ['beforeInsert', 'beforeUpdate'],
260-
handler: async (ctx: HookContext) => {
261-
if (ctx.input.tax_id && !(await verifyTaxId(ctx.input.tax_id))) {
262-
throw new Error('Invalid tax ID');
263-
}
258+
body: {
259+
language: 'js',
260+
source: `
261+
if (!ctx.input.tax_id) return;
262+
const hit = await ctx.api.object('blocked_tax_id').findOne({ where: { tax_id: ctx.input.tax_id } });
263+
if (hit) throw new Error('Tax ID is blocked');
264+
`,
265+
capabilities: ['api.read'],
264266
},
265267
});
266268
```
@@ -301,30 +303,8 @@ priority: 100 // Application validations (default)
301303
priority: 1000 // User validations (run last)
302304
```
303305

304-
Lower numbers execute **first**.
305-
306306
## Incorrect vs Correct
307307

308-
### ❌ Incorrect — Script Logic Inverted
309-
310-
```typescript
311-
{
312-
type: 'script',
313-
condition: P`record.amount > 0`, // ❌ Fails when amount > 0 (inverted!)
314-
message: 'Amount must be positive',
315-
}
316-
```
317-
318-
### ✅ Correct — Script Logic
319-
320-
```typescript
321-
{
322-
type: 'script',
323-
condition: P`record.amount <= 0`, // ✅ Fails when amount <= 0
324-
message: 'Amount must be positive',
325-
}
326-
```
327-
328308
### ❌ Incorrect — Validation Fires Too Often
329309

330310
```typescript

‎skills/objectstack-platform/SKILL.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,7 @@ what the platform owns.
165165
generic connector executors in `plugins:`; and the eight generator barrels
166166
(`objects`, `views`, `actions`, `flows`, `dashboards`, `apps`, `skills`, `picklists`),
167167
each `src/*/index.ts` handed to the stack key of its name through
168-
`exportsOf` — the scaffolded `AGENTS.md` says how to add to them. The
169-
memory driver and the Hono server are NOT in the file — the CLI
170-
auto-registers both at boot
168+
`exportsOf` — the scaffolded `AGENTS.md` says how to add to them.
171169

172170
The five retired remote templates (`todo`, `compliance`, `content`,
173171
`contracts`, `procurement`) are refused by name.
@@ -305,7 +303,8 @@ token through the `CAPABILITY_PROVIDERS` registry in
305303
|:--|:--|
306304
| `automation` | `@objectstack/service-automation` — flows, and any declarative `connectors:` entry |
307305
| `analytics` `cache` `storage` `queue` `job` `messaging` `realtime` `settings` `sms` `push` | `@objectstack/service-` + the token ([the `push` block](./references/operations.md#mobile-push)) |
308-
| `marketplace` | `@objectstack/service-package` |
306+
| `mailbox` | `@objectstack/service-mailbox` — inbound mail |
307+
| `marketplace` `package-registry` | `@objectstack/service-package` |
309308
| `audit` `email` `sharing` `approvals` `webhooks` | `@objectstack/plugin-` + the token |
310309
| `pinyin-search` | `@objectstack/plugin-pinyin-search` |
311310
| `mcp` | `@objectstack/mcp` |

‎skills/objectstack-platform/evals/config-plugins-ops.json‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,38 @@
301301
"os push"
302302
]
303303
}
304+
},
305+
{
306+
"id": 18,
307+
"prompt": "Our `os serve` app must receive customers' inbound email into mailboxes and link each message to its records. What turns that on?",
308+
"expected_output": "`requires: ['mailbox']` on the stack root: the token mounts `@objectstack/service-mailbox` (inbound mail). No `MailboxServicePlugin` instance in `plugins:` and no hand-built mail objects; `defineStack` rejects an unknown token at authoring time.",
309+
"files": [],
310+
"assertions": {
311+
"must_contain": [
312+
"requires",
313+
"'mailbox'",
314+
"@objectstack/service-mailbox"
315+
],
316+
"must_not_contain": [
317+
"MailboxServicePlugin("
318+
]
319+
}
320+
},
321+
{
322+
"id": 19,
323+
"prompt": "Scaffold, with the CLI, an object, a flow and an AI agent for our support app.",
324+
"expected_output": "`os generate object <name>` and `os generate flow <name>` scaffold the first two. There is no agent generator: `os generate agent` is retired (agents are platform-internal, ADR-0063 §2), so the AI extension is a skill, `os generate skill <name>`. No hand-written `defineAgent` file.",
325+
"files": [],
326+
"assertions": {
327+
"must_contain": [
328+
"os generate object",
329+
"os generate flow",
330+
"os generate skill"
331+
],
332+
"must_not_contain": [
333+
"defineAgent("
334+
]
335+
}
304336
}
305337
]
306338
}

‎skills/objectstack-platform/references/operations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
| `os compile` | Compile to portable JSON artifact (for runtime hydration) |
2121
| `os serve` | Serve a compiled stack in production mode |
2222
| `os start` | Quick-start a server: auto-compiles `objectstack.config.ts` when no artifact is present, and falls back to an empty kernel with the Console + marketplace when there is no config at all. It does **not** validate env or apply migrations — run `os validate` / `os migrate apply` yourself |
23-
| `os generate <kind>` | Scaffold an object / view / flow / agent from a template |
23+
| `os generate <kind>` | Scaffold an object / view / flow / skill from a template |
2424

2525
## Verify your work
2626

0 commit comments

Comments
 (0)