From ec63437b314f7176f9745ba06e501ad49a81b6b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:50:32 +0000 Subject: [PATCH 1/3] docs(skills): split the four over-500-line SKILL.md into routing + references, zero deletion The four published SKILL.md files over 500 lines (platform 1223, automation 961, data 854, upgrade 600) now keep frontmatter, when-to-use, rules, decision tables and pointers; every moved paragraph and code block lands verbatim under that skill's references/ with a one-line pointer left where it was. The seven reference files over 300 lines gain a 5-line table of contents anchored to their existing headings. objectstack-data's description shrinks to 689 characters with the same trigger phrases and exclusion sentence. The token ratchet re-locks the four entry rows at their landed counts, pins the nine new reference files at theirs, and raises the four TOC'd rows that carried no headroom by exactly the added tokens under the maintainer's ruling on decision batch #213 item 1. skills/README.md and the skills-reference page are regenerated from the trimmed frontmatter. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- content/docs/ai/skills-reference.mdx | 6 +- scripts/check-skills-token-ratchet.mjs | 97 ++- skills/README.md | 2 +- skills/objectstack-automation/SKILL.md | 519 +----------- .../references/examples-flows.md | 136 ++++ .../state-machines-and-approvals.md | 383 +++++++++ skills/objectstack-data/SKILL.md | 419 +--------- .../objectstack-data/references/data-hooks.md | 6 + .../references/examples-objects.md | 197 +++++ .../objectstack-data/references/lint-rules.md | 53 ++ skills/objectstack-data/references/seeds.md | 142 ++++ skills/objectstack-data/rules/field-types.md | 6 + .../objectstack-data/rules/relationships.md | 6 + skills/objectstack-data/rules/validation.md | 6 + skills/objectstack-platform/SKILL.md | 758 +----------------- .../references/bootstrap.md | 190 +++++ .../references/operations.md | 202 +++++ .../references/plugin-development.md | 359 +++++++++ skills/objectstack-ui/rules/dashboards.md | 6 + skills/objectstack-ui/rules/list-views.md | 6 + skills/objectstack-ui/rules/pages.md | 6 + skills/objectstack-upgrade/SKILL.md | 118 +-- .../references/examples-upgrade.md | 119 +++ 23 files changed, 1947 insertions(+), 1795 deletions(-) create mode 100644 skills/objectstack-automation/references/examples-flows.md create mode 100644 skills/objectstack-automation/references/state-machines-and-approvals.md create mode 100644 skills/objectstack-data/references/examples-objects.md create mode 100644 skills/objectstack-data/references/lint-rules.md create mode 100644 skills/objectstack-data/references/seeds.md create mode 100644 skills/objectstack-platform/references/bootstrap.md create mode 100644 skills/objectstack-platform/references/operations.md create mode 100644 skills/objectstack-platform/references/plugin-development.md create mode 100644 skills/objectstack-upgrade/references/examples-upgrade.md diff --git a/content/docs/ai/skills-reference.mdx b/content/docs/ai/skills-reference.mdx index 735761daa1e..ea7c5dc1a62 100644 --- a/content/docs/ai/skills-reference.mdx +++ b/content/docs/ai/skills-reference.mdx @@ -46,7 +46,7 @@ ObjectStack ships **10 skills** — one per authoring domain, plus process skill | # | Skill | Domain | Path | What it covers | | :--- | :--- | :--- | :--- | :--- | | 1 | [Platform](#platform) | `platform` | `skills/objectstack-platform/` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, scaffolding), declaring platform capabilities (`requires:` — which service plugins boot), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). | -| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data `lifecycle` retention/TTL/rotation, metadata `protection` locks, and external / federated datasources (`defineDatasource`) — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. | +| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, relationships, validations, indexes, hooks, security, seeds and datasources. | | 3 | [Query](#query) | `query` | `skills/objectstack-query/` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, relation expansion, and full-text search. | | 4 | [UI](#ui) | `ui` | `skills/objectstack-ui/` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). | | 5 | [Automation](#automation) | `automation` | `skills/objectstack-automation/` | Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the `jobs` (`defineJob`) / `webhooks` (`defineWebhook`) stack collections. | @@ -76,11 +76,11 @@ Do not use for data schema design (see objectstack-data) or query patterns (see **Domain** `data` · **Path** `skills/objectstack-data/` -Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data `lifecycle` retention/TTL/rotation, metadata `protection` locks, and external / federated datasources (`defineDatasource`) — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. +Design ObjectStack data schemas — objects, fields, relationships, validations, indexes, hooks, security, seeds and datasources. Use when the user is creating or modifying `*.object.ts` files or `src/data/*.ts` seed modules, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, pointing an object at an existing external database, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that belong on fields. -Do not use for querying data (see objectstack-query) or for plugin / kernel hooks (see objectstack-platform). CEL expressions in formulas / validations / sharing rules / dynamic seed values: load objectstack-formula alongside. +Do not use for querying data (see objectstack-query) or for plugin / kernel hooks (see objectstack-platform). CEL expressions: load objectstack-formula alongside. **Tags:** `object`, `field`, `validation`, `index`, `relationship`, `hook`, `schema`, `permission`, `rls`, `security`, `seed`, `fixture` diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index fb4c4431240..11af8031ea7 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -306,7 +306,14 @@ export const CEILINGS = new Map([ // trigger user to narrow it, so one with no organization predicate reads and // writes across every tenant. +150 tokens, of which the row's 25 tokens of // headroom absorb 25; the CEILING moves 125. - ['skills/objectstack-automation/SKILL.md', 12768], + // 12768 -> 5762: RE-LOCK at the landed count after the #19715 split (decision + // batch #213 item 1, maintainer 「1 2 4 同意」): the Flow Example, the whole State + // Machines & Approvals block and the Time-relative triggers section moved + // VERBATIM into `references/examples-flows.md` and + // `references/state-machines-and-approvals.md` (rows below), one pointer line + // each left in place. The moved text is priced in its own rows, so the split + // banks the entry's shrink instead of leaving 7,006 tokens of headroom behind. + ['skills/objectstack-automation/SKILL.md', 5762], // 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above). // The invariant-vs-transition-gate choice, placed in the field-conditional-rule // bullet list where the tool is actually picked. Both wrong picks are silent: @@ -317,7 +324,14 @@ export const CEILINGS = new Map([ // split (#14296 item 1 = A, condition (b)). The raise recorded above is spent // and its headroom leaves with it; the moved text is priced in its own row // below, so the package total is unchanged by the re-lock itself. - ['skills/objectstack-data/SKILL.md', 10009], + // 10009 -> 6128: RE-LOCK at the landed count after the #19715 split (decision + // batch #213 item 1, maintainer 「1 2 4 同意」): Field Groups, the Quick-Start + // Template, Lifecycle Hooks, Metadata Protection, the whole Seed Data & + // Fixtures block and Linting & Generation Quality moved VERBATIM into + // `references/examples-objects.md`, `references/seeds.md` and + // `references/lint-rules.md` (rows below); the frontmatter `description` + // shrank to 689 characters (card item 3). One pointer line per moved block. + ['skills/objectstack-data/SKILL.md', 6128], ['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055) ['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349) // 12705 -> 12984 (2026-08-31 app-repo-principles raise, see the block above). @@ -328,7 +342,14 @@ export const CEILINGS = new Map([ // fix; no defensive coding, no shape tolerance, no hand-written predicate // re-implementing a platform rule, and no landing of the half that fits. // Nothing in this file said any of it. +280 tokens, 1 absorbed, ceiling +279. - ['skills/objectstack-platform/SKILL.md', 12984], + // 12984 -> 5833: RE-LOCK at the landed count after the #19715 split (decision + // batch #213 item 1, maintainer 「1 2 4 同意」): Part 2 (plugin development) and + // Part 3 (the operations tail) moved VERBATIM into + // `references/plugin-development.md` and `references/operations.md`, and six + // Part 1 example / composition sections into `references/bootstrap.md` (rows + // below). One pointer line per moved block; the entry keeps its rules and + // decision tables at 487 lines. + ['skills/objectstack-platform/SKILL.md', 5833], // `skills/objectstack-pm-dispatch/SKILL.md` (9708) and its // `rules/dev-template.md` (1838) left this table on 2026-09-10 with the files: // the maintainer deleted the published PM skill whole (verbatim: @@ -389,7 +410,11 @@ export const CEILINGS = new Map([ // Independently reproducible without running this script: // ceil($(wc -c < skills/objectstack-ui/SKILL.md) / 4) == ceil(15424 / 4) ['skills/objectstack-ui/SKILL.md', 3856], - ['skills/objectstack-upgrade/SKILL.md', 8333], // -2 (was 8335) + // 8333 -> 6193: RE-LOCK at the landed count after the #19715 split (decision + // batch #213 item 1, maintainer 「1 2 4 同意」): the worked R1, the report + // template and the v17-canonical shapes block moved VERBATIM into + // `references/examples-upgrade.md` (row below), one pointer line each. + ['skills/objectstack-upgrade/SKILL.md', 6193], // ── the #12392 extension: the rest of the AUTHORED bundle ──────────────── // Every row below is an INITIAL measurement, taken on @@ -407,17 +432,30 @@ export const CEILINGS = new Map([ // SKILL.md ceilings, which is the single fact that made #12392 a defect // rather than an observation. ['skills/objectstack-data/evals/README.md', 143], - ['skills/objectstack-data/references/data-hooks.md', 12611], + // 12611 -> 10066: RE-LOCK at the landed count (#19715, card item 4). The file + // gained a 5-line table of contents (+182 tokens, anchors to its own + // headings); the row carried 2,727 tokens of headroom left by an earlier + // shrink that nobody re-locked, so the TOC needed no raise. Pinned AT the + // measurement — zero headroom, the ratchet's steady state. + ['skills/objectstack-data/references/data-hooks.md', 10066], ['skills/objectstack-data/rules/datasources.md', 911], // -552 (was 3584): re-locked at the landed count (#16374) after PR #16187 // (the two `tree` rows) and PR #16369 (the ASCII decision tree deleted) // both merged. Lowering to the landed count is the ratchet's ordinary // direction — a seat's re-lock, not a maintainer ruling. - ['skills/objectstack-data/rules/field-types.md', 3032], + // 3032 -> 3158 (#19715, card item 4): a 5-line table of contents at the top, + // anchors to the file's existing headings, nothing else changed. +126 tokens, + // zero headroom to absorb any of it, ceiling +126. Maintainer ruling + // 2026-09-22 on decision batch #213 (item 1 carries the TOCs: 「四个超长 SKILL.md 按 500 行切,整段示例与 platform 的运维尾段下沉 references/,零删内容;同卡顺手:data 的 description 压到约 700 字符、长参考文件加目录」), + // verbatim and untranslated: 「1 2 4 同意」. + ['skills/objectstack-data/rules/field-types.md', 3158], ['skills/objectstack-data/rules/indexing.md', 3241], ['skills/objectstack-data/rules/lifecycle.md', 1590], ['skills/objectstack-data/rules/naming.md', 773], - ['skills/objectstack-data/rules/relationships.md', 3778], + // 3778 -> 3676: RE-LOCK at the landed count (#19715, card item 4). A 5-line + // table of contents (+154 tokens) landed inside the row's 256 tokens of + // headroom, so no raise; pinned AT the measurement — zero headroom. + ['skills/objectstack-data/rules/relationships.md', 3676], // NEW FILE (#14296 item 1 = A, condition (b)): the entry's Security & Access // Control block moved here whole. Pinned at its landed count — no headroom, // because a split that arrives with budget is a raise wearing a new path. @@ -437,7 +475,10 @@ export const CEILINGS = new Map([ // score, a duplicate guess — warns and never errors, and an override flag // added to soften a block is evidence the block should have been a warning. // +85 tokens, no headroom to absorb any of it, ceiling +85. - ['skills/objectstack-data/rules/validation.md', 3109], + // 3109 -> 2756: RE-LOCK at the landed count (#19715, card item 4). A 5-line + // table of contents (+155 tokens) landed inside the row's 508 tokens of + // headroom, so no raise; pinned AT the measurement — zero headroom. + ['skills/objectstack-data/rules/validation.md', 2756], // objectstack-platform ['skills/objectstack-platform/references/plugin-hooks.md', 2628], @@ -467,10 +508,25 @@ export const CEILINGS = new Map([ // added nothing but five titles and five pointers, // so the package nets -58 (33,957 -> 33,899). ['skills/objectstack-ui/rules/actions.md', 2108], - ['skills/objectstack-ui/rules/dashboards.md', 6090], - ['skills/objectstack-ui/rules/list-views.md', 3011], + // 6090 -> 6252 (#19715, card item 4): a 5-line table of contents at the top, + // anchors to the file's existing headings, nothing else changed. +162 tokens, + // zero headroom to absorb any of it, ceiling +162. Maintainer ruling + // 2026-09-22 on decision batch #213 item 1, verbatim and untranslated: + // 「1 2 4 同意」. + ['skills/objectstack-ui/rules/dashboards.md', 6252], + // 3011 -> 3141 (#19715, card item 4): a 5-line table of contents at the top, + // anchors to the file's existing headings, nothing else changed. +132 tokens, + // of which the row's 2 tokens of headroom absorb 2; the CEILING moves 130. + // Maintainer ruling 2026-09-22 on decision batch #213 item 1, verbatim and + // untranslated: 「1 2 4 同意」. + ['skills/objectstack-ui/rules/list-views.md', 3141], ['skills/objectstack-ui/rules/navigation.md', 2273], - ['skills/objectstack-ui/rules/pages.md', 5501], + // 5501 -> 5692 (#19715, card item 4): a 5-line table of contents at the top, + // anchors to the file's existing headings, nothing else changed. +191 tokens, + // zero headroom to absorb any of it, ceiling +191. Maintainer ruling + // 2026-09-22 on decision batch #213 item 1, verbatim and untranslated: + // 「1 2 4 同意」. + ['skills/objectstack-ui/rules/pages.md', 5692], // ── 2026-09-22 evals fixtures — decision batch #213 item 2, PR #19721 ──── // Every published skill gained `evals/*.json` in the objectui fixture shape. @@ -500,6 +556,25 @@ export const CEILINGS = new Map([ ['skills/objectstack-upgrade/evals/protocol-major-upgrade.json', 1033], // the remaining skills' eval notes + + // ── 2026-09-22 the #19715 split — decision batch #213 item 1 ───────────── + // The maintainer ruled the four over-500-line published SKILL.md files split + // into routing + rules, every moved paragraph and code block landing + // VERBATIM under that skill's `references/` with a one-line pointer left in + // place; verbatim and untranslated: 「1 2 4 同意」 on item 1 「四个超长 SKILL.md 按 500 行切,整段示例与 platform 的运维尾段下沉 references/,零删内容;同卡顺手:data 的 description 压到约 700 字符、长参考文件加目录」. + // Each row below is a NEW FILE holding text that moved out of its SKILL.md + // (whose row is RE-LOCKED above), pinned AT its landed count — no headroom, + // because a split that arrives with budget is a raise wearing a new path. + // The package deltas are the pointer lines and the four new file titles only. + ['skills/objectstack-automation/references/examples-flows.md', 1436], + ['skills/objectstack-automation/references/state-machines-and-approvals.md', 5569], + ['skills/objectstack-data/references/examples-objects.md', 1779], + ['skills/objectstack-data/references/lint-rules.md', 970], + ['skills/objectstack-data/references/seeds.md', 1299], + ['skills/objectstack-platform/references/bootstrap.md', 1351], + ['skills/objectstack-platform/references/operations.md', 3125], + ['skills/objectstack-platform/references/plugin-development.md', 3137], + ['skills/objectstack-upgrade/references/examples-upgrade.md', 1197], ]); /** diff --git a/skills/README.md b/skills/README.md index 62482e1e7c8..23a3453dd17 100644 --- a/skills/README.md +++ b/skills/README.md @@ -38,7 +38,7 @@ apps too). | Skill | Domain | What it covers | |:------|:-------|:---------------| | [Platform](./objectstack-platform/SKILL.md) | `platform` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, scaffolding), declaring platform capabilities (`requires:` — which service plugins boot), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). | -| [Data](./objectstack-data/SKILL.md) | `data` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data `lifecycle` retention/TTL/rotation, metadata `protection` locks, and external / federated datasources (`defineDatasource`) — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. | +| [Data](./objectstack-data/SKILL.md) | `data` | Design ObjectStack data schemas — objects, fields, relationships, validations, indexes, hooks, security, seeds and datasources. | | [Query](./objectstack-query/SKILL.md) | `query` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, relation expansion, and full-text search. | | [UI](./objectstack-ui/SKILL.md) | `ui` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). | | [Automation](./objectstack-automation/SKILL.md) | `automation` | Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the `jobs` (`defineJob`) / `webhooks` (`defineWebhook`) stack collections. | diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 134893d26ac..70026a41f8e 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -140,73 +140,7 @@ variables: [ ], ``` -### 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 refused. -> `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, and every path through -> the graph must reach an `end` node. - - -```typescript -import { defineFlow } from '@objectstack/spec'; - -export const EscalateOverdueCasesFlow = defineFlow({ - name: 'escalate_overdue_cases', - label: 'Escalate Overdue Cases', - type: 'schedule', - status: 'active', - runAs: 'system', // a scheduled run has no trigger user — elevate explicitly - nodes: [ - { - 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 a named parse error, not a silent - // strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use - // the cron`…` tagged template — its envelope is not a recognized shape. - config: { schedule: { type: 'cron', expression: '0 9 * * *' } }, - }, - { - id: 'escalate_overdue', - type: 'update_record', - label: 'Escalate Overdue Cases', - config: { - 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', - 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', label: 'End' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'escalate_overdue' }, - { id: 'e2', source: 'escalate_overdue', target: 'notify_manager' }, - { id: 'e3', source: 'notify_manager', target: 'end' }, - ], -}); -``` +→ Moved verbatim to [references/examples-flows.md](./references/examples-flows.md) § Flow Example — Auto-Escalate Overdue Cases. ### Failure routing & `runAs` @@ -375,389 +309,7 @@ them right the first time: --- -## State Machines & Approvals - -A record's **state machine** locks the legal transitions of its status field -so that automation — increasingly AI-generated — cannot drive a record into an -illegal state. - -### State Machine — a `state_machine` validation rule (ADR-0020) - -Since **ADR-0020** there is **no `workflow` metadata type** and no -`object.stateMachines` map. A record state machine is **one `state_machine` -validation rule** in the object's `validations` array: a flat `field` + -`{ from: [allowedTo] }` transition table. It is **enforced on the write path** — -an update whose `field` moves to a state not listed for the current state is -rejected with the rule's `message`. A `from` state mapped to `[]` is a declared -dead-end. - -```typescript -{ - type: 'state_machine', - name: 'case_lifecycle', - label: 'Case Lifecycle', - field: 'status', // the field that holds the state - message: 'Invalid status transition.', - initialStates: ['new'], // states a record may be CREATED in - transitions: { - new: ['open'], - open: ['escalated', 'resolved'], - escalated: ['open', 'resolved'], - resolved: ['open', 'closed'], - closed: [], // final — no outgoing transitions - }, -} -``` - -Notes: -- **One rule per field.** Parallel lifecycles (e.g. `status` + `payment_status`) - are N separate `state_machine` rules, one per field. -- **`initialStates`** (optional) 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) — a `record_change` flow whose start-node condition gates on the - transition, e.g. `previous.status != 'escalated' && record.status == 'escalated'`. -- **Introspection:** `GET /api/v1/meta/object/: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` = no FSM governs the field, **or** - `?from=` was omitted — always pass `from`). -- **An unlisted `from` state is NOT guarded.** An update whose current state is - not a key of `transitions` is treated leniently (no lock) — list every state - you want guarded rather than relying on an implicit "any → any". -- Predicate conditions in sibling rules evaluate against the merged record in - the **`record.`** CEL scope (bare field names do not resolve). - -### Approvals (Flow Nodes) - -Since **ADR-0019** there is no standalone approval-process type. An approval is -authored as an **Approval node** (`type: 'approval'`) on an ordinary flow — the -run **suspends** when it reaches the node and **resumes** down the node's -`approve` / `reject` out-edge once a decision is recorded. Multi-step review is -just successive Approval nodes wired together on the canvas, so the whole review -is one diagram a reviewer (or AI) can read end-to-end. - -> There is no `approvals: [...]` stack collection — approval flows live in your -> normal `flows: [...]`. The approval *state* (`sys_approval_request` / -> `sys_approval_action`, the record lock, the status mirror, approver -> resolution) is owned by `plugin-approvals`. - -```typescript -// A record-triggered flow: high-value opportunities need manager sign-off, -// and director sign-off too when the amount clears 500k. -{ - name: 'opportunity_discount_approval', - label: 'Opportunity Discount Approval', - type: 'record_change', - nodes: [ - // Record-change flows bind via the START NODE's config — there is no - // separate top-level `trigger`. `triggerType` is one of - // `record-(before|after)-(create|update|delete)`; `condition` (bare CEL) - // gates whether the flow launches. - { - id: 'start', - type: 'start', - label: 'On Opportunity Update', - config: { - objectName: 'opportunity', - triggerType: 'record-after-update', - condition: cel`record.amount > 100000`, - }, - }, - { - id: 'manager_review', - type: 'approval', - label: 'Sales Manager Review', - config: { - approvers: [{ type: 'position', value: 'sales_manager' }], - 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 - }, - }, - // 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', - label: 'Sales Director Sign-off', - config: { - approvers: [{ type: 'position', value: 'sales_director' }], - behavior: 'unanimous', - approvalStatusField: 'approval_status', - }, - }, - { id: 'mark_won', type: 'update_record', label: 'Mark Won', - config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } }, - { id: 'approved', type: 'end', label: 'Approved' }, - { id: 'rejected', type: 'end', label: 'Rejected' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'manager_review', - // entry criteria re-homes onto the edge entering the approval node: - condition: cel`record.amount > 100000` }, - { id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' }, - { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' }, - // 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' }, - ], -} -``` - -### Send-back for revision (ADR-0044) - -Approval centers also model **send back for revision** (退回修改) — distinct from -`reject` (terminate) and from a comment thread (which keeps the request pending). -Send-back is a **flow movement**: the request finalizes as `returned`, the run -walks a **`revise`** out-edge to an **`approval_revise`** node (the *revise -window*) where the record unlocks and the submitter reworks it, and an explicit -*resubmit* re-enters the approval node over a **declared back-edge**, opening -round N+1 with a fresh approver slate. - -``` -approval ──approve──▶ … - ──reject───▶ … - ──revise───▶ approval_revise (record unlocked, submitter edits) - └──resubmit──[type:'back']──▶ approval (round N+1) -``` - -Three pieces author it: - -1. **`revise` out-edge** — a third branch label alongside `approve` / `reject`, - targeting an **`approval_revise`** node. It must be that node type: the window - is a *service-owned* pause (`resumeAuthority: 'service'`), ended only by - `POST /api/v1/approvals/requests/:id/resubmit`; a `wait` is - `resumeAuthority: 'any'`, so a raw run-resume would walk the back-edge - unchecked. The node takes **no config** — there is no signal to wait on. -2. **`type: 'back'` resubmit edge** — the edge from the revise window back into - the approval node MUST be typed `'back'`. This is the *only* thing that - legalizes the cycle: `registerFlow` validates the graph **minus `back` edges** - as a DAG, so an **unmarked** cycle is rejected — you opt in, edge by edge. At - run time a back-edge traverses normally (it just re-enters the node). -3. **`maxRevisions`** on the approval `config` (default `3`) — the budget of - send-backs per run; exceeding it **auto-rejects** (resumes down the `reject` - edge). `maxRevisions: 0` disables send-back, so never pair `0` with a `revise` - edge. - -```typescript -{ - id: 'manager_review', type: 'approval', label: 'Manager Review', - config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 }, -}, -// No config and no `waitEventConfig`: the window ends on the submitter's -// explicit resubmit, not on a signal or a timer. -{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' }, -// …among the approval's edges… -{ id: 'rev', source: 'manager_review', target: 'wait_revision', label: 'revise' }, -{ id: 'back', source: 'wait_revision', target: 'manager_review', label: 'resubmit', type: 'back' }, -``` - -> Three mistakes the compile-time flow lint flags: a `revise` edge into anything -> but an `approval_revise` node (an **error** — `sendBack` refuses that metadata, -> so the branch cannot run; `flow-approval-revise-target-not-service-owned`), a -> `revise` edge whose window never loops back (a dead end `registerFlow` accepts -> but that leaves the 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 the `showcase_budget_approval` flow in the showcase app in -> the framework repo for the canonical shape. - -### Recording a decision - -A decision is recorded through `ApprovalService.decide()` (or the REST routes -`POST /api/v1/approvals/requests/:id/approve` | `/reject`). That finalizes the -`sys_approval_request` and **resumes** the suspended run down the matching -branch — you never resume the flow by hand, and you *cannot*: the -`approval` node declares `resumeAuthority: 'service'`, so -`POST /api/v1/automation/:name/runs/:runId/resume` answers **403** for a run -parked on one (including via a `subflow` pause) and changes nothing. - -A decision may also carry **structured outputs** (`{ outputs: { … } }` in the -decide body) when the node declares the keys in `decisionOutputs` — the author -declares keys, approvers only fill values. Accepted outputs resume the run as -`.` flow variables, so a LATER node reads them as -`vars..` — this is how "the previous approver picks the next -step's approvers" works without writing to a record field (see Dynamic -approvers below). A decision carrying an undeclared key is rejected; -`decision` / `requestId` are reserved. A declaration marked -`required: true` must carry a non-blank value to **approve** (never to -reject) — enforced before any write, with no elevation bypass, so the run -cannot resume past the node with the key a later `expression` approver reads -still missing. - -### Approver Types - -| `type` | Resolves to | -|:-------|:------------| -| `user` | A specific user id (`value` = user id) | -| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) | -| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major | -| `team` | Members of a flat `sys_team` | -| `department` | A department + all descendant departments | -| `manager` | The submitter's manager (`sys_user.manager_id`) | -| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | -| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author | -| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | - -### Dynamic approvers (`type: 'expression'`) - -An `expression` approver computes WHO approves at the moment the node is -entered. Its CEL source sees exactly **three roots** — nothing else: - -| Root | Meaning | Analog | -|:-----|:--------|:-------| -| `current.*` | The record's **live** state at node entry — fields written by earlier steps/approvers are visible | ServiceNow `current` | -| `trigger.*` | The **submit-time snapshot** (what flow conditions call `record`) | ServiceNow Flow Designer `trigger.record`, Power Automate `triggerBody()` | -| `vars.*` | Flow variables — node outputs (`vars..`), `get_record` results, `vars.previous` (the pre-update row) | BPMN process variables | - -**`record` and bare field names are NOT available and fail the node loudly.** -Everywhere else on this platform `record` means "the record at event time" -(flow conditions: the trigger snapshot; hook conditions: the stored record -overlaid with the write's payload) — at an -approval node that phrase is ambiguous between two different times, so you must -say which one: `current.x` or `trigger.x`. Do not carry the `record.x` habit -over from conditions. - -Result contract: a user-id string, a CSV string, or an array of ids. An **empty** -result (present-but-empty field/variable) triggers `onEmptyApprovers`. A -**missing** key (`vars.never_written`) is a loud error, never a silent empty -slate — guard genuinely-optional inputs explicitly, e.g. -`has(vars.picked) ? vars.picked : []`. - -```typescript -// ① Route on a field an EARLIER approver filled in mid-flow (live value): -{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' } - -// ② The previous approval node's decision outputs pick this node's approvers: -{ type: 'expression', value: cel`vars.lead_review.next_reviewers` } - -// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands -// each into its members, and with behavior: 'per_group' EACH department is -// its own sign-off group: -{ - approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }], - behavior: 'per_group', - onEmptyApprovers: 'fail', -} -``` - -The full "previous approver picks the next step's approvers" loop, end to end -(the shipped `showcase_dynamic_approval` flow in the showcase app is this shape): - - -```typescript -import { defineFlow } from '@objectstack/spec'; - -export const DynamicApprovalFlow = defineFlow({ - name: 'dynamic_approval', - label: 'Dynamic Approval', - type: 'autolaunched', - status: 'active', - nodes: [ - { - id: 'start', type: 'start', label: 'On Submit', - config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" }, - }, - { - // Node A declares what a decision may hand to the flow. The TYPED - // declaration renders a multi-select sys_user picker in the decision - // dialog; the lead approves with outputs: - // POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } } - // `required: true` is enforced by the runtime on APPROVE (never on - // reject) — node B below has nobody to route to without it. - id: 'lead_review', type: 'approval', label: 'Lead Review', - config: { - approvers: [{ type: 'org_membership_level', value: 'owner' }], - decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }], - }, - }, - { - // Node B resolves them at entry from the lead's decision outputs. - id: 'co_sign', type: 'approval', label: 'Co-sign', - config: { - approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }], - behavior: 'unanimous', - onEmptyApprovers: 'fail', - }, - }, - { id: 'approved', type: 'end', label: 'Approved' }, - { id: 'rejected', type: 'end', label: 'Rejected' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'lead_review' }, - { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' }, - { id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' }, - { id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' }, - { id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' }, - ], -}); -``` - -Time-word cheat sheet across surfaces (do not mix them up): - -| Surface | Event-time record | Pre-event record | Live record | -|:--------|:------------------|:-----------------|:------------| -| Flow condition / `{…}` template | `record` (trigger snapshot) | `previous` | — (use a `get_record` node) | -| Approval `expression` approver | `trigger.*` | `vars.previous` | `current.*` | - -Object-hook `ctx` is a different vocabulary — see **objectstack-data** -`references/data-hooks.md`. - -### Node Config (`ApprovalNodeConfigSchema`) - -| Field | Purpose | -|:------|:--------| -| `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`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime 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) | -| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through), `fallback` (request opens on the sibling `fallbackApprovers` instead — same shape as `approvers`, required by this policy and refused under any other; a fallback that itself resolves to nobody degrades to `admin_rescue`). Declare it explicitly on any node with an `expression` approver (linted) | -| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `.` variables; undeclared keys reject the decision; `decision`/`requestId` reserved | -| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `timeoutHours` is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. `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) | -| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge | - -### Branching, side-effects & rejection - -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`, 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: a - `revise` out-edge into an **`approval_revise`** window, closed by a - `type: 'back'` resubmit edge. See *Send-back for revision* above. -- **Hard reject** — route the `reject` edge to an `end` node (the old - `reject_process`). - -### Approval Best Practices - -1. **Gate entry on the edge** (`condition` into the Approval node) so the flow - only pauses for records that actually need sign-off. -2. **Set `approvalStatusField`** to mirror status onto the row — views and - formulas can then filter on it without joining `sys_approval_request`. -3. **Keep `lockRecord: true`** unless you have a strong reason to allow - edits while pending — otherwise approvers chase a moving target. -4. **Model rejection as a visible branch** — a back-edge to revise, or an `end` - node to terminate. The path is on the diagram, not hidden in config. -5. **Notify from downstream nodes** wired to the `approve` / `reject` edges - rather than expecting the node to send mail itself. +→ State Machines & Approvals moved verbatim to [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) (State Machine — a `state_machine` validation rule · Approvals (Flow Nodes) · Send-back for revision · Recording a decision · Approver Types · Dynamic approvers (`type: 'expression'`) · Node Config (`ApprovalNodeConfigSchema`) · Branching, side-effects & rejection · Approval Best Practices). --- @@ -848,72 +400,7 @@ owns `label`, `type`, `nodes` and the `edges` `FlowSchema` requires): > triggers — `previous.x` is the value before the change, `record.x` is the > value after. See [objectstack-formula](../objectstack-formula/SKILL.md). -### Time-relative triggers — scheduled per-record date sweep - -**Don't** express "act N days before/after a date" (renewal reminders, "expiring -soon", overdue sweeps) as a `record_change` flow gated on date-equality -(`end_date == daysFromNow(60)`) — that predicate is only evaluated when the -record *happens to change*, so unattended it almost never fires. Use a -**declarative time-relative trigger**: a `schedule`-type flow whose `start` node -carries a **`timeRelative`** descriptor is swept on a schedule (daily by default) -and launched **once per record** whose date field falls in the window. The record -is on the context, so the start `condition` and `{record.*}` interpolation work -exactly as for a record-change flow — and because the window is evaluated every -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', 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 * * *' } - }, - }, - // …downstream nodes (notify, update_record, …) - ], - edges: [ /* start → downstream */ ], -} -``` - -**Date EQUALITY never matches**, so a hand-rolled sweep filters windows, not -days: a `date` field carries a time component, so `field == daysFromNow(N)` (or -`{ $in: [...] }`) compares two differently-timed timestamps and silently returns -nothing (build warns `flow-date-equality-filter`). Tier each threshold as a -one-day **window** (`$gte`/`$lt`): - -```ts -filter: { status: 'active', $or: [ - { end_date: { $gte: '{TODAY() + 7}', $lt: '{TODAY() + 8}' } }, - { end_date: { $gte: '{TODAY() + 30}', $lt: '{TODAY() + 31}' } }, - { end_date: { $gte: '{TODAY() + 60}', $lt: '{TODAY() + 61}' } }, -] } -``` - -Abutting windows tile the timeline, so each record matches exactly one tier — -fires once, idempotent, no guard field. 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. For "days remaining" in a message, use -`daysBetween(today(), record.end_date)`. - -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). Full descriptor schema: -`node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts`. +→ Moved verbatim to [references/examples-flows.md](./references/examples-flows.md) § Time-relative triggers — scheduled per-record date sweep. --- diff --git a/skills/objectstack-automation/references/examples-flows.md b/skills/objectstack-automation/references/examples-flows.md new file mode 100644 index 00000000000..41736c84632 --- /dev/null +++ b/skills/objectstack-automation/references/examples-flows.md @@ -0,0 +1,136 @@ +# Automation — long flow examples (moved verbatim from SKILL.md) + +### 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 refused. +> `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, and every path through +> the graph must reach an `end` node. + + +```typescript +import { defineFlow } from '@objectstack/spec'; + +export const EscalateOverdueCasesFlow = defineFlow({ + name: 'escalate_overdue_cases', + label: 'Escalate Overdue Cases', + type: 'schedule', + status: 'active', + runAs: 'system', // a scheduled run has no trigger user — elevate explicitly + nodes: [ + { + 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 a named parse error, not a silent + // strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use + // the cron`…` tagged template — its envelope is not a recognized shape. + config: { schedule: { type: 'cron', expression: '0 9 * * *' } }, + }, + { + id: 'escalate_overdue', + type: 'update_record', + label: 'Escalate Overdue Cases', + config: { + 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', + 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', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'escalate_overdue' }, + { id: 'e2', source: 'escalate_overdue', target: 'notify_manager' }, + { id: 'e3', source: 'notify_manager', target: 'end' }, + ], +}); +``` + +### Time-relative triggers — scheduled per-record date sweep + +**Don't** express "act N days before/after a date" (renewal reminders, "expiring +soon", overdue sweeps) as a `record_change` flow gated on date-equality +(`end_date == daysFromNow(60)`) — that predicate is only evaluated when the +record *happens to change*, so unattended it almost never fires. Use a +**declarative time-relative trigger**: a `schedule`-type flow whose `start` node +carries a **`timeRelative`** descriptor is swept on a schedule (daily by default) +and launched **once per record** whose date field falls in the window. The record +is on the context, so the start `condition` and `{record.*}` interpolation work +exactly as for a record-change flow — and because the window is evaluated every +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', 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 * * *' } + }, + }, + // …downstream nodes (notify, update_record, …) + ], + edges: [ /* start → downstream */ ], +} +``` + +**Date EQUALITY never matches**, so a hand-rolled sweep filters windows, not +days: a `date` field carries a time component, so `field == daysFromNow(N)` (or +`{ $in: [...] }`) compares two differently-timed timestamps and silently returns +nothing (build warns `flow-date-equality-filter`). Tier each threshold as a +one-day **window** (`$gte`/`$lt`): + +```ts +filter: { status: 'active', $or: [ + { end_date: { $gte: '{TODAY() + 7}', $lt: '{TODAY() + 8}' } }, + { end_date: { $gte: '{TODAY() + 30}', $lt: '{TODAY() + 31}' } }, + { end_date: { $gte: '{TODAY() + 60}', $lt: '{TODAY() + 61}' } }, +] } +``` + +Abutting windows tile the timeline, so each record matches exactly one tier — +fires once, idempotent, no guard field. 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. For "days remaining" in a message, use +`daysBetween(today(), record.end_date)`. + +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). Full descriptor schema: +`node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts`. diff --git a/skills/objectstack-automation/references/state-machines-and-approvals.md b/skills/objectstack-automation/references/state-machines-and-approvals.md new file mode 100644 index 00000000000..575b0dad681 --- /dev/null +++ b/skills/objectstack-automation/references/state-machines-and-approvals.md @@ -0,0 +1,383 @@ +## State Machines & Approvals + +A record's **state machine** locks the legal transitions of its status field +so that automation — increasingly AI-generated — cannot drive a record into an +illegal state. + +### State Machine — a `state_machine` validation rule (ADR-0020) + +Since **ADR-0020** there is **no `workflow` metadata type** and no +`object.stateMachines` map. A record state machine is **one `state_machine` +validation rule** in the object's `validations` array: a flat `field` + +`{ from: [allowedTo] }` transition table. It is **enforced on the write path** — +an update whose `field` moves to a state not listed for the current state is +rejected with the rule's `message`. A `from` state mapped to `[]` is a declared +dead-end. + +```typescript +{ + type: 'state_machine', + name: 'case_lifecycle', + label: 'Case Lifecycle', + field: 'status', // the field that holds the state + message: 'Invalid status transition.', + initialStates: ['new'], // states a record may be CREATED in + transitions: { + new: ['open'], + open: ['escalated', 'resolved'], + escalated: ['open', 'resolved'], + resolved: ['open', 'closed'], + closed: [], // final — no outgoing transitions + }, +} +``` + +Notes: +- **One rule per field.** Parallel lifecycles (e.g. `status` + `payment_status`) + are N separate `state_machine` rules, one per field. +- **`initialStates`** (optional) 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) — a `record_change` flow whose start-node condition gates on the + transition, e.g. `previous.status != 'escalated' && record.status == 'escalated'`. +- **Introspection:** `GET /api/v1/meta/object/: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` = no FSM governs the field, **or** + `?from=` was omitted — always pass `from`). +- **An unlisted `from` state is NOT guarded.** An update whose current state is + not a key of `transitions` is treated leniently (no lock) — list every state + you want guarded rather than relying on an implicit "any → any". +- Predicate conditions in sibling rules evaluate against the merged record in + the **`record.`** CEL scope (bare field names do not resolve). + +### Approvals (Flow Nodes) + +Since **ADR-0019** there is no standalone approval-process type. An approval is +authored as an **Approval node** (`type: 'approval'`) on an ordinary flow — the +run **suspends** when it reaches the node and **resumes** down the node's +`approve` / `reject` out-edge once a decision is recorded. Multi-step review is +just successive Approval nodes wired together on the canvas, so the whole review +is one diagram a reviewer (or AI) can read end-to-end. + +> There is no `approvals: [...]` stack collection — approval flows live in your +> normal `flows: [...]`. The approval *state* (`sys_approval_request` / +> `sys_approval_action`, the record lock, the status mirror, approver +> resolution) is owned by `plugin-approvals`. + +```typescript +// A record-triggered flow: high-value opportunities need manager sign-off, +// and director sign-off too when the amount clears 500k. +{ + name: 'opportunity_discount_approval', + label: 'Opportunity Discount Approval', + type: 'record_change', + nodes: [ + // Record-change flows bind via the START NODE's config — there is no + // separate top-level `trigger`. `triggerType` is one of + // `record-(before|after)-(create|update|delete)`; `condition` (bare CEL) + // gates whether the flow launches. + { + id: 'start', + type: 'start', + label: 'On Opportunity Update', + config: { + objectName: 'opportunity', + triggerType: 'record-after-update', + condition: cel`record.amount > 100000`, + }, + }, + { + id: 'manager_review', + type: 'approval', + label: 'Sales Manager Review', + config: { + approvers: [{ type: 'position', value: 'sales_manager' }], + 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 + }, + }, + // 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', + label: 'Sales Director Sign-off', + config: { + approvers: [{ type: 'position', value: 'sales_director' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + }, + }, + { id: 'mark_won', type: 'update_record', label: 'Mark Won', + config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'manager_review', + // entry criteria re-homes onto the edge entering the approval node: + condition: cel`record.amount > 100000` }, + { id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' }, + { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' }, + // 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' }, + ], +} +``` + +### Send-back for revision (ADR-0044) + +Approval centers also model **send back for revision** (退回修改) — distinct from +`reject` (terminate) and from a comment thread (which keeps the request pending). +Send-back is a **flow movement**: the request finalizes as `returned`, the run +walks a **`revise`** out-edge to an **`approval_revise`** node (the *revise +window*) where the record unlocks and the submitter reworks it, and an explicit +*resubmit* re-enters the approval node over a **declared back-edge**, opening +round N+1 with a fresh approver slate. + +``` +approval ──approve──▶ … + ──reject───▶ … + ──revise───▶ approval_revise (record unlocked, submitter edits) + └──resubmit──[type:'back']──▶ approval (round N+1) +``` + +Three pieces author it: + +1. **`revise` out-edge** — a third branch label alongside `approve` / `reject`, + targeting an **`approval_revise`** node. It must be that node type: the window + is a *service-owned* pause (`resumeAuthority: 'service'`), ended only by + `POST /api/v1/approvals/requests/:id/resubmit`; a `wait` is + `resumeAuthority: 'any'`, so a raw run-resume would walk the back-edge + unchecked. The node takes **no config** — there is no signal to wait on. +2. **`type: 'back'` resubmit edge** — the edge from the revise window back into + the approval node MUST be typed `'back'`. This is the *only* thing that + legalizes the cycle: `registerFlow` validates the graph **minus `back` edges** + as a DAG, so an **unmarked** cycle is rejected — you opt in, edge by edge. At + run time a back-edge traverses normally (it just re-enters the node). +3. **`maxRevisions`** on the approval `config` (default `3`) — the budget of + send-backs per run; exceeding it **auto-rejects** (resumes down the `reject` + edge). `maxRevisions: 0` disables send-back, so never pair `0` with a `revise` + edge. + +```typescript +{ + id: 'manager_review', type: 'approval', label: 'Manager Review', + config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 }, +}, +// No config and no `waitEventConfig`: the window ends on the submitter's +// explicit resubmit, not on a signal or a timer. +{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' }, +// …among the approval's edges… +{ id: 'rev', source: 'manager_review', target: 'wait_revision', label: 'revise' }, +{ id: 'back', source: 'wait_revision', target: 'manager_review', label: 'resubmit', type: 'back' }, +``` + +> Three mistakes the compile-time flow lint flags: a `revise` edge into anything +> but an `approval_revise` node (an **error** — `sendBack` refuses that metadata, +> so the branch cannot run; `flow-approval-revise-target-not-service-owned`), a +> `revise` edge whose window never loops back (a dead end `registerFlow` accepts +> but that leaves the 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 the `showcase_budget_approval` flow in the showcase app in +> the framework repo for the canonical shape. + +### Recording a decision + +A decision is recorded through `ApprovalService.decide()` (or the REST routes +`POST /api/v1/approvals/requests/:id/approve` | `/reject`). That finalizes the +`sys_approval_request` and **resumes** the suspended run down the matching +branch — you never resume the flow by hand, and you *cannot*: the +`approval` node declares `resumeAuthority: 'service'`, so +`POST /api/v1/automation/:name/runs/:runId/resume` answers **403** for a run +parked on one (including via a `subflow` pause) and changes nothing. + +A decision may also carry **structured outputs** (`{ outputs: { … } }` in the +decide body) when the node declares the keys in `decisionOutputs` — the author +declares keys, approvers only fill values. Accepted outputs resume the run as +`.` flow variables, so a LATER node reads them as +`vars..` — this is how "the previous approver picks the next +step's approvers" works without writing to a record field (see Dynamic +approvers below). A decision carrying an undeclared key is rejected; +`decision` / `requestId` are reserved. A declaration marked +`required: true` must carry a non-blank value to **approve** (never to +reject) — enforced before any write, with no elevation bypass, so the run +cannot resume past the node with the key a later `expression` approver reads +still missing. + +### Approver Types + +| `type` | Resolves to | +|:-------|:------------| +| `user` | A specific user id (`value` = user id) | +| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) | +| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major | +| `team` | Members of a flat `sys_team` | +| `department` | A department + all descendant departments | +| `manager` | The submitter's manager (`sys_user.manager_id`) | +| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | +| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author | +| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | + +### Dynamic approvers (`type: 'expression'`) + +An `expression` approver computes WHO approves at the moment the node is +entered. Its CEL source sees exactly **three roots** — nothing else: + +| Root | Meaning | Analog | +|:-----|:--------|:-------| +| `current.*` | The record's **live** state at node entry — fields written by earlier steps/approvers are visible | ServiceNow `current` | +| `trigger.*` | The **submit-time snapshot** (what flow conditions call `record`) | ServiceNow Flow Designer `trigger.record`, Power Automate `triggerBody()` | +| `vars.*` | Flow variables — node outputs (`vars..`), `get_record` results, `vars.previous` (the pre-update row) | BPMN process variables | + +**`record` and bare field names are NOT available and fail the node loudly.** +Everywhere else on this platform `record` means "the record at event time" +(flow conditions: the trigger snapshot; hook conditions: the stored record +overlaid with the write's payload) — at an +approval node that phrase is ambiguous between two different times, so you must +say which one: `current.x` or `trigger.x`. Do not carry the `record.x` habit +over from conditions. + +Result contract: a user-id string, a CSV string, or an array of ids. An **empty** +result (present-but-empty field/variable) triggers `onEmptyApprovers`. A +**missing** key (`vars.never_written`) is a loud error, never a silent empty +slate — guard genuinely-optional inputs explicitly, e.g. +`has(vars.picked) ? vars.picked : []`. + +```typescript +// ① Route on a field an EARLIER approver filled in mid-flow (live value): +{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' } + +// ② The previous approval node's decision outputs pick this node's approvers: +{ type: 'expression', value: cel`vars.lead_review.next_reviewers` } + +// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands +// each into its members, and with behavior: 'per_group' EACH department is +// its own sign-off group: +{ + approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }], + behavior: 'per_group', + onEmptyApprovers: 'fail', +} +``` + +The full "previous approver picks the next step's approvers" loop, end to end +(the shipped `showcase_dynamic_approval` flow in the showcase app is this shape): + + +```typescript +import { defineFlow } from '@objectstack/spec'; + +export const DynamicApprovalFlow = defineFlow({ + name: 'dynamic_approval', + label: 'Dynamic Approval', + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', type: 'start', label: 'On Submit', + config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" }, + }, + { + // Node A declares what a decision may hand to the flow. The TYPED + // declaration renders a multi-select sys_user picker in the decision + // dialog; the lead approves with outputs: + // POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } } + // `required: true` is enforced by the runtime on APPROVE (never on + // reject) — node B below has nobody to route to without it. + id: 'lead_review', type: 'approval', label: 'Lead Review', + config: { + approvers: [{ type: 'org_membership_level', value: 'owner' }], + decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }], + }, + }, + { + // Node B resolves them at entry from the lead's decision outputs. + id: 'co_sign', type: 'approval', label: 'Co-sign', + config: { + approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }], + behavior: 'unanimous', + onEmptyApprovers: 'fail', + }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'lead_review' }, + { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' }, + { id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' }, + { id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' }, + { id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' }, + ], +}); +``` + +Time-word cheat sheet across surfaces (do not mix them up): + +| Surface | Event-time record | Pre-event record | Live record | +|:--------|:------------------|:-----------------|:------------| +| Flow condition / `{…}` template | `record` (trigger snapshot) | `previous` | — (use a `get_record` node) | +| Approval `expression` approver | `trigger.*` | `vars.previous` | `current.*` | + +Object-hook `ctx` is a different vocabulary — see **objectstack-data** +`references/data-hooks.md`. + +### Node Config (`ApprovalNodeConfigSchema`) + +| Field | Purpose | +|:------|:--------| +| `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`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime 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) | +| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through), `fallback` (request opens on the sibling `fallbackApprovers` instead — same shape as `approvers`, required by this policy and refused under any other; a fallback that itself resolves to nobody degrades to `admin_rescue`). Declare it explicitly on any node with an `expression` approver (linted) | +| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `.` variables; undeclared keys reject the decision; `decision`/`requestId` reserved | +| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `timeoutHours` is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. `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) | +| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge | + +### Branching, side-effects & rejection + +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`, 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: a + `revise` out-edge into an **`approval_revise`** window, closed by a + `type: 'back'` resubmit edge. See *Send-back for revision* above. +- **Hard reject** — route the `reject` edge to an `end` node (the old + `reject_process`). + +### Approval Best Practices + +1. **Gate entry on the edge** (`condition` into the Approval node) so the flow + only pauses for records that actually need sign-off. +2. **Set `approvalStatusField`** to mirror status onto the row — views and + formulas can then filter on it without joining `sys_approval_request`. +3. **Keep `lockRecord: true`** unless you have a strong reason to allow + edits while pending — otherwise approvers chase a moving target. +4. **Model rejection as a visible branch** — a back-edge to revise, or an `end` + node to terminate. The path is on the diagram, not hidden in config. +5. **Notify from downstream nodes** wired to the `approve` / `reject` edges + rather than expecting the node to send mail itself. diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 4b4ffa9232f..2d2bd842280 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -1,20 +1,16 @@ --- name: objectstack-data description: > - Design ObjectStack data schemas — objects, fields, field conditional - rules, relationships, validations, indexes, lifecycle hooks, permissions, - row-level security, data `lifecycle` retention/TTL/rotation, metadata - `protection` locks, and external / federated datasources - (`defineDatasource`) — and the seeds (`defineSeed()`) that load fixtures - and reference data alongside them. Use when the user is creating or - modifying `*.object.ts` files or `src/data/*.ts` seed modules, picking - field types, modelling relationships, writing `beforeInsert`/`afterUpdate` - hooks, configuring per-object access control, pointing an object at an - existing external database, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that - belong on fields. Do not use for querying data (see objectstack-query) or for - plugin / kernel hooks (see objectstack-platform). CEL expressions in - formulas / validations / sharing rules / dynamic seed values: load - objectstack-formula alongside. + Design ObjectStack data schemas — objects, fields, relationships, + validations, indexes, hooks, security, seeds and datasources. Use when the + user is creating or modifying `*.object.ts` files or `src/data/*.ts` seed + modules, picking field types, modelling relationships, writing + `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, + pointing an object at an existing external database, or authoring bootstrap + / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules + that belong on fields. Do not use for querying data (see objectstack-query) + or for plugin / kernel hooks (see objectstack-platform). CEL expressions: + load objectstack-formula alongside. license: Apache-2.0 compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) metadata: @@ -93,7 +89,7 @@ the publish door rejects it before that: | `displayNameField` | — | **Deprecated** alias for `nameField` (still honored as a fallback) | | `titleFormat` | — | **Deprecated, not removed (ADR-0079)** — render-only: the server can't return or query it. Use `nameField` (it wins); for a composite title, designate a `returnType: 'text'` formula field as `nameField` | | `enable` | — | Capability flags (trackHistory, searchable, apiEnabled, etc.) | -| `fieldGroups` | — | Ordered list of logical field groups for forms/detail pages (see [Field Groups](#field-groups-mvp)) | +| `fieldGroups` | — | Ordered list of logical field groups for forms/detail pages (see [Field Groups](./references/examples-objects.md#field-groups-mvp)) | | `lifecycle` | `record` semantics (permanent) | Data retention/rotation/archival contract. **Required for append-only, high-write-rate objects** — a `telemetry`/`transient`/`event`/`audit` class must declare a bounding policy or parsing fails (see [Data Lifecycle & Retention](./rules/lifecycle.md)) | ### Object Capabilities (`enable`) @@ -181,53 +177,7 @@ per-project convention for this — the mirror field is the answer. --- -## Field Groups (MVP) - -Organize fields into logical groups (e.g., "Contact Information", "Billing", -"System") for forms, detail pages, and editors. - -- Declare groups on `ObjectSchema.fieldGroups` — **array order is the display order**. -- Assign each field to a group via `Field.group`, which references an - `ObjectFieldGroup.key`. In-group display order equals the traversal order - of `fields`. -- Group keys must be `snake_case`; group labels are human-readable. -- Optional per-group: `icon`, `description`, and `collapse` - (`'none'` always open · `'expanded'` collapsible, starts open · - `'collapsed'` collapsible, starts closed — replaces the deprecated - `defaultExpanded` flag, ADR-0085). Groups render identically on forms, - modals, and detail pages; for a bespoke single-page layout assign a - custom Page instead. - - -```typescript -import { ObjectSchema } from '@objectstack/spec/data'; - -export default ObjectSchema.create({ - name: 'account', - label: 'Account', - sharingModel: 'private', - - fieldGroups: [ - { key: 'contact_info', label: 'Contact Information', icon: 'user' }, - { key: 'billing', label: 'Billing', collapse: 'collapsed' }, - { key: 'system', label: 'System' }, - ], - - fields: { - name: { type: 'text', required: true, group: 'contact_info' }, - email: { type: 'email', group: 'contact_info' }, - phone: { type: 'phone', group: 'contact_info' }, - vat_id: { type: 'text', group: 'billing' }, - billing_address: { type: 'address', group: 'billing' }, - created_at: { type: 'datetime', readonly: true, group: 'system' }, - created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' }, - }, -}); -``` - -**Supported migrations at this layer:** add / rename / delete / reorder groups -(edit the `fieldGroups` array), assign a field to a group (edit `Field.group`). -Explicit per-field in-group ordering is deferred to a future iteration. +→ Moved verbatim to [references/examples-objects.md](./references/examples-objects.md) § Field Groups (MVP). --- @@ -297,66 +247,7 @@ export const Invoice = ObjectSchema.create({ --- -## Quick-Start Template - - -```typescript -import { ObjectSchema } from '@objectstack/spec/data'; - -export default ObjectSchema.create({ - name: 'support_case', - label: 'Support Case', - pluralLabel: 'Support Cases', - description: 'A customer-reported issue tracked to resolution.', - icon: 'life-buoy', - sharingModel: 'private', // required in practice — see above - highlightFields: ['subject', 'status', 'priority'], - enable: { - trackHistory: true, - feeds: true, - activities: true, - }, - fields: { - subject: { type: 'text', required: true, maxLength: 255 }, - description: { type: 'richtext' }, - status: { type: 'select', required: true, options: [ - { label: 'New', value: 'new', default: true }, - { label: 'Open', value: 'open' }, - { label: 'Escalated', value: 'escalated', color: '#e74c3c' }, - { label: 'Resolved', value: 'resolved', color: '#2ecc71' }, - { label: 'Closed', value: 'closed' }, - ]}, - priority: { type: 'select', options: [ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium', default: true }, - { label: 'High', value: 'high', color: '#e67e22' }, - { label: 'Urgent', value: 'urgent', color: '#e74c3c' }, - ]}, - account: { type: 'lookup', reference: 'account', required: true }, - contact: { type: 'lookup', reference: 'contact' }, - assigned_to: { type: 'lookup', reference: 'user' }, - due_date: { type: 'datetime' }, - }, - validations: [ - { - name: 'status_flow', - type: 'state_machine', - field: 'status', - transitions: { - new: ['open'], - open: ['escalated', 'resolved'], - escalated: ['open', 'resolved'], - resolved: ['open', 'closed'], - closed: [], - }, - message: 'Invalid status transition.', - }, - ], -}); -``` - -Declared `indexes` are a separate decision — see -[Index Strategy](./rules/indexing.md). +→ Moved verbatim to [references/examples-objects.md](./references/examples-objects.md) § Quick-Start Template. --- @@ -496,33 +387,7 @@ indexes: [ See [rules/indexing.md](./rules/indexing.md) for composite indexes, unique scope, and how to build partial / gin / gist indexes at the database layer. -### Lifecycle Hooks - -Implement business logic at data operation lifecycle points: - - -```typescript -import { defineHook, HookContext } from '@objectstack/spec/data'; - -export default defineHook({ - name: 'account_defaults', - object: 'account', - events: ['beforeInsert'], - handler: async (ctx: HookContext) => { - if (!ctx.input.industry) { - ctx.input.industry = 'Other'; - } - ctx.input.created_at = new Date().toISOString(); - }, -}); -``` - -The `handler` above is the inline (in-process) form. The **preferred**, -metadata-native form is a sandboxed `body` — `{ language: 'js', source, capabilities }` -run in an isolated VM, the shape that AI/Studio-authored hooks and every build -artifact carry. See [references/data-hooks.md](./references/data-hooks.md) for all 8 -lifecycle events, both registration forms, the **sandboxed `body` ctx + capability -contract**, and the canonical patterns. +→ Moved verbatim to [references/examples-objects.md](./references/examples-objects.md) § Lifecycle Hooks (the hooks reference itself is [references/data-hooks.md](./references/data-hooks.md)). --- @@ -562,265 +427,15 @@ it, and `defineStack` errors unless the grant declares --- -## Metadata Protection (`protection`) - -Package authors can lock shipped metadata against Studio edits / overlays / deletes. - -The `protection` block is **declared on the source schema** (`*.object.ts`, -`*.app.ts`, `*.view.ts`, …) and stripped at load time — it never appears in -the runtime envelope. The runtime instead populates `_lock`, `_lockReason`, -`_lockDocsUrl`, `_lockSource`, and `_packageId`, which REST returns to Studio -and the lock banner reads. - -### Schema - -```ts -protection?: { - /** Lock level — controls what Studio can do to this item. */ - lock: 'none' | 'no-overlay' | 'no-delete' | 'full'; - /** 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 | -| `no-overlay` | ❌ | ✅ | Schema is platform-defined but tenant can drop it (e.g. `sys_role`) | -| `no-delete` | ✅ | ❌ | Tenant may customize fields but the object itself must exist | -| `full` | ❌ | ❌ | Core admin UI / platform identity (e.g. `sys_user`, `app/setup`) | - -### Example - - -```typescript -// src/objects/sys-user.object.ts -import { ObjectSchema } from '@objectstack/spec/data'; - -export const SysUserObject = ObjectSchema.create({ - name: 'sys_user', - label: 'User', - sharingModel: 'public_read', - protection: { - lock: 'full', - reason: 'Core identity object', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - fields: { username: { type: 'text', required: true } }, -}); -``` - -The same block works on non-object metadata (apps, views, dashboards, flows, -agents, tools, skills, reports, email-templates). Enforcement: `PUT`/`DELETE` on -`/api/v1/meta/:type/:name` return `403 item_locked`, and an artifact lock -overrides a package lock. Default to **no** `protection` block for -tenant-authored metadata. +→ Moved verbatim to [references/examples-objects.md](./references/examples-objects.md) § Metadata Protection (`protection`). --- -## Seed Data & Fixtures (`defineSeed()`) - -Object definition and seed data live together — a `*.object.ts` usually -pairs with a `*.seed.ts` (fixtures, reference rows, bootstrap data). -`defineSeed()` is type-safe: TypeScript checks every record's field keys -against the object definition. - -> The factory is `defineSeed` — **not** `defineDataset`, which is the -> unrelated ADR-0021 analytics semantic layer (`@objectstack/spec/ui`). - -> ⛔ `sys_organization` is platform-bootstrapped — never a seed target; the -> deployment posture decides how many exist (`rules/security.md` -> § Multi-tenancy). - -### Quick start - -```typescript -// src/data/index.ts -import { defineSeed } from '@objectstack/spec/data'; -import { Status } from '../objects/status.object'; -import { Category } from '../objects/category.object'; - -// Reference data — every environment -export const statusSeed = defineSeed(Status, { - externalId: 'code', - mode: 'upsert', - records: [ - { code: 'active', label: 'Active', color: '#2ecc71' }, - { code: 'inactive', label: 'Inactive', color: '#95a5a6' }, - ], -}); - -// Demo data — dev/test only -export const categorySeed = defineSeed(Category, { - externalId: 'slug', - mode: 'upsert', - env: ['dev', 'test'], - records: [ - { slug: 'electronics', name: 'Electronics' }, - ], -}); - -export const SeedData = [statusSeed, categorySeed]; // parents first -``` - -### `Seed` fields - -| Field | Default | Purpose | -|:------|:--------|:--------| -| `object` | derived | Auto-set from `objectDef.name` — never write manually | -| `externalId` | `'name'` | Stable business key used for upsert / update lookup | -| `mode` | `'upsert'` | Import strategy (see below) | -| `env` | `['prod','dev','test']` | Environments where the seed loads | -| `records` | — | `Partial>[]` | - -Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`. - -### Import modes - -| Mode | Behavior | Use for | -|:-----|:---------|:--------| -| `upsert` (default) | Update by `externalId`, insert if missing. Idempotent. | Reference data, bootstrap rows | -| `insert` | Insert all; fail on duplicate `externalId`. | Append-only / audit tables | -| `update` | Update only existing rows; never create. | Patching existing config | -| `ignore` | Insert; silently skip duplicates. | Additive bootstrap | -| `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data | - -### `externalId` selection - -Pick a stable natural key. **Never use `id`** — UUIDs differ across -environments. - -| Scenario | Key | -|:---------|:----| -| Named entities (country, currency) | `'code'` / `'slug'` | -| Users / contacts | `'email'` | -| Externally sourced | `'external_id'` | -| Generic | `'name'` (default) | - -### Relationship references - -For `lookup` fields, supply the **natural key** of the target record (not -its UUID). The seed runner resolves at load time. Order seeds so parents -appear before children in the exported array: - -> If a lookup value matches no natural key, the loader falls back to -> resolving it as the target's `id` — so a reference to an existing record -> by internal id resolves instead of dangling to null. Natural keys remain -> the portable default; rely on the id fallback only for records you didn't -> seed (e.g. a system user). - -```typescript -const contacts = defineSeed(Contact, { - externalId: 'email', - records: [{ - email: 'john@acme.example.com', - first_name: 'John', - account: 'Acme Corporation', // natural key of an Account record - }], -}); -``` - -### Dynamic values (CEL) - -Any field value may be a CEL expression evaluated at install time against -a single per-load pinned `now`. This is the **only** correct way to author -time-based or identity-derived seed values — `new Date()` ships the package -author's clock to every customer and breaks build determinism. - -```typescript -import { defineSeed } from '@objectstack/spec/data'; -import { cel } from '@objectstack/spec'; - -defineSeed(Opportunity, { - records: [{ - name: 'Acme Q3 Renewal', - close_date: cel`daysFromNow(45)`, - created_at: cel`now()`, - owner_id: cel`os.user.id`, // installer - organization_id: cel`os.org.id`, - }], -}); -``` - -Stdlib in seed context: `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`, -`isBlank(v)`, `coalesce(v, fallback)`. Scope: `os.user`, `os.org`, `os.env`. -See **objectstack-formula** for the full contract. - -**Determinism gate:** two consecutive `os build` runs with no source -changes must produce byte-identical `dist/objectstack.json`. CEL + pinned -`now` is what guarantees that — using `Date.now()` will fail CI. - -### Seed best practices - -| Practice | Why | -|:---------|:----| -| Always use `defineSeed()`, never `SeedSchema.parse()` | Lose compile-time field checking otherwise | -| Prefer natural keys (`code` / `email` / `slug`) | Portable across environments | -| Default to `upsert` | Idempotent re-runs | -| Scope demo data with `env: ['dev','test']` | Keep noise out of prod | -| Order seeds parent → child in the exported array | References resolve at load time | -| Use `replace` only on cache/lookup tables, with comments | Data-loss footgun | +→ Seed Data & Fixtures (`defineSeed()`) moved verbatim to [references/seeds.md](./references/seeds.md) (Quick start · `Seed` fields · Import modes · `externalId` selection · Relationship references · Dynamic values (CEL) · Seed best practices). --- -## Linting & Generation Quality - -`os lint` checks the data model against the conventions in this skill — -not just naming/labels but the relationship/master-detail/roll-up patterns. Run -it after authoring or generating metadata. Severities: `error` (structural, -fails the command), `warning` (likely-wrong choice), `suggestion` (nudge). - -Data-model rules (in addition to naming/label/i18n): - -| Rule | Severity | Catches | -|---|---|---| -| `relationship/missing-reference` | error | lookup/master_detail without a `reference` target | -| `relationship/master-detail-required` | warning | a `master_detail` that isn't `required` (a detail can't exist without its master) | -| `relationship/delete-behavior` | suggestion | `master_detail` without an explicit `deleteBehavior` | -| `relationship/line-items-inline-edit` | suggestion | a `*_line`/`*_item` master_detail child without `inlineEdit` | -| `relationship/line-item-should-be-master-detail` | suggestion | a line-item-shaped child using `lookup` instead of `master_detail` | -| `relationship/association-inline-edit` | warning | an association (comment/audit/activity) marked `inlineEdit` (clutters the parent form — use a detail-page related list) | -| `rollup/missing-summary` | suggestion | a parent of numeric master_detail children with no roll-up `summary` | -| `field/select-missing-options` | warning | a `select`/`multiselect`/`radio` with no `options` (or options source) | -| `object/missing-name-field` | suggestion | an object with no `nameField` (ADR-0079's canonical title pointer) and no name-like field (`name`/`title`/`subject`/`label`/`full_name`/`display_name`/`code`) | -| `security-owd-unset` | error | an object published with no authored `sharingModel` (422 lint envelope; absence is not a decision) | -| `security-owd-alias` | error | a legacy OWD spelling instead of the canonical four (ADR-0090 D4) | -| `security-external-wider-than-internal` | error | `externalSharingModel` wider than `sharingModel` (ADR-0090 D11) | -| `security-master-detail-ungranted` | warning | a `master_detail` child whose master carries no matching grant | - -> **`code` counts for R9, but is NOT a title-derivation key.** R9's name-like -> list above is the *looser* of two "name-like" sets, and the difference is -> deliberate. R9 asks **"will records be anonymous?"** — is there any readable -> face at all — and a `code` clears that bar. ADR-0079's title derivation -> (`resolveDisplayField`) asks the narrower **"what IS the title?"**, and its -> name-ish set is `name`/`title`/`subject`/`label`/`full_name`/`display_name` -> **without `code`** — an identifier is not a title. So an object whose only -> name-ish field is `code` is R9-clean, yet its title is derived by the -> lower-priority "first title-eligible field by declaration order" tier rather -> than by name. Nothing user-visible turns on this (R9 is `suggestion`, and the -> `Record #` floor guarantees a title regardless), but do not read the R9 -> list as the derivation contract — set `nameField` explicitly when the title -> matters. - -These same rules are the **rubric for AI-generated metadata** — a generation is -"good" exactly when it is schema-valid and lint-clean: - -- `os lint --score` — print a 0–100 metadata-quality score (+ letter - grade and severity breakdown) for the current project. Schema errors and lint - errors weigh most; suggestions barely move it. -- `os lint --eval` — run the generation eval over a bundled golden - corpus (invoice+lines, project+tasks, blog+comments, expense+lines, - account+contacts) offline; each case must clear the pass bar (`--eval-min`, - default 75). Deterministic, no API key. - -When generating object metadata, target a lint-clean model: master_detail (with -`required` + `deleteBehavior` + `inlineEdit` for line items), roll-up summaries -on parents, `select` options, and a name/title field per object. +→ Linting & Generation Quality moved verbatim to [references/lint-rules.md](./references/lint-rules.md). --- diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 2bd51733145..4bbd3337556 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -1,5 +1,11 @@ # Data Lifecycle Hooks — Reference +- [Core Concepts](#core-concepts) · [Hook Lifecycle Events](#hook-lifecycle-events) · [Before vs After Hooks](#before-vs-after-hooks) +- [Hook Definition Schema](#hook-definition-schema) · [Key Properties Explained](#key-properties-explained) +- [Sandboxed Hook Bodies (`body`)](#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call) · [Capabilities — the complete list](#capabilities--the-complete-list) · [Sandbox restrictions](#sandbox-restrictions) +- [Hook Context API](#hook-context-api) · [Common Patterns](#common-patterns) · [Registration Methods](#registration-methods) +- [Best Practices](#best-practices) · [Error Handling](#error-handling) · [Testing Hooks](#testing-hooks) · [References](#references) + Reference companion to `objectstack-data/SKILL.md`, and the catalog's anchor for data hooks. Covers the 8 lifecycle events, the inline `handler` and sandboxed `body` forms, the `HookContext` API, registration, and the canonical patterns diff --git a/skills/objectstack-data/references/examples-objects.md b/skills/objectstack-data/references/examples-objects.md new file mode 100644 index 00000000000..01e553201a8 --- /dev/null +++ b/skills/objectstack-data/references/examples-objects.md @@ -0,0 +1,197 @@ +# Data — object authoring examples (moved verbatim from SKILL.md) + +## Field Groups (MVP) + +Organize fields into logical groups (e.g., "Contact Information", "Billing", +"System") for forms, detail pages, and editors. + +- Declare groups on `ObjectSchema.fieldGroups` — **array order is the display order**. +- Assign each field to a group via `Field.group`, which references an + `ObjectFieldGroup.key`. In-group display order equals the traversal order + of `fields`. +- Group keys must be `snake_case`; group labels are human-readable. +- Optional per-group: `icon`, `description`, and `collapse` + (`'none'` always open · `'expanded'` collapsible, starts open · + `'collapsed'` collapsible, starts closed — replaces the deprecated + `defaultExpanded` flag, ADR-0085). Groups render identically on forms, + modals, and detail pages; for a bespoke single-page layout assign a + custom Page instead. + + +```typescript +import { ObjectSchema } from '@objectstack/spec/data'; + +export default ObjectSchema.create({ + name: 'account', + label: 'Account', + sharingModel: 'private', + + fieldGroups: [ + { key: 'contact_info', label: 'Contact Information', icon: 'user' }, + { key: 'billing', label: 'Billing', collapse: 'collapsed' }, + { key: 'system', label: 'System' }, + ], + + fields: { + name: { type: 'text', required: true, group: 'contact_info' }, + email: { type: 'email', group: 'contact_info' }, + phone: { type: 'phone', group: 'contact_info' }, + vat_id: { type: 'text', group: 'billing' }, + billing_address: { type: 'address', group: 'billing' }, + created_at: { type: 'datetime', readonly: true, group: 'system' }, + created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' }, + }, +}); +``` + +**Supported migrations at this layer:** add / rename / delete / reorder groups +(edit the `fieldGroups` array), assign a field to a group (edit `Field.group`). +Explicit per-field in-group ordering is deferred to a future iteration. + +## Quick-Start Template + + +```typescript +import { ObjectSchema } from '@objectstack/spec/data'; + +export default ObjectSchema.create({ + name: 'support_case', + label: 'Support Case', + pluralLabel: 'Support Cases', + description: 'A customer-reported issue tracked to resolution.', + icon: 'life-buoy', + sharingModel: 'private', // required in practice — see above + highlightFields: ['subject', 'status', 'priority'], + enable: { + trackHistory: true, + feeds: true, + activities: true, + }, + fields: { + subject: { type: 'text', required: true, maxLength: 255 }, + description: { type: 'richtext' }, + status: { type: 'select', required: true, options: [ + { label: 'New', value: 'new', default: true }, + { label: 'Open', value: 'open' }, + { label: 'Escalated', value: 'escalated', color: '#e74c3c' }, + { label: 'Resolved', value: 'resolved', color: '#2ecc71' }, + { label: 'Closed', value: 'closed' }, + ]}, + priority: { type: 'select', options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium', default: true }, + { label: 'High', value: 'high', color: '#e67e22' }, + { label: 'Urgent', value: 'urgent', color: '#e74c3c' }, + ]}, + account: { type: 'lookup', reference: 'account', required: true }, + contact: { type: 'lookup', reference: 'contact' }, + assigned_to: { type: 'lookup', reference: 'user' }, + due_date: { type: 'datetime' }, + }, + validations: [ + { + name: 'status_flow', + type: 'state_machine', + field: 'status', + transitions: { + new: ['open'], + open: ['escalated', 'resolved'], + escalated: ['open', 'resolved'], + resolved: ['open', 'closed'], + closed: [], + }, + message: 'Invalid status transition.', + }, + ], +}); +``` + +Declared `indexes` are a separate decision — see +[Index Strategy](../rules/indexing.md). + +### Lifecycle Hooks + +Implement business logic at data operation lifecycle points: + + +```typescript +import { defineHook, HookContext } from '@objectstack/spec/data'; + +export default defineHook({ + name: 'account_defaults', + object: 'account', + events: ['beforeInsert'], + handler: async (ctx: HookContext) => { + if (!ctx.input.industry) { + ctx.input.industry = 'Other'; + } + ctx.input.created_at = new Date().toISOString(); + }, +}); +``` + +The `handler` above is the inline (in-process) form. The **preferred**, +metadata-native form is a sandboxed `body` — `{ language: 'js', source, capabilities }` +run in an isolated VM, the shape that AI/Studio-authored hooks and every build +artifact carry. See [references/data-hooks.md](./data-hooks.md) for all 8 +lifecycle events, both registration forms, the **sandboxed `body` ctx + capability +contract**, and the canonical patterns. + +## Metadata Protection (`protection`) + +Package authors can lock shipped metadata against Studio edits / overlays / deletes. + +The `protection` block is **declared on the source schema** (`*.object.ts`, +`*.app.ts`, `*.view.ts`, …) and stripped at load time — it never appears in +the runtime envelope. The runtime instead populates `_lock`, `_lockReason`, +`_lockDocsUrl`, `_lockSource`, and `_packageId`, which REST returns to Studio +and the lock banner reads. + +### Schema + +```ts +protection?: { + /** Lock level — controls what Studio can do to this item. */ + lock: 'none' | 'no-overlay' | 'no-delete' | 'full'; + /** 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 | +| `no-overlay` | ❌ | ✅ | Schema is platform-defined but tenant can drop it (e.g. `sys_role`) | +| `no-delete` | ✅ | ❌ | Tenant may customize fields but the object itself must exist | +| `full` | ❌ | ❌ | Core admin UI / platform identity (e.g. `sys_user`, `app/setup`) | + +### Example + + +```typescript +// src/objects/sys-user.object.ts +import { ObjectSchema } from '@objectstack/spec/data'; + +export const SysUserObject = ObjectSchema.create({ + name: 'sys_user', + label: 'User', + sharingModel: 'public_read', + protection: { + lock: 'full', + reason: 'Core identity object', + docsUrl: 'https://objectstack.ai/docs/references/shared/protection', + }, + fields: { username: { type: 'text', required: true } }, +}); +``` + +The same block works on non-object metadata (apps, views, dashboards, flows, +agents, tools, skills, reports, email-templates). Enforcement: `PUT`/`DELETE` on +`/api/v1/meta/:type/:name` return `403 item_locked`, and an artifact lock +overrides a package lock. Default to **no** `protection` block for +tenant-authored metadata. diff --git a/skills/objectstack-data/references/lint-rules.md b/skills/objectstack-data/references/lint-rules.md new file mode 100644 index 00000000000..8115f6f4d86 --- /dev/null +++ b/skills/objectstack-data/references/lint-rules.md @@ -0,0 +1,53 @@ +## Linting & Generation Quality + +`os lint` checks the data model against the conventions in this skill — +not just naming/labels but the relationship/master-detail/roll-up patterns. Run +it after authoring or generating metadata. Severities: `error` (structural, +fails the command), `warning` (likely-wrong choice), `suggestion` (nudge). + +Data-model rules (in addition to naming/label/i18n): + +| Rule | Severity | Catches | +|---|---|---| +| `relationship/missing-reference` | error | lookup/master_detail without a `reference` target | +| `relationship/master-detail-required` | warning | a `master_detail` that isn't `required` (a detail can't exist without its master) | +| `relationship/delete-behavior` | suggestion | `master_detail` without an explicit `deleteBehavior` | +| `relationship/line-items-inline-edit` | suggestion | a `*_line`/`*_item` master_detail child without `inlineEdit` | +| `relationship/line-item-should-be-master-detail` | suggestion | a line-item-shaped child using `lookup` instead of `master_detail` | +| `relationship/association-inline-edit` | warning | an association (comment/audit/activity) marked `inlineEdit` (clutters the parent form — use a detail-page related list) | +| `rollup/missing-summary` | suggestion | a parent of numeric master_detail children with no roll-up `summary` | +| `field/select-missing-options` | warning | a `select`/`multiselect`/`radio` with no `options` (or options source) | +| `object/missing-name-field` | suggestion | an object with no `nameField` (ADR-0079's canonical title pointer) and no name-like field (`name`/`title`/`subject`/`label`/`full_name`/`display_name`/`code`) | +| `security-owd-unset` | error | an object published with no authored `sharingModel` (422 lint envelope; absence is not a decision) | +| `security-owd-alias` | error | a legacy OWD spelling instead of the canonical four (ADR-0090 D4) | +| `security-external-wider-than-internal` | error | `externalSharingModel` wider than `sharingModel` (ADR-0090 D11) | +| `security-master-detail-ungranted` | warning | a `master_detail` child whose master carries no matching grant | + +> **`code` counts for R9, but is NOT a title-derivation key.** R9's name-like +> list above is the *looser* of two "name-like" sets, and the difference is +> deliberate. R9 asks **"will records be anonymous?"** — is there any readable +> face at all — and a `code` clears that bar. ADR-0079's title derivation +> (`resolveDisplayField`) asks the narrower **"what IS the title?"**, and its +> name-ish set is `name`/`title`/`subject`/`label`/`full_name`/`display_name` +> **without `code`** — an identifier is not a title. So an object whose only +> name-ish field is `code` is R9-clean, yet its title is derived by the +> lower-priority "first title-eligible field by declaration order" tier rather +> than by name. Nothing user-visible turns on this (R9 is `suggestion`, and the +> `Record #` floor guarantees a title regardless), but do not read the R9 +> list as the derivation contract — set `nameField` explicitly when the title +> matters. + +These same rules are the **rubric for AI-generated metadata** — a generation is +"good" exactly when it is schema-valid and lint-clean: + +- `os lint --score` — print a 0–100 metadata-quality score (+ letter + grade and severity breakdown) for the current project. Schema errors and lint + errors weigh most; suggestions barely move it. +- `os lint --eval` — run the generation eval over a bundled golden + corpus (invoice+lines, project+tasks, blog+comments, expense+lines, + account+contacts) offline; each case must clear the pass bar (`--eval-min`, + default 75). Deterministic, no API key. + +When generating object metadata, target a lint-clean model: master_detail (with +`required` + `deleteBehavior` + `inlineEdit` for line items), roll-up summaries +on parents, `select` options, and a name/title field per object. diff --git a/skills/objectstack-data/references/seeds.md b/skills/objectstack-data/references/seeds.md new file mode 100644 index 00000000000..a742d868819 --- /dev/null +++ b/skills/objectstack-data/references/seeds.md @@ -0,0 +1,142 @@ +## Seed Data & Fixtures (`defineSeed()`) + +Object definition and seed data live together — a `*.object.ts` usually +pairs with a `*.seed.ts` (fixtures, reference rows, bootstrap data). +`defineSeed()` is type-safe: TypeScript checks every record's field keys +against the object definition. + +> The factory is `defineSeed` — **not** `defineDataset`, which is the +> unrelated ADR-0021 analytics semantic layer (`@objectstack/spec/ui`). + +> ⛔ `sys_organization` is platform-bootstrapped — never a seed target; the +> deployment posture decides how many exist (`rules/security.md` +> § Multi-tenancy). + +### Quick start + +```typescript +// src/data/index.ts +import { defineSeed } from '@objectstack/spec/data'; +import { Status } from '../objects/status.object'; +import { Category } from '../objects/category.object'; + +// Reference data — every environment +export const statusSeed = defineSeed(Status, { + externalId: 'code', + mode: 'upsert', + records: [ + { code: 'active', label: 'Active', color: '#2ecc71' }, + { code: 'inactive', label: 'Inactive', color: '#95a5a6' }, + ], +}); + +// Demo data — dev/test only +export const categorySeed = defineSeed(Category, { + externalId: 'slug', + mode: 'upsert', + env: ['dev', 'test'], + records: [ + { slug: 'electronics', name: 'Electronics' }, + ], +}); + +export const SeedData = [statusSeed, categorySeed]; // parents first +``` + +### `Seed` fields + +| Field | Default | Purpose | +|:------|:--------|:--------| +| `object` | derived | Auto-set from `objectDef.name` — never write manually | +| `externalId` | `'name'` | Stable business key used for upsert / update lookup | +| `mode` | `'upsert'` | Import strategy (see below) | +| `env` | `['prod','dev','test']` | Environments where the seed loads | +| `records` | — | `Partial>[]` | + +Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`. + +### Import modes + +| Mode | Behavior | Use for | +|:-----|:---------|:--------| +| `upsert` (default) | Update by `externalId`, insert if missing. Idempotent. | Reference data, bootstrap rows | +| `insert` | Insert all; fail on duplicate `externalId`. | Append-only / audit tables | +| `update` | Update only existing rows; never create. | Patching existing config | +| `ignore` | Insert; silently skip duplicates. | Additive bootstrap | +| `replace` ⚠️ | Delete everything, then insert. **Data loss.** | Cache / lookup tables only — never user data | + +### `externalId` selection + +Pick a stable natural key. **Never use `id`** — UUIDs differ across +environments. + +| Scenario | Key | +|:---------|:----| +| Named entities (country, currency) | `'code'` / `'slug'` | +| Users / contacts | `'email'` | +| Externally sourced | `'external_id'` | +| Generic | `'name'` (default) | + +### Relationship references + +For `lookup` fields, supply the **natural key** of the target record (not +its UUID). The seed runner resolves at load time. Order seeds so parents +appear before children in the exported array: + +> If a lookup value matches no natural key, the loader falls back to +> resolving it as the target's `id` — so a reference to an existing record +> by internal id resolves instead of dangling to null. Natural keys remain +> the portable default; rely on the id fallback only for records you didn't +> seed (e.g. a system user). + +```typescript +const contacts = defineSeed(Contact, { + externalId: 'email', + records: [{ + email: 'john@acme.example.com', + first_name: 'John', + account: 'Acme Corporation', // natural key of an Account record + }], +}); +``` + +### Dynamic values (CEL) + +Any field value may be a CEL expression evaluated at install time against +a single per-load pinned `now`. This is the **only** correct way to author +time-based or identity-derived seed values — `new Date()` ships the package +author's clock to every customer and breaks build determinism. + +```typescript +import { defineSeed } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; + +defineSeed(Opportunity, { + records: [{ + name: 'Acme Q3 Renewal', + close_date: cel`daysFromNow(45)`, + created_at: cel`now()`, + owner_id: cel`os.user.id`, // installer + organization_id: cel`os.org.id`, + }], +}); +``` + +Stdlib in seed context: `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`, +`isBlank(v)`, `coalesce(v, fallback)`. Scope: `os.user`, `os.org`, `os.env`. +See **objectstack-formula** for the full contract. + +**Determinism gate:** two consecutive `os build` runs with no source +changes must produce byte-identical `dist/objectstack.json`. CEL + pinned +`now` is what guarantees that — using `Date.now()` will fail CI. + +### Seed best practices + +| Practice | Why | +|:---------|:----| +| Always use `defineSeed()`, never `SeedSchema.parse()` | Lose compile-time field checking otherwise | +| Prefer natural keys (`code` / `email` / `slug`) | Portable across environments | +| Default to `upsert` | Idempotent re-runs | +| Scope demo data with `env: ['dev','test']` | Keep noise out of prod | +| Order seeds parent → child in the exported array | References resolve at load time | +| Use `replace` only on cache/lookup tables, with comments | Data-loss footgun | diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 09a92c85203..c419cdd5a2a 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -1,5 +1,11 @@ # Field Types Reference +- [Two spellings: `Field.*` factory or object literal](#two-spellings-field-factory-or-object-literal) +- [Text & Content](#text--content) · [Numbers](#numbers) · [Date & Time](#date--time) · [Logic](#logic) · [Selection](#selection) +- [Relational](#relational) · [Media](#media) · [Embedded](#embedded-json-sub-objects) · [Calculated](#calculated) · [Enhanced Types](#enhanced-types) +- [Common Field Configurations](#common-field-configurations) +- [Incorrect vs Correct](#incorrect-vs-correct) + Quick reference for choosing the right field type from 49 available options. ## Two spellings: `Field.*` factory or object literal diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index e241b2d108a..6463682e055 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -1,5 +1,11 @@ # Relationship Patterns +- [Relationship Types](#relationship-types) · [When to Use lookup vs master_detail](#when-to-use-lookup-vs-master_detail) +- [Patterns](#patterns) — [lookup](#one-to-many-lookup) · [master_detail](#one-to-many-master_detail) · [Junction Object](#many-to-many-junction-object) · [tree](#hierarchical-tree-self-reference) +- [Delete Behaviors](#delete-behaviors) · [Roll-up Summaries](#roll-up-summaries) +- [Inline Editing (Master-Detail Entry)](#inline-editing-master-detail-entry) · [Detail-page related lists](#detail-page-related-lists-the-read-side-mirror) +- [Incorrect vs Correct](#incorrect-vs-correct) + Guide for modeling relationships between objects using `lookup`, `master_detail`, and junction patterns. ## Relationship Types diff --git a/skills/objectstack-data/rules/validation.md b/skills/objectstack-data/rules/validation.md index d056f567118..977bb6cb91f 100644 --- a/skills/objectstack-data/rules/validation.md +++ b/skills/objectstack-data/rules/validation.md @@ -1,5 +1,11 @@ # Validation Rules +- [Available Rule Types](#available-rule-types) · [Expression Syntax](#expression-syntax) · [Script Validation](#script-validation) +- [Uniqueness — Use Unique Indexes](#uniqueness--use-unique-indexes) · [State Machine Validation](#state-machine-validation) +- [Format](#format-validation) · [Cross-Field](#cross-field-validation) · [JSON Schema](#json-schema-validation) · [Conditional](#conditional-validation) +- [External / Custom Validation → Lifecycle Hooks](#external--custom-validation--lifecycle-hooks) · [Validation Properties](#validation-properties) +- [Incorrect vs Correct](#incorrect-vs-correct) + Comprehensive guide for implementing validation rules in ObjectStack. ## Available Rule Types diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index f77d8d077fb..80c7d4cc45d 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -36,36 +36,7 @@ development** (plugins, services, kernel hook / event handlers, `objectstack.config.ts` is the single entry point for every project. It calls `defineStack()` to declare all metadata. -### Minimal Example - - -```typescript -import { defineStack } from '@objectstack/spec'; -import { Field } from '@objectstack/spec/data'; - -export default defineStack({ - manifest: { - id: 'com.example.todo', - version: '1.0.0', - type: 'app', - name: 'Todo Manager', - }, - objects: [ - { - name: 'task', - label: 'Task', - fields: { - title: Field.text({ required: true }), - status: Field.select({ options: [ - { label: 'Open', value: 'open' }, - { label: 'Done', value: 'done' }, - ], defaultValue: 'open' }), - due_date: Field.date(), - }, - }, - ], -}); -``` +→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Minimal Example. ### Full Configuration Reference @@ -113,48 +84,7 @@ the collection items take — read `ObjectStackDefinitionInput`). Each collection's item shape lives in its own domain folder (`data/object.zod.ts`, `ui/view.zod.ts`, …). -### Map Format (Key → Name) - -All named collections support **map format** where the key becomes the `name` field: - -```typescript -export default defineStack({ - // Array format (traditional) - objects: [ - { name: 'task', fields: { title: Field.text() } }, - ], - - // Map format (key becomes name) — preferred for readability - objects: { - task: { fields: { title: Field.text() } }, - project: { fields: { name: Field.text() } }, - }, -}); -``` - -### Barrel Import Pattern - -Use barrel exports to keep config clean: - -```typescript -// src/objects/index.ts -export { default as task } from './task.object'; -export { default as project } from './project.object'; - -// objectstack.config.ts -import * as objects from './src/objects'; -import * as apps from './src/apps'; -import * as views from './src/views'; -import * as flows from './src/flows'; - -export default defineStack({ - manifest: { id: 'com.example.pm', namespace: 'pm', version: '1.0.0', type: 'app', name: 'PM' }, - objects: Object.values(objects), - apps: Object.values(apps), - views: Object.values(views), - flows: Object.values(flows), -}); -``` +→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Map Format (Key → Name) · § Barrel Import Pattern. ### Strict Validation @@ -230,7 +160,7 @@ deliberately move to a new protocol major — never to silence a mismatch. An ObjectStack app is a **simplified implementation of business features**: author metadata under the platform's spec, guided by these skills, and check it -with the `os` commands ([Verify your work](#verify-your-work)). Never rebuild +with the `os` commands ([Verify your work](./references/operations.md#verify-your-work)). Never rebuild what the platform owns. - **Business features belong in the app; capability belongs in the platform.** @@ -269,15 +199,7 @@ The five remote content templates (`todo`, `compliance`, `content`, no longer maintained. Do not recommend them; asking for one by name is refused. Build domain metadata on top of `blank` instead. -### Scaffolding Command - -```bash -# Interactive — prompts for a name -npx create-objectstack - -# Direct — skip prompts (blank is the default, and the only, template) -npx create-objectstack my-app -``` +→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Scaffolding Command. --- @@ -430,7 +352,7 @@ CLI: `os serve` / `os dev` **Port resolution** (both `os dev` and `os start` → `os serve`): `--port` flag › `$OS_PORT` › `$PORT` › `3000`. On a conflict the behaviour is mode-dependent — dev hops to the next free port, production fails loudly. See -[Ports & networking](#ports--networking). +[Ports & networking](./references/operations.md#ports--networking). ### `requires:` — which service plugins boot @@ -495,51 +417,11 @@ export const onEnable = async (ctx: { ql: { registerAction: (...a: unknown[]) => }; ``` -### Plugin Loading Order Matters - -Plugins initialize in registration order. Key dependencies: - -| Plugin | Depends On | Reason | -|:-------|:-----------|:-------| -| ObjectQLPlugin | (none) | Core data engine, should load first | -| DriverPlugin | (none) | Registers driver service | -| AppPlugin | ObjectQLPlugin | Registers objects/metadata with engine | -| AuthPlugin | ObjectQLPlugin | Needs user/session objects | -| RESTPlugin | ObjectQLPlugin, AppPlugin | Generates routes from registered objects | -| AIServicePlugin | ObjectQLPlugin, AppPlugin | Needs metadata for tool generation. **Cloud / EE only** — `@objectstack/service-ai` moved to cloud (cloud ADR-0025); the open edition has no in-UI AI plugin and uses `@objectstack/mcp` (BYO-AI) | - -### Programmatic Bootstrap (Without CLI) - -```typescript -import { Runtime, DriverPlugin, AppPlugin } from '@objectstack/runtime'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; -import appConfig from './objectstack.config'; - -const runtime = new Runtime(); -runtime.use(new ObjectQLPlugin()); -runtime.use(new DriverPlugin(new InMemoryDriver())); -runtime.use(new AppPlugin(appConfig)); -await runtime.start(); - -const kernel = runtime.getKernel(); -// kernel is now ready — use it with an adapter -``` +→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Plugin Loading Order Matters · § Programmatic Bootstrap (Without CLI). --- -## Multi-App Composition - -Host several apps in one runtime by registering an `AppPlugin` per app — this is -how real multi-app composition happens (`packages/cli/src/commands/serve.ts`). -Each app contributes its objects under their canonical `name`; names are -globally unique and equal the physical table name, so use them directly in -queries, hooks, formulas, and REST URLs. - -A merge-at-authoring-time alternative, `composeStacks()`, exists in -`@objectstack/spec` (`stack.zod.ts`) with `objectConflict` / -`manifest` strategies. No app in this repo uses it — read the schema before -reaching for it. +→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Multi-App Composition. --- @@ -566,7 +448,7 @@ definition: never write it by hand, and never hand-write a raw ## CLI Commands Daily commands are covered in **Part 3 — Operations** below -([jump there](#part-3--operations-cli-testing-deployment)). High-level cheat +([jump there](./references/operations.md#part-3--operations-cli-testing-deployment)). High-level cheat sheet for the bootstrap loop: ```bash @@ -582,635 +464,17 @@ PORT=8080 os start # production — pin the port explicitly (see Ports & netwo --- -## Complete Working Example - -A minimal but complete project from scratch: - -**`package.json`**: -```json -{ - "name": "my-todo-app", - "type": "module", - "scripts": { - "dev": "objectstack dev", - "start": "objectstack start", - "build": "objectstack build", - "validate": "objectstack validate" - }, - "dependencies": { - "@objectstack/spec": "^17.0.0", - "@objectstack/runtime": "^17.0.0", - "@objectstack/driver-memory": "^17.0.0", - "@objectstack/plugin-hono-server": "^17.0.0" - }, - "devDependencies": { - "@objectstack/cli": "^17.0.0", - "typescript": "^5.3.0" - } -} -``` - -**`src/objects/task.object.ts`** — one `ObjectSchema.create({ … })` call. Field -types, `indexes:` and the rest of the object surface are **objectstack-data**'s; -the scaffolder's own `note.object.ts` is the shape to copy. - -**`src/objects/index.ts`**: -```typescript -export { default as task } from './task.object'; -``` - -**`objectstack.config.ts`**: -```typescript -import { defineStack } from '@objectstack/spec'; -import * as objects from './src/objects'; - -export default defineStack({ - manifest: { - id: 'com.example.todo', - version: '1.0.0', - type: 'app', - name: 'Todo Manager', - }, - objects: Object.values(objects), -}); -``` - -```bash -# Run it -os dev --ui -# → Server at http://localhost:3000 (default port; dev auto-hops if taken) -# → REST API at http://localhost:3000/api -# → Console at http://localhost:3000/_console/ -``` - ---- ---- - -# Part 2 — Plugin Development & Kernel Extension - - -## Quick Reference — Detailed Rules - -For comprehensive documentation with incorrect/correct examples: - -- **[Plugin Lifecycle](./rules/plugin-lifecycle.md)** — 3-phase lifecycle (init/start/destroy), execution order, complete examples -- **[Service Registry](./rules/service-registry.md)** — DI container, factories, lifecycles (singleton/transient/scoped), core fallbacks -- **[Hooks & Events](./references/plugin-hooks.md)** — Kernel hooks & events reference (record-level lifecycle hooks → [objectstack-data](../objectstack-data/SKILL.md)) - ---- - -## ObjectKernel vs LiteKernel - -| Feature | ObjectKernel | LiteKernel | -|:--------|:-------------|:-----------| -| **Use case** | Production servers, full applications | Serverless, edge, unit tests | -| **Package** | `@objectstack/core` | `@objectstack/core` | -| **Plugin loading** | Async with validation & metadata | Synchronous `use()` | -| **Service factories** | Singleton / Transient / Scoped | Direct instances only | -| **Health monitoring** | Built-in per-plugin health checks | Not available | -| **Graceful shutdown** | Timeout + rollback on failure | Basic destroy phase | -| **Dependency resolution** | Topological sort + circular detection (throws) | Topological sort (throws on cycles) | -| **Core fallbacks** | Auto-injects in-memory fallbacks | Not available | -| **Config validation** | Zod schema validation per plugin | Not available | - -### A third answer: no kernel at all - -If the host only needs the **data engine** — query / CRUD / hooks / validation — -neither kernel is the answer. Import `ObjectQL` from -`@objectstack/objectql/core` (ADR-0076): no kernel, no `ObjectQLPlugin`, no -metadata-management layer, and the *same* `ObjectSchema.create({ … })` -definitions a full backend ships. `examples/embed-objectql` is the worked -example; it is the right shape for a thin, latency-sensitive host such as a -gateway. - -### ObjectKernel Configuration - -```typescript -import { ObjectKernel } from '@objectstack/core'; - -const kernel = new ObjectKernel({ - logger: { - level: 'info', // debug|info|warn|error|fatal|silent - format: 'json', // 'json' | 'text' | 'pretty' - }, - defaultStartupTimeout: 30000, // Per plugin (ms) - gracefulShutdown: true, // Register SIGINT/SIGTERM handlers - shutdownTimeout: 60000, // Total shutdown timeout (ms) - rollbackOnFailure: true, // Rollback all plugins if one fails - skipSystemValidation: false, // Skip system checks (useful for tests) -}); -``` - -### LiteKernel Configuration - -```typescript -import { LiteKernel } from '@objectstack/core'; - -const kernel = new LiteKernel({ - logger: { level: 'warn' }, -}); -``` - ---- - -## Plugin Interface — Quick Overview - -```typescript -import type { Plugin, PluginContext } from '@objectstack/core'; - -export interface Plugin { - name: string; // Unique identifier (reverse domain recommended) - version?: string; // Semantic version - type?: PluginType; // closed set exported by @objectstack/core - dependencies?: string[]; // Plugins that must init before this one - - // Phase 1: Register services - init(ctx: PluginContext): Promise | void; - - // Phase 2: Execute business logic (optional) - start?(ctx: PluginContext): Promise | void; - - // Phase 3: Cleanup (optional) - destroy?(): Promise | void; -} -``` - -See [rules/plugin-lifecycle.md](./rules/plugin-lifecycle.md) for complete examples. - ---- - -## PluginContext API - -### Service Registry - -```typescript -// Register a service (in init phase) -ctx.registerService('my-service', myServiceInstance); - -// Get a service (in start phase) -const db = ctx.getService('objectql'); - -// Replace a service -ctx.replaceService('cache', new InstrumentedCache(existingCache)); - -// Get all services -const allServices: Map = ctx.getServices(); -``` - -See [rules/service-registry.md](./rules/service-registry.md) for factories and lifecycles. - -### Hook / Event System - -```typescript -// Register a kernel hook handler -ctx.hook('kernel:ready', async () => { - ctx.logger.info('System is ready!'); -}); - -// React to a metadata hot-reload / publish -ctx.hook('metadata:reloaded', async (payload?: { changed?: string[] }) => { - ctx.logger.info('Metadata reloaded', { changed: payload?.changed }); -}); - -// Trigger a custom hook -await ctx.trigger('my-plugin:initialized', { version: '1.0.0' }); -``` - -Built-in kernel events: `kernel:ready`, `kernel:bootstrapped`, -`kernel:listening`, `kernel:shutdown`, `app:seeded`, `metadata:reloaded`, -`external.schema.drift`. - -> **⚠️ There are no `data:*` kernel hooks.** Record-level lifecycle logic -> (beforeInsert / afterUpdate / …) runs on the **ObjectQL engine**, not the -> kernel event bus — author it via the `hooks:` collection or -> `ql.on('beforeInsert', 'task', async (ctx) => { … })` (see -> **objectstack-data**). Because `ctx.hook()` accepts any string, a handler -> registered for `'data:beforeInsert'` will register successfully and then -> **silently never fire**. Kernel hooks are for platform lifecycle only. - -See [references/plugin-hooks.md](./references/plugin-hooks.md) for the kernel event list, payloads, and patterns. - -### Logger - -```typescript -ctx.logger.debug('Detailed trace info', { key: 'value' }); -ctx.logger.info('Plugin initialized'); -ctx.logger.warn('Cache miss rate high', { rate: 0.45 }); -ctx.logger.error('Connection failed', error); -``` - -### Kernel Access - -```typescript -const kernel = ctx.getKernel(); -const isRunning = kernel.isRunning(); -const state = kernel.getState(); // 'idle' | 'initializing' | 'running' | 'stopping' | 'stopped' -``` - ---- - -## Complete Plugin Example - -```typescript -// src/plugins/audit.ts -import type { Plugin, PluginContext } from '@objectstack/core'; - -interface AuditEntry { - timestamp: string; - event: string; - detail?: Record; -} - -class AuditService { - private log: AuditEntry[] = []; - - record(event: string, detail?: Record) { - this.log.push({ timestamp: new Date().toISOString(), event, detail }); - } - - getLog(): AuditEntry[] { - return [...this.log]; - } -} - -const AuditPlugin: Plugin = { - name: 'com.example.audit', - version: '1.0.0', - type: 'plugin', - - async init(ctx: PluginContext) { - // Phase 1: Register service and kernel hooks - const auditService = new AuditService(); - ctx.registerService('audit', auditService); - - ctx.hook('kernel:ready', async () => { - auditService.record('kernel:ready'); - }); - - ctx.hook('metadata:reloaded', async (payload?: { changed?: string[] }) => { - auditService.record('metadata:reloaded', { changed: payload?.changed }); - }); - - ctx.logger.info('Audit plugin initialized'); - }, - - async start(ctx: PluginContext) { - // Phase 2: Log that audit is active - ctx.logger.info('Audit logging active'); - }, - - async destroy() { - // Phase 3: Cleanup - }, -}; - -export default AuditPlugin; -``` +→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Complete Working Example. --- - -## Using Plugins - -```typescript -import { ObjectKernel } from '@objectstack/core'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { DriverPlugin } from '@objectstack/runtime'; -import { InMemoryDriver } from '@objectstack/driver-memory'; -import AuditPlugin from './plugins/audit'; - -const kernel = new ObjectKernel(); -await kernel.use(new ObjectQLPlugin()); -await kernel.use(new DriverPlugin(new InMemoryDriver())); -await kernel.use(AuditPlugin); -await kernel.bootstrap(); - -// Services are now available -const audit = kernel.getService('audit'); -``` - --- -## Testing Plugins - -```typescript -import { describe, it, expect } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; -import type { PluginContext } from '@objectstack/core'; -import AuditPlugin from './audit'; - -describe('AuditPlugin', () => { - it('records kernel lifecycle events', async () => { - const kernel = new LiteKernel({ logger: { level: 'silent' } }); - kernel.use(AuditPlugin); - - // `kernel.context` is protected — to fire events in a test, capture a - // PluginContext from a probe plugin instead. - let probe!: PluginContext; - kernel.use({ name: 'test.probe', init(ctx) { probe = ctx; } }); - - await kernel.bootstrap(); // fires kernel:ready → recorded - - // Simulate a metadata hot-reload announcement - await probe.trigger('metadata:reloaded', { changed: ['object/task'] }); - - const audit = kernel.getService<{ getLog(): { event: string }[] }>('audit'); - const events = audit.getLog().map((e) => e.event); - expect(events).toContain('kernel:ready'); - expect(events).toContain('metadata:reloaded'); - - await kernel.shutdown(); - }); -}); -``` +→ Part 2 — Plugin Development & Kernel Extension moved verbatim to [references/plugin-development.md](./references/plugin-development.md) (Quick Reference — Detailed Rules · ObjectKernel vs LiteKernel · Plugin Interface — Quick Overview · PluginContext API · Complete Plugin Example · Using Plugins · Testing Plugins · Well-Known Plugin Names & Services · MetadataPlugin Runtime Boundary · Health Monitoring · Feature Flags); its detailed rules stay at [rules/plugin-lifecycle.md](./rules/plugin-lifecycle.md) · [rules/service-registry.md](./rules/service-registry.md) · [references/plugin-hooks.md](./references/plugin-hooks.md). --- - -## Well-Known Plugin Names & Services - -| Plugin Name | Service Key | Package | -|:------------|:------------|:--------| -| `com.objectstack.engine.objectql` | `objectql` (also `data`) | `@objectstack/objectql` | -| `com.objectstack.driver.*` | `driver.{name}` | `@objectstack/driver-*` | -| `com.objectstack.auth` | `auth` | `@objectstack/plugin-auth` | -| `com.objectstack.rest.api` | — (registers no service) | `@objectstack/rest` | -| `com.objectstack.metadata` | `metadata` | `@objectstack/metadata` | -| `com.objectstack.service.realtime` | `realtime` | `@objectstack/service-realtime` | -| `com.objectstack.service.cache` | `cache` | `@objectstack/service-cache` | -| `com.objectstack.server.hono` | — | `@objectstack/plugin-hono-server` → `HonoServerPlugin` | -| `com.objectstack.setup` | — | `@objectstack/setup` → `createSetupAppPlugin` (ADR-0048 one-app pkg) | -| `com.objectstack.studio` | — | `@objectstack/studio` → `createStudioAppPlugin` | -| `com.objectstack.account` | — | `@objectstack/account` → `createAccountAppPlugin` | -| `com.objectstack.cloud.connection` | — | `@objectstack/cloud-connection` → `createCloudConnectionPlugin` | - --- -## MetadataPlugin Runtime Boundary - -`MetadataPlugin` is the `IMetadataService` provider for the ObjectStack runtime, but runtime -metadata is read-only and artifact/file backed: - -- Do **not** register `sys_metadata` or `sys_metadata_history` from an ObjectStack - runtime plugin. Those persistence tables belong to the control plane. - (Exception: an *isolated environment kernel* may opt into `sys_metadata` - hydration from its own DB — the general boundary otherwise stands.) -- Do **not** call `MetadataManager.setDataEngine()` automatically from - `MetadataPlugin.start()`. Project databases must contain business rows only. -- Use `artifactSource: { mode: 'local-file', path: './dist/objectstack.json' }` - for local artifact boot; production should use the Artifact API loader once - wired. -- `DatabaseLoader`, `setDatabaseDriver()`, and `setDataEngine()` remain valid for - control-plane services that explicitly own metadata revisions, history, or - overlays. - -```typescript -import { MetadataPlugin } from '@objectstack/metadata'; - -await kernel.use(new MetadataPlugin({ - watch: false, - artifactSource: { mode: 'local-file', path: './dist/objectstack.json' }, -})); -``` - ---- - -## Health Monitoring (ObjectKernel Only) - -A plugin opts in by adding an `async healthCheck()` returning -`{ healthy: boolean; message?: string; details?: Record }` -(`PluginHealthStatus`, importable from `@objectstack/core`). Return `healthy: -false` rather than throwing. The kernel side is three calls: - -```typescript -const health = await kernel.checkPluginHealth('com.example.db'); -const allHealth = await kernel.checkAllPluginsHealth(); -const metrics = kernel.getPluginMetrics(); // Map -``` - ---- - -## Feature Flags - -Feature flags are **not a spec/metadata concept**. There is no `featureFlags:` / -`features:` key on `defineStack` (writing one is refused at load, not stripped), and the -former `FeatureFlagSchema` (`@objectstack/spec/kernel`) was removed — it had zero runtime -consumers, and its only protocol home (the static `ObjectStackCapabilities.system.features` -descriptor) was itself dead: no endpoint ever served it. Runtime capability discovery is -`GET /api/v1/discovery`. - -The live toggle surfaces are runtime configuration, not authored metadata: - -- **`feature_flags` settings manifest** (`@objectstack/service-settings`) — org-tunable - toggles like `ai_enabled` / `beta_*`, resolvable at runtime and env-overridable via - `OS_FEATURE_FLAGS_*` (ADR-0007 settings cascade). -- **Auth capability gates** — `requiresFeature` on actions/params lowers to the - `PUBLIC_AUTH_FEATURES` registry (`kernel/public-auth-features.ts`), the fixed - deployment-level flags plugin-auth advertises to anonymous clients. - ---- ---- - -# Part 3 — Operations: CLI, Testing, Deployment - -Every project gets the same `os` command surface — `npm install` does not need -to be re-run when commands are added. - -## Daily-loop commands - -| Command | What it does | -|:--------|:-------------| -| `os init` | Scaffold a new project (alternative to `npx create-objectstack`) | -| `os dev` | Start the dev server with hot metadata reload. `--seed-admin` (default **on** for plain `os dev`) seeds a loginable dev admin **in-process via the runtime** (env vars `OS_SEED_ADMIN*`) on an **empty** DB only — idempotent, never overwrites an existing account (default `admin@objectos.ai` / `admin123`; override with `--admin-email` / `--admin-password`; disable with `--no-seed-admin`). `--fresh` = ephemeral clean OS_HOME/DB, implies `--seed-admin`. The seeded admin is promoted to **platform admin**, so Setup/Studio work on first login. | -| `os dev --ui` | Also mount the bundled Console portal at `/_console/` (there is no separate `os studio` command) | -| `os validate` | Validate `objectstack.config.ts` — Zod protocol schema, CEL/predicate validation (`record.` existence), and widget-binding integrity. Same gates as `os build`, no artifact emitted. See [Verify your work](#verify-your-work). | -| `os lint` | Style/convention lint on metadata files | -| `os info` | Print a metadata summary of the config (objects, apps, and other collections; `--json`) | -| `os doctor` | Diagnose common setup issues | - -## Build & runtime - -| Command | What it does | -|:--------|:-------------| -| `os build` | Compile TS metadata, bundle, and produce `dist/` | -| `os compile` | Compile to portable JSON artifact (for runtime hydration) | -| `os serve` | Serve a compiled stack in production mode | -| `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 | -| `os generate ` | Scaffold an object / view / flow / agent from a template | - -## Verify your work - -ObjectStack metadata mistakes fail **silently at runtime**, not at edit time: -a bare field ref in a predicate (`done` instead of `record.done`) evaluates to -`null` and silently hides an action/validation on every record; a -dangling dashboard widget binding renders an empty chart (ADR-0021). Both are -caught at author time by one command: - -```bash -os validate # Zod schema + CEL predicates + widget bindings — no artifact -# or -os build # the same three gates, plus emits dist/objectstack.json -``` - -`os validate` and `os build` run the **same** structural + semantic gates: - -1. **Zod protocol schema** — the stack conforms to `@objectstack/spec`. -2. **CEL / predicate validation (ADR-0032)** — every `visible` / `disabled` / - `requiredWhen` / validation rule / flow condition / sharing rule is parsed - for CEL syntax *and* checked that each `record.` reference exists on - the target object. A bare `field` (missing `record.`) fails here. -3. **Widget-binding integrity (ADR-0021)** — every dashboard widget's - `dataset` / `dimensions` / `values` resolves to a declared dataset/field. - -Both exit non-zero with a located, corrective message; `os build` additionally -emits the artifact. Use `os validate` as the fast inner-loop check after editing -metadata and `os build` when you need `dist/`. In a scaffolded project these are -`npm run validate` / `npm run build`. - -**Rule of thumb: never report a metadata change as done until `os validate` -passes.** (`os lint` is a *separate* style/convention pass — naming, labels, -namespace prefixes — and does not replace `os validate`.) - -## Ports & networking - -Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): - -``` ---port › $OS_PORT › $PORT › 3000 (default) -``` - -**Conflict behaviour is mode-dependent — this is deliberate:** - -| Mode | If the resolved port is busy | -|:-----|:-----------------------------| -| **Dev** (`os dev`, or `NODE_ENV=development`) | Auto-hops to the next free port (up to +100) so several example apps run side-by-side. The startup banner shows the *actual* bound port. | -| **Production** (`os start`) | **Fails loudly and exits 1.** It never silently drifts — a shifted port breaks reverse-proxy upstreams, better-auth callback URLs, and CORS trusted-origins as opaque 403/502s. | - -**Production guidance:** - -- **Pin the port explicitly** — `PORT=8080 os start` (or `--port 8080`). Don't - rely on the `3000` default; it collides easily on shared hosts. -- **Keep these in sync when you change the port** (mismatch ⇒ better-auth - `Invalid origin` 403 / CORS failures): - - reverse-proxy upstream (`nginx`/`caddy`) - - `OS_AUTH_URL` / better-auth `baseURL` + `callbackURL` - - `OS_TRUSTED_ORIGINS` (CORS allow-list) - - the app's `hostname` -- **Recommended topology:** terminate TLS on a reverse proxy (`:443`) and let - the app listen on an internal high port (e.g. `8080`) fixed via `PORT`. - -## Data & migrations - -| Command | What it does | -|:--------|:-------------| -| `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) | -| `os diff` | Compare two ObjectStack config files and detect breaking changes | -| `os meta register` / `os meta resync` | Register (create/update) metadata on a target server / re-sync it (there is no `os meta apply`) | -| `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) | - -## Environments & deploy - -| Command | What it does | -|:--------|:-------------| -| `os login` / `logout` / `whoami` | Auth against the ObjectStack cloud control plane | -| `os environments list` / `create` / `switch` | Manage cloud environments (prod/staging/dev) | -| `os register` | Register the local stack as a deployable target | -| `os cloud login` / `logout` / `whoami` | Cloud auth subcommands (these three only — there are no `os cloud logs/metrics/status`) | -| `os package publish [dist/objectstack.json] [--env … --install --visibility org]` | Upload the compiled artifact as a versioned package to the cloud catalog (ADR-0008 P3) | -| `os package install [--version │ --runtime http://localhost:3000]` | Install a package into a **running** runtime via its install-local endpoint. Catalog mode (by manifest id) or air-gapped local-artifact mode. Auths with the **target runtime's** session (`--email/--password` or `OS_RUNTIME_EMAIL`/`OS_RUNTIME_PASSWORD`), not the cloud login | - -> **Cloud connection & marketplace (`@objectstack/cloud-connection`, ADR-0008/0009).** -> The open runtime-side cloud client. Its plugins — -> `CloudConnectionPlugin`/`createCloudConnectionPlugin`, `MarketplaceProxyPlugin`, -> `MarketplaceInstallLocalPlugin`, `RuntimeConfigPlugin` — expose the install-local -> endpoint that `os package install` targets, ship the **Installed Apps** page and -> marketplace Setup nav as plugin metadata, and maintain `LocalManifestSource` -> (a local desired-state ledger) plus runtime-identity bind v2 (environment-less -> self-hosted binding). - -## Shipping a plugin - -Authoring stops at `kernel.use(plugin)`; these three make it distributable. -`manifest.type: 'plugin'` is what marks the package as one. - -| Command | What it does | -|:--|:--| -| `os plugin build [--out dist/x.osplugin]` | Bundle from `objectstack.plugin.json` into a reproducible ustar+gzip `ID-VERSION.osplugin` with per-file `sha256-` integrity | -| `os plugin sign` | Sign the built artifact | -| `os plugin publish` | Upload it to the catalog | - -## Testing pattern - -Use `LiteKernel` for unit / integration tests — it skips the cloud bits and -plugin discovery, so tests run in milliseconds. Assemble the same plugins the -CLI would auto-register (`use()` is synchronous and chainable): - -```typescript -import { describe, it, expect } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { DriverPlugin, AppPlugin } from '@objectstack/runtime'; -import { InMemoryDriver } from '@objectstack/driver-memory'; -import stack from '../objectstack.config'; - -describe('stack boot', () => { - it('registers the task object', async () => { - const kernel = new LiteKernel({ logger: { level: 'silent' } }); - kernel - .use(new ObjectQLPlugin()) - .use(new DriverPlugin(new InMemoryDriver())) - .use(new AppPlugin(stack)); - await kernel.bootstrap(); - - const ql = kernel.getService('objectql'); - // query / mutate through the engine — see objectstack-query for the API - expect(ql).toBeDefined(); - - await kernel.shutdown(); - }); -}); -``` - -- **Seed in tests:** declare fixtures in the stack's `data:` collection — - they load at boot. See **objectstack-data** for env-scoped fixtures - (`env: ['test']`). -- **Reset between tests:** create a fresh `LiteKernel` per test — with the - in-memory driver a full bootstrap is cheap, and there is no `reset()`. -- **HTTP-level tests:** mount `createHonoApp({ kernel })` from - `@objectstack/hono` and drive it with `app.request(...)` / `fetch`. - -## Deployment targets - -| Target | Driver | HTTP layer | Notes | -|:-------|:-------|:-----------|:------| -| Node.js server | `driver-sql` (`pg` / `mysql` / `better-sqlite3`) | `plugin-hono-server` / `@objectstack/hono` | Default — works anywhere Node runs | -| Edge (Cloudflare Workers, Vercel Edge) | `driver-turso` (**cloud / EE only**) | `@objectstack/hono` | Cold-start friendly; LiteKernel only | -| Serverless (Lambda, Vercel functions) | `driver-sql` (`pg` with pooler) / `driver-mongodb` | `@objectstack/hono` | Mind cold-start: prefer LiteKernel | -| Browser / WebContainer | `driver-sqlite-wasm` | none (in-process) | Playground, demos | -| Docker / Kubernetes | any | any | Use `os start` as the entrypoint; pin `PORT` and `EXPOSE` it (see [Ports & networking](#ports--networking)) | - -## Health & observability - -- **Health endpoints:** the HTTP dispatcher exposes `GET /health` and - `GET /ready` under the API prefix. -- **Logs:** plugins log via `ctx.logger`. Logger config is a **kernel - construction** option, not a `defineStack` key: - `new ObjectKernel({ logger: { level: 'info', format: 'json' } })`. -- **Metrics:** use the kernel's built-ins — `kernel.getPluginMetrics()` - (per-plugin startup durations) and `await kernel.checkAllPluginsHealth()`. - There is no `metrics` service and no `@objectstack/plugin-prometheus`. - -## Common ops pitfalls - -| Symptom | Likely cause | -|:--------|:-------------| -| `os dev` hangs at "Loading metadata…" | Circular import in `objectstack.config.ts` — run `os validate` | -| `os start` exits with "Port N is already in use" | Intended: production never auto-shifts ports. Free the port or set `PORT=` — see [Ports & networking](#ports--networking) | -| better-auth `Invalid origin` 403 after a port/host change | Port or hostname out of sync with `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` — see [Ports & networking](#ports--networking) | -| Migrations apply locally but not in cloud | `env` scoping on the dataset excludes the target environment | -| Adapter 404s on auto-generated routes | `enable.apiEnabled: false` on the object, or missing `os build` | -| LiteKernel test passes, ObjectKernel boot fails | Test missed a plugin the CLI auto-registers — compare your test's `use()` list against the `os dev` boot log | -| Hot reload misses new objects | Barrel `src/objects/index.ts` not re-exporting — check the file | -| Login works but **Setup / Studio missing** | The logged-in user isn't a platform admin. Setup/Studio are gated by `setup.access` / `studio.access` on `admin_full_access`, auto-granted only to the first registered **human** (`bootstrapPlatformAdmin`). The `usr_system` seed identity is skipped, so it can't steal the grant. Either sign up first (`--seed-admin`/`--fresh` does this) or check `sys_user_permission_set` for a cross-tenant (`organization_id = NULL`) `admin_full_access` link on your user. Don't edit nav code first. | -| A permission set is declared but grants nobody anything | Declaring a set is not assigning it — see "Assigning a permission set to a user" in **objectstack-data**. | +→ Part 3 — Operations: CLI, Testing, Deployment moved verbatim to [references/operations.md](./references/operations.md) (Daily-loop commands · Build & runtime · Verify your work · Ports & networking · Data & migrations · Environments & deploy · Shipping a plugin · Testing pattern · Deployment targets · Health & observability · Common ops pitfalls). --- diff --git a/skills/objectstack-platform/references/bootstrap.md b/skills/objectstack-platform/references/bootstrap.md new file mode 100644 index 00000000000..e2465dba3dd --- /dev/null +++ b/skills/objectstack-platform/references/bootstrap.md @@ -0,0 +1,190 @@ +# Platform — bootstrap examples and composition notes (moved verbatim from SKILL.md) + +### Minimal Example + + +```typescript +import { defineStack } from '@objectstack/spec'; +import { Field } from '@objectstack/spec/data'; + +export default defineStack({ + manifest: { + id: 'com.example.todo', + version: '1.0.0', + type: 'app', + name: 'Todo Manager', + }, + objects: [ + { + name: 'task', + label: 'Task', + fields: { + title: Field.text({ required: true }), + status: Field.select({ options: [ + { label: 'Open', value: 'open' }, + { label: 'Done', value: 'done' }, + ], defaultValue: 'open' }), + due_date: Field.date(), + }, + }, + ], +}); +``` + +### Map Format (Key → Name) + +All named collections support **map format** where the key becomes the `name` field: + +```typescript +export default defineStack({ + // Array format (traditional) + objects: [ + { name: 'task', fields: { title: Field.text() } }, + ], + + // Map format (key becomes name) — preferred for readability + objects: { + task: { fields: { title: Field.text() } }, + project: { fields: { name: Field.text() } }, + }, +}); +``` + +### Barrel Import Pattern + +Use barrel exports to keep config clean: + +```typescript +// src/objects/index.ts +export { default as task } from './task.object'; +export { default as project } from './project.object'; + +// objectstack.config.ts +import * as objects from './src/objects'; +import * as apps from './src/apps'; +import * as views from './src/views'; +import * as flows from './src/flows'; + +export default defineStack({ + manifest: { id: 'com.example.pm', namespace: 'pm', version: '1.0.0', type: 'app', name: 'PM' }, + objects: Object.values(objects), + apps: Object.values(apps), + views: Object.values(views), + flows: Object.values(flows), +}); +``` + +### Scaffolding Command + +```bash +# Interactive — prompts for a name +npx create-objectstack + +# Direct — skip prompts (blank is the default, and the only, template) +npx create-objectstack my-app +``` + +### Plugin Loading Order Matters + +Plugins initialize in registration order. Key dependencies: + +| Plugin | Depends On | Reason | +|:-------|:-----------|:-------| +| ObjectQLPlugin | (none) | Core data engine, should load first | +| DriverPlugin | (none) | Registers driver service | +| AppPlugin | ObjectQLPlugin | Registers objects/metadata with engine | +| AuthPlugin | ObjectQLPlugin | Needs user/session objects | +| RESTPlugin | ObjectQLPlugin, AppPlugin | Generates routes from registered objects | +| AIServicePlugin | ObjectQLPlugin, AppPlugin | Needs metadata for tool generation. **Cloud / EE only** — `@objectstack/service-ai` moved to cloud (cloud ADR-0025); the open edition has no in-UI AI plugin and uses `@objectstack/mcp` (BYO-AI) | + +### Programmatic Bootstrap (Without CLI) + +```typescript +import { Runtime, DriverPlugin, AppPlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import appConfig from './objectstack.config'; + +const runtime = new Runtime(); +runtime.use(new ObjectQLPlugin()); +runtime.use(new DriverPlugin(new InMemoryDriver())); +runtime.use(new AppPlugin(appConfig)); +await runtime.start(); + +const kernel = runtime.getKernel(); +// kernel is now ready — use it with an adapter +``` + +## Multi-App Composition + +Host several apps in one runtime by registering an `AppPlugin` per app — this is +how real multi-app composition happens (`packages/cli/src/commands/serve.ts`). +Each app contributes its objects under their canonical `name`; names are +globally unique and equal the physical table name, so use them directly in +queries, hooks, formulas, and REST URLs. + +A merge-at-authoring-time alternative, `composeStacks()`, exists in +`@objectstack/spec` (`stack.zod.ts`) with `objectConflict` / +`manifest` strategies. No app in this repo uses it — read the schema before +reaching for it. + +## Complete Working Example + +A minimal but complete project from scratch: + +**`package.json`**: +```json +{ + "name": "my-todo-app", + "type": "module", + "scripts": { + "dev": "objectstack dev", + "start": "objectstack start", + "build": "objectstack build", + "validate": "objectstack validate" + }, + "dependencies": { + "@objectstack/spec": "^17.0.0", + "@objectstack/runtime": "^17.0.0", + "@objectstack/driver-memory": "^17.0.0", + "@objectstack/plugin-hono-server": "^17.0.0" + }, + "devDependencies": { + "@objectstack/cli": "^17.0.0", + "typescript": "^5.3.0" + } +} +``` + +**`src/objects/task.object.ts`** — one `ObjectSchema.create({ … })` call. Field +types, `indexes:` and the rest of the object surface are **objectstack-data**'s; +the scaffolder's own `note.object.ts` is the shape to copy. + +**`src/objects/index.ts`**: +```typescript +export { default as task } from './task.object'; +``` + +**`objectstack.config.ts`**: +```typescript +import { defineStack } from '@objectstack/spec'; +import * as objects from './src/objects'; + +export default defineStack({ + manifest: { + id: 'com.example.todo', + version: '1.0.0', + type: 'app', + name: 'Todo Manager', + }, + objects: Object.values(objects), +}); +``` + +```bash +# Run it +os dev --ui +# → Server at http://localhost:3000 (default port; dev auto-hops if taken) +# → REST API at http://localhost:3000/api +# → Console at http://localhost:3000/_console/ +``` diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md new file mode 100644 index 00000000000..0d0a5237658 --- /dev/null +++ b/skills/objectstack-platform/references/operations.md @@ -0,0 +1,202 @@ +# Part 3 — Operations: CLI, Testing, Deployment + +Every project gets the same `os` command surface — `npm install` does not need +to be re-run when commands are added. + +## Daily-loop commands + +| Command | What it does | +|:--------|:-------------| +| `os init` | Scaffold a new project (alternative to `npx create-objectstack`) | +| `os dev` | Start the dev server with hot metadata reload. `--seed-admin` (default **on** for plain `os dev`) seeds a loginable dev admin **in-process via the runtime** (env vars `OS_SEED_ADMIN*`) on an **empty** DB only — idempotent, never overwrites an existing account (default `admin@objectos.ai` / `admin123`; override with `--admin-email` / `--admin-password`; disable with `--no-seed-admin`). `--fresh` = ephemeral clean OS_HOME/DB, implies `--seed-admin`. The seeded admin is promoted to **platform admin**, so Setup/Studio work on first login. | +| `os dev --ui` | Also mount the bundled Console portal at `/_console/` (there is no separate `os studio` command) | +| `os validate` | Validate `objectstack.config.ts` — Zod protocol schema, CEL/predicate validation (`record.` existence), and widget-binding integrity. Same gates as `os build`, no artifact emitted. See [Verify your work](#verify-your-work). | +| `os lint` | Style/convention lint on metadata files | +| `os info` | Print a metadata summary of the config (objects, apps, and other collections; `--json`) | +| `os doctor` | Diagnose common setup issues | + +## Build & runtime + +| Command | What it does | +|:--------|:-------------| +| `os build` | Compile TS metadata, bundle, and produce `dist/` | +| `os compile` | Compile to portable JSON artifact (for runtime hydration) | +| `os serve` | Serve a compiled stack in production mode | +| `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 | +| `os generate ` | Scaffold an object / view / flow / agent from a template | + +## Verify your work + +ObjectStack metadata mistakes fail **silently at runtime**, not at edit time: +a bare field ref in a predicate (`done` instead of `record.done`) evaluates to +`null` and silently hides an action/validation on every record; a +dangling dashboard widget binding renders an empty chart (ADR-0021). Both are +caught at author time by one command: + +```bash +os validate # Zod schema + CEL predicates + widget bindings — no artifact +# or +os build # the same three gates, plus emits dist/objectstack.json +``` + +`os validate` and `os build` run the **same** structural + semantic gates: + +1. **Zod protocol schema** — the stack conforms to `@objectstack/spec`. +2. **CEL / predicate validation (ADR-0032)** — every `visible` / `disabled` / + `requiredWhen` / validation rule / flow condition / sharing rule is parsed + for CEL syntax *and* checked that each `record.` reference exists on + the target object. A bare `field` (missing `record.`) fails here. +3. **Widget-binding integrity (ADR-0021)** — every dashboard widget's + `dataset` / `dimensions` / `values` resolves to a declared dataset/field. + +Both exit non-zero with a located, corrective message; `os build` additionally +emits the artifact. Use `os validate` as the fast inner-loop check after editing +metadata and `os build` when you need `dist/`. In a scaffolded project these are +`npm run validate` / `npm run build`. + +**Rule of thumb: never report a metadata change as done until `os validate` +passes.** (`os lint` is a *separate* style/convention pass — naming, labels, +namespace prefixes — and does not replace `os validate`.) + +## Ports & networking + +Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): + +``` +--port › $OS_PORT › $PORT › 3000 (default) +``` + +**Conflict behaviour is mode-dependent — this is deliberate:** + +| Mode | If the resolved port is busy | +|:-----|:-----------------------------| +| **Dev** (`os dev`, or `NODE_ENV=development`) | Auto-hops to the next free port (up to +100) so several example apps run side-by-side. The startup banner shows the *actual* bound port. | +| **Production** (`os start`) | **Fails loudly and exits 1.** It never silently drifts — a shifted port breaks reverse-proxy upstreams, better-auth callback URLs, and CORS trusted-origins as opaque 403/502s. | + +**Production guidance:** + +- **Pin the port explicitly** — `PORT=8080 os start` (or `--port 8080`). Don't + rely on the `3000` default; it collides easily on shared hosts. +- **Keep these in sync when you change the port** (mismatch ⇒ better-auth + `Invalid origin` 403 / CORS failures): + - reverse-proxy upstream (`nginx`/`caddy`) + - `OS_AUTH_URL` / better-auth `baseURL` + `callbackURL` + - `OS_TRUSTED_ORIGINS` (CORS allow-list) + - the app's `hostname` +- **Recommended topology:** terminate TLS on a reverse proxy (`:443`) and let + the app listen on an internal high port (e.g. `8080`) fixed via `PORT`. + +## Data & migrations + +| Command | What it does | +|:--------|:-------------| +| `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) | +| `os diff` | Compare two ObjectStack config files and detect breaking changes | +| `os meta register` / `os meta resync` | Register (create/update) metadata on a target server / re-sync it (there is no `os meta apply`) | +| `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) | + +## Environments & deploy + +| Command | What it does | +|:--------|:-------------| +| `os login` / `logout` / `whoami` | Auth against the ObjectStack cloud control plane | +| `os environments list` / `create` / `switch` | Manage cloud environments (prod/staging/dev) | +| `os register` | Register the local stack as a deployable target | +| `os cloud login` / `logout` / `whoami` | Cloud auth subcommands (these three only — there are no `os cloud logs/metrics/status`) | +| `os package publish [dist/objectstack.json] [--env … --install --visibility org]` | Upload the compiled artifact as a versioned package to the cloud catalog (ADR-0008 P3) | +| `os package install [--version │ --runtime http://localhost:3000]` | Install a package into a **running** runtime via its install-local endpoint. Catalog mode (by manifest id) or air-gapped local-artifact mode. Auths with the **target runtime's** session (`--email/--password` or `OS_RUNTIME_EMAIL`/`OS_RUNTIME_PASSWORD`), not the cloud login | + +> **Cloud connection & marketplace (`@objectstack/cloud-connection`, ADR-0008/0009).** +> The open runtime-side cloud client. Its plugins — +> `CloudConnectionPlugin`/`createCloudConnectionPlugin`, `MarketplaceProxyPlugin`, +> `MarketplaceInstallLocalPlugin`, `RuntimeConfigPlugin` — expose the install-local +> endpoint that `os package install` targets, ship the **Installed Apps** page and +> marketplace Setup nav as plugin metadata, and maintain `LocalManifestSource` +> (a local desired-state ledger) plus runtime-identity bind v2 (environment-less +> self-hosted binding). + +## Shipping a plugin + +Authoring stops at `kernel.use(plugin)`; these three make it distributable. +`manifest.type: 'plugin'` is what marks the package as one. + +| Command | What it does | +|:--|:--| +| `os plugin build [--out dist/x.osplugin]` | Bundle from `objectstack.plugin.json` into a reproducible ustar+gzip `ID-VERSION.osplugin` with per-file `sha256-` integrity | +| `os plugin sign` | Sign the built artifact | +| `os plugin publish` | Upload it to the catalog | + +## Testing pattern + +Use `LiteKernel` for unit / integration tests — it skips the cloud bits and +plugin discovery, so tests run in milliseconds. Assemble the same plugins the +CLI would auto-register (`use()` is synchronous and chainable): + +```typescript +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { DriverPlugin, AppPlugin } from '@objectstack/runtime'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import stack from '../objectstack.config'; + +describe('stack boot', () => { + it('registers the task object', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + kernel + .use(new ObjectQLPlugin()) + .use(new DriverPlugin(new InMemoryDriver())) + .use(new AppPlugin(stack)); + await kernel.bootstrap(); + + const ql = kernel.getService('objectql'); + // query / mutate through the engine — see objectstack-query for the API + expect(ql).toBeDefined(); + + await kernel.shutdown(); + }); +}); +``` + +- **Seed in tests:** declare fixtures in the stack's `data:` collection — + they load at boot. See **objectstack-data** for env-scoped fixtures + (`env: ['test']`). +- **Reset between tests:** create a fresh `LiteKernel` per test — with the + in-memory driver a full bootstrap is cheap, and there is no `reset()`. +- **HTTP-level tests:** mount `createHonoApp({ kernel })` from + `@objectstack/hono` and drive it with `app.request(...)` / `fetch`. + +## Deployment targets + +| Target | Driver | HTTP layer | Notes | +|:-------|:-------|:-----------|:------| +| Node.js server | `driver-sql` (`pg` / `mysql` / `better-sqlite3`) | `plugin-hono-server` / `@objectstack/hono` | Default — works anywhere Node runs | +| Edge (Cloudflare Workers, Vercel Edge) | `driver-turso` (**cloud / EE only**) | `@objectstack/hono` | Cold-start friendly; LiteKernel only | +| Serverless (Lambda, Vercel functions) | `driver-sql` (`pg` with pooler) / `driver-mongodb` | `@objectstack/hono` | Mind cold-start: prefer LiteKernel | +| Browser / WebContainer | `driver-sqlite-wasm` | none (in-process) | Playground, demos | +| Docker / Kubernetes | any | any | Use `os start` as the entrypoint; pin `PORT` and `EXPOSE` it (see [Ports & networking](#ports--networking)) | + +## Health & observability + +- **Health endpoints:** the HTTP dispatcher exposes `GET /health` and + `GET /ready` under the API prefix. +- **Logs:** plugins log via `ctx.logger`. Logger config is a **kernel + construction** option, not a `defineStack` key: + `new ObjectKernel({ logger: { level: 'info', format: 'json' } })`. +- **Metrics:** use the kernel's built-ins — `kernel.getPluginMetrics()` + (per-plugin startup durations) and `await kernel.checkAllPluginsHealth()`. + There is no `metrics` service and no `@objectstack/plugin-prometheus`. + +## Common ops pitfalls + +| Symptom | Likely cause | +|:--------|:-------------| +| `os dev` hangs at "Loading metadata…" | Circular import in `objectstack.config.ts` — run `os validate` | +| `os start` exits with "Port N is already in use" | Intended: production never auto-shifts ports. Free the port or set `PORT=` — see [Ports & networking](#ports--networking) | +| better-auth `Invalid origin` 403 after a port/host change | Port or hostname out of sync with `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` — see [Ports & networking](#ports--networking) | +| Migrations apply locally but not in cloud | `env` scoping on the dataset excludes the target environment | +| Adapter 404s on auto-generated routes | `enable.apiEnabled: false` on the object, or missing `os build` | +| LiteKernel test passes, ObjectKernel boot fails | Test missed a plugin the CLI auto-registers — compare your test's `use()` list against the `os dev` boot log | +| Hot reload misses new objects | Barrel `src/objects/index.ts` not re-exporting — check the file | +| Login works but **Setup / Studio missing** | The logged-in user isn't a platform admin. Setup/Studio are gated by `setup.access` / `studio.access` on `admin_full_access`, auto-granted only to the first registered **human** (`bootstrapPlatformAdmin`). The `usr_system` seed identity is skipped, so it can't steal the grant. Either sign up first (`--seed-admin`/`--fresh` does this) or check `sys_user_permission_set` for a cross-tenant (`organization_id = NULL`) `admin_full_access` link on your user. Don't edit nav code first. | +| A permission set is declared but grants nobody anything | Declaring a set is not assigning it — see "Assigning a permission set to a user" in **objectstack-data**. | diff --git a/skills/objectstack-platform/references/plugin-development.md b/skills/objectstack-platform/references/plugin-development.md new file mode 100644 index 00000000000..0ac5a43223e --- /dev/null +++ b/skills/objectstack-platform/references/plugin-development.md @@ -0,0 +1,359 @@ +# Part 2 — Plugin Development & Kernel Extension + + +## Quick Reference — Detailed Rules + +For comprehensive documentation with incorrect/correct examples: + +- **[Plugin Lifecycle](../rules/plugin-lifecycle.md)** — 3-phase lifecycle (init/start/destroy), execution order, complete examples +- **[Service Registry](../rules/service-registry.md)** — DI container, factories, lifecycles (singleton/transient/scoped), core fallbacks +- **[Hooks & Events](./plugin-hooks.md)** — Kernel hooks & events reference (record-level lifecycle hooks → [objectstack-data](../../objectstack-data/SKILL.md)) + +--- + +## ObjectKernel vs LiteKernel + +| Feature | ObjectKernel | LiteKernel | +|:--------|:-------------|:-----------| +| **Use case** | Production servers, full applications | Serverless, edge, unit tests | +| **Package** | `@objectstack/core` | `@objectstack/core` | +| **Plugin loading** | Async with validation & metadata | Synchronous `use()` | +| **Service factories** | Singleton / Transient / Scoped | Direct instances only | +| **Health monitoring** | Built-in per-plugin health checks | Not available | +| **Graceful shutdown** | Timeout + rollback on failure | Basic destroy phase | +| **Dependency resolution** | Topological sort + circular detection (throws) | Topological sort (throws on cycles) | +| **Core fallbacks** | Auto-injects in-memory fallbacks | Not available | +| **Config validation** | Zod schema validation per plugin | Not available | + +### A third answer: no kernel at all + +If the host only needs the **data engine** — query / CRUD / hooks / validation — +neither kernel is the answer. Import `ObjectQL` from +`@objectstack/objectql/core` (ADR-0076): no kernel, no `ObjectQLPlugin`, no +metadata-management layer, and the *same* `ObjectSchema.create({ … })` +definitions a full backend ships. `examples/embed-objectql` is the worked +example; it is the right shape for a thin, latency-sensitive host such as a +gateway. + +### ObjectKernel Configuration + +```typescript +import { ObjectKernel } from '@objectstack/core'; + +const kernel = new ObjectKernel({ + logger: { + level: 'info', // debug|info|warn|error|fatal|silent + format: 'json', // 'json' | 'text' | 'pretty' + }, + defaultStartupTimeout: 30000, // Per plugin (ms) + gracefulShutdown: true, // Register SIGINT/SIGTERM handlers + shutdownTimeout: 60000, // Total shutdown timeout (ms) + rollbackOnFailure: true, // Rollback all plugins if one fails + skipSystemValidation: false, // Skip system checks (useful for tests) +}); +``` + +### LiteKernel Configuration + +```typescript +import { LiteKernel } from '@objectstack/core'; + +const kernel = new LiteKernel({ + logger: { level: 'warn' }, +}); +``` + +--- + +## Plugin Interface — Quick Overview + +```typescript +import type { Plugin, PluginContext } from '@objectstack/core'; + +export interface Plugin { + name: string; // Unique identifier (reverse domain recommended) + version?: string; // Semantic version + type?: PluginType; // closed set exported by @objectstack/core + dependencies?: string[]; // Plugins that must init before this one + + // Phase 1: Register services + init(ctx: PluginContext): Promise | void; + + // Phase 2: Execute business logic (optional) + start?(ctx: PluginContext): Promise | void; + + // Phase 3: Cleanup (optional) + destroy?(): Promise | void; +} +``` + +See [rules/plugin-lifecycle.md](../rules/plugin-lifecycle.md) for complete examples. + +--- + +## PluginContext API + +### Service Registry + +```typescript +// Register a service (in init phase) +ctx.registerService('my-service', myServiceInstance); + +// Get a service (in start phase) +const db = ctx.getService('objectql'); + +// Replace a service +ctx.replaceService('cache', new InstrumentedCache(existingCache)); + +// Get all services +const allServices: Map = ctx.getServices(); +``` + +See [rules/service-registry.md](../rules/service-registry.md) for factories and lifecycles. + +### Hook / Event System + +```typescript +// Register a kernel hook handler +ctx.hook('kernel:ready', async () => { + ctx.logger.info('System is ready!'); +}); + +// React to a metadata hot-reload / publish +ctx.hook('metadata:reloaded', async (payload?: { changed?: string[] }) => { + ctx.logger.info('Metadata reloaded', { changed: payload?.changed }); +}); + +// Trigger a custom hook +await ctx.trigger('my-plugin:initialized', { version: '1.0.0' }); +``` + +Built-in kernel events: `kernel:ready`, `kernel:bootstrapped`, +`kernel:listening`, `kernel:shutdown`, `app:seeded`, `metadata:reloaded`, +`external.schema.drift`. + +> **⚠️ There are no `data:*` kernel hooks.** Record-level lifecycle logic +> (beforeInsert / afterUpdate / …) runs on the **ObjectQL engine**, not the +> kernel event bus — author it via the `hooks:` collection or +> `ql.on('beforeInsert', 'task', async (ctx) => { … })` (see +> **objectstack-data**). Because `ctx.hook()` accepts any string, a handler +> registered for `'data:beforeInsert'` will register successfully and then +> **silently never fire**. Kernel hooks are for platform lifecycle only. + +See [references/plugin-hooks.md](./plugin-hooks.md) for the kernel event list, payloads, and patterns. + +### Logger + +```typescript +ctx.logger.debug('Detailed trace info', { key: 'value' }); +ctx.logger.info('Plugin initialized'); +ctx.logger.warn('Cache miss rate high', { rate: 0.45 }); +ctx.logger.error('Connection failed', error); +``` + +### Kernel Access + +```typescript +const kernel = ctx.getKernel(); +const isRunning = kernel.isRunning(); +const state = kernel.getState(); // 'idle' | 'initializing' | 'running' | 'stopping' | 'stopped' +``` + +--- + +## Complete Plugin Example + +```typescript +// src/plugins/audit.ts +import type { Plugin, PluginContext } from '@objectstack/core'; + +interface AuditEntry { + timestamp: string; + event: string; + detail?: Record; +} + +class AuditService { + private log: AuditEntry[] = []; + + record(event: string, detail?: Record) { + this.log.push({ timestamp: new Date().toISOString(), event, detail }); + } + + getLog(): AuditEntry[] { + return [...this.log]; + } +} + +const AuditPlugin: Plugin = { + name: 'com.example.audit', + version: '1.0.0', + type: 'plugin', + + async init(ctx: PluginContext) { + // Phase 1: Register service and kernel hooks + const auditService = new AuditService(); + ctx.registerService('audit', auditService); + + ctx.hook('kernel:ready', async () => { + auditService.record('kernel:ready'); + }); + + ctx.hook('metadata:reloaded', async (payload?: { changed?: string[] }) => { + auditService.record('metadata:reloaded', { changed: payload?.changed }); + }); + + ctx.logger.info('Audit plugin initialized'); + }, + + async start(ctx: PluginContext) { + // Phase 2: Log that audit is active + ctx.logger.info('Audit logging active'); + }, + + async destroy() { + // Phase 3: Cleanup + }, +}; + +export default AuditPlugin; +``` + +--- + +## Using Plugins + +```typescript +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { DriverPlugin } from '@objectstack/runtime'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import AuditPlugin from './plugins/audit'; + +const kernel = new ObjectKernel(); +await kernel.use(new ObjectQLPlugin()); +await kernel.use(new DriverPlugin(new InMemoryDriver())); +await kernel.use(AuditPlugin); +await kernel.bootstrap(); + +// Services are now available +const audit = kernel.getService('audit'); +``` + +--- + +## Testing Plugins + +```typescript +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; +import AuditPlugin from './audit'; + +describe('AuditPlugin', () => { + it('records kernel lifecycle events', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + kernel.use(AuditPlugin); + + // `kernel.context` is protected — to fire events in a test, capture a + // PluginContext from a probe plugin instead. + let probe!: PluginContext; + kernel.use({ name: 'test.probe', init(ctx) { probe = ctx; } }); + + await kernel.bootstrap(); // fires kernel:ready → recorded + + // Simulate a metadata hot-reload announcement + await probe.trigger('metadata:reloaded', { changed: ['object/task'] }); + + const audit = kernel.getService<{ getLog(): { event: string }[] }>('audit'); + const events = audit.getLog().map((e) => e.event); + expect(events).toContain('kernel:ready'); + expect(events).toContain('metadata:reloaded'); + + await kernel.shutdown(); + }); +}); +``` + +--- + +## Well-Known Plugin Names & Services + +| Plugin Name | Service Key | Package | +|:------------|:------------|:--------| +| `com.objectstack.engine.objectql` | `objectql` (also `data`) | `@objectstack/objectql` | +| `com.objectstack.driver.*` | `driver.{name}` | `@objectstack/driver-*` | +| `com.objectstack.auth` | `auth` | `@objectstack/plugin-auth` | +| `com.objectstack.rest.api` | — (registers no service) | `@objectstack/rest` | +| `com.objectstack.metadata` | `metadata` | `@objectstack/metadata` | +| `com.objectstack.service.realtime` | `realtime` | `@objectstack/service-realtime` | +| `com.objectstack.service.cache` | `cache` | `@objectstack/service-cache` | +| `com.objectstack.server.hono` | — | `@objectstack/plugin-hono-server` → `HonoServerPlugin` | +| `com.objectstack.setup` | — | `@objectstack/setup` → `createSetupAppPlugin` (ADR-0048 one-app pkg) | +| `com.objectstack.studio` | — | `@objectstack/studio` → `createStudioAppPlugin` | +| `com.objectstack.account` | — | `@objectstack/account` → `createAccountAppPlugin` | +| `com.objectstack.cloud.connection` | — | `@objectstack/cloud-connection` → `createCloudConnectionPlugin` | + +--- + +## MetadataPlugin Runtime Boundary + +`MetadataPlugin` is the `IMetadataService` provider for the ObjectStack runtime, but runtime +metadata is read-only and artifact/file backed: + +- Do **not** register `sys_metadata` or `sys_metadata_history` from an ObjectStack + runtime plugin. Those persistence tables belong to the control plane. + (Exception: an *isolated environment kernel* may opt into `sys_metadata` + hydration from its own DB — the general boundary otherwise stands.) +- Do **not** call `MetadataManager.setDataEngine()` automatically from + `MetadataPlugin.start()`. Project databases must contain business rows only. +- Use `artifactSource: { mode: 'local-file', path: './dist/objectstack.json' }` + for local artifact boot; production should use the Artifact API loader once + wired. +- `DatabaseLoader`, `setDatabaseDriver()`, and `setDataEngine()` remain valid for + control-plane services that explicitly own metadata revisions, history, or + overlays. + +```typescript +import { MetadataPlugin } from '@objectstack/metadata'; + +await kernel.use(new MetadataPlugin({ + watch: false, + artifactSource: { mode: 'local-file', path: './dist/objectstack.json' }, +})); +``` + +--- + +## Health Monitoring (ObjectKernel Only) + +A plugin opts in by adding an `async healthCheck()` returning +`{ healthy: boolean; message?: string; details?: Record }` +(`PluginHealthStatus`, importable from `@objectstack/core`). Return `healthy: +false` rather than throwing. The kernel side is three calls: + +```typescript +const health = await kernel.checkPluginHealth('com.example.db'); +const allHealth = await kernel.checkAllPluginsHealth(); +const metrics = kernel.getPluginMetrics(); // Map +``` + +--- + +## Feature Flags + +Feature flags are **not a spec/metadata concept**. There is no `featureFlags:` / +`features:` key on `defineStack` (writing one is refused at load, not stripped), and the +former `FeatureFlagSchema` (`@objectstack/spec/kernel`) was removed — it had zero runtime +consumers, and its only protocol home (the static `ObjectStackCapabilities.system.features` +descriptor) was itself dead: no endpoint ever served it. Runtime capability discovery is +`GET /api/v1/discovery`. + +The live toggle surfaces are runtime configuration, not authored metadata: + +- **`feature_flags` settings manifest** (`@objectstack/service-settings`) — org-tunable + toggles like `ai_enabled` / `beta_*`, resolvable at runtime and env-overridable via + `OS_FEATURE_FLAGS_*` (ADR-0007 settings cascade). +- **Auth capability gates** — `requiresFeature` on actions/params lowers to the + `PUBLIC_AUTH_FEATURES` registry (`kernel/public-auth-features.ts`), the fixed + deployment-level flags plugin-auth advertises to anonymous clients. diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index b376e001961..1981d247abf 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -1,5 +1,11 @@ # Dashboards, Reports & Cubes +- [Dashboards](#dashboards) · [Widget Types](#widget-types) · [Dataset-Bound Widgets](#dataset-bound-widgets) · [KPI Widgets, Filters, Drilldown](#kpi-widgets-filters-drilldown) +- [Dashboard filters](#dashboard-filters--globalfilters--per-widget-filterbindings) · [Period-over-period](#period-over-period--compareto) +- [Server-side date bucketing](#server-side-date-bucketing--dategranularity-adr-0021) · [Widget `options`](#widget-options--the-five-declared-keys) · [Drilldown](#drilldown) +- [Report Configuration](#report-configuration) +- [Analytics Cubes — Semantic Layer](#analytics-cubes--semantic-layer) · [Cube Rules](#cube-rules) + ## Dashboards Dashboards are a grid of widgets (`columns` × `rowHeight`) sharing a diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index 8ec806a5449..4866ced2bd8 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -1,5 +1,11 @@ # List, Kanban & Gantt Views +- [Configuring a List View](#configuring-a-list-view) · [The `defineView` container](#the-defineview-container-viewts-file-shape) · [Data Source](#data-source-data) +- [Columns](#columns) · [Column Features](#column-features) · [Filtering](#filtering) · [Sorting](#sorting) +- [End-User Quick Filters](#end-user-quick-filters-userfilters-adr-0047) · [Toolbar Search](#toolbar-search-searchablefields-adr-0061) +- [Configuring Kanban Views](#configuring-kanban-views) +- [Configuring Gantt Views](#configuring-gantt-views) + ## Configuring a List View ### The `defineView` container (`*.view.ts` file shape) diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index 7fdf6b033fd..d91ac63f514 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -1,5 +1,11 @@ # Pages & Docs +- [Pages — Lightning-Style Page Layouts](#pages--lightning-style-page-layouts) · [Page Types](#page-types) · [Templates & Regions](#templates--regions) +- [Component Catalogue](#component-catalogue-selection) · [Example — Record Detail Page](#example--record-detail-page) +- [AI-authored source pages](#ai-authored-source-pages--kindhtml-and-kindreact-adr-00800081) · [Styling a page](#styling-a-page-adr-0065--responsivestyles-not-classname) +- [Docs — Package Documentation](#docs--package-documentation-adr-0046) · [Authoring rules](#authoring-rules-each-enforced-by-os-build) +- [Routing model](#routing-model--platform-level-viewer-opt-in-entry) · [Inline metadata views](#inline-metadata-views--the-metadata-fence-adr-0051) · [Example](#example) + ## Pages — Lightning-Style Page Layouts A **Page** is a Salesforce-Lightning-style layout composed of **regions** diff --git a/skills/objectstack-upgrade/SKILL.md b/skills/objectstack-upgrade/SKILL.md index fe9ae32873e..703d56a885d 100644 --- a/skills/objectstack-upgrade/SKILL.md +++ b/skills/objectstack-upgrade/SKILL.md @@ -266,39 +266,7 @@ TypeScript is what breaks — or worse, keeps compiling while reading `undefined fixtures, and the project's own AI conventions file. Nothing fails, and the next agent to read the repo re-authors the retired shape from it. -### 2.3 A worked R1 — the retired field-mapping `transform` - -The shape in a protocol-16 project: - -```jsonc -{ - "connectors": [{ - "name": "sap_erp", - "fieldMappings": [ - { "source": "order_value", "target": "order_total", - "transform": { "type": "javascript", "expression": "value / 100" } } - ] - }] -} -``` - -The chain deletes the key (`field-mapping-transform-removed`) and the schema -tombstones it, so the parse error *is* the prescription: the union had five -members and **no runtime ever executed any of them**. The customer wrote it -because they wanted a transformation, and that need is real regardless. - -The prescription names one live target; the rest is the business decision: - -| If the intent was… | The v17 home is… | -|:--|:--| -| per-row value shaping on an import | **Import mapping** `mapping.fieldMapping[].transform` — a string enum, settings in `params`; the REST import path runs `none`/`constant`/`map`/`split`/`join`, passes `lookup` to reference resolution, rejects `javascript` (400). | -| multi-source, multi-stage transformation | **nothing** — the L2 ETL layer retired at 17, unexecuted. Do it where it runs: warehouse ELT, a `flow`, a job. | -| nothing — the value was already correct | delete the key and record that the transformation never ran. | - -That third row is frequently the truth: the member never executed, so the -connector has been landing raw values for as long as it has been running. -Whether the downstream data is wrong is a question only the owner can answer — -exactly the kind of finding the report exists to surface. +→ Moved verbatim to [references/examples-upgrade.md](./references/examples-upgrade.md) § 2.3 A worked R1 — the retired field-mapping `transform`. @@ -446,54 +414,7 @@ part of it. > > Run both. A report that cites only `validate` cannot see this class at all. -### 3.4 The report — the human half - -The upgrade is not finished by a passing command; it is finished by a document a -maintainer can read in five minutes and a year from now. Write -`.upgrade/REPORT.md`: - -```markdown -# Protocol 16 → 17 upgrade — - -**Status:** complete | complete with N open decisions -**Spec:** · **Chain:** 16 → 17 -**Verified:** `os validate` green · `tsc --noEmit` green · replay-from-17 applies 0 mechanical changes - -## 1 · Mechanical (applied by the chain) - -| Site | Change | Conversion | -|:--|:--|:--| -| `objects[crm_lead].fields.name` | `required: true` → `+ storage.notNull: true` | `field-required-notnull-explicit` | -| … | | | - -_N sites, M conversions. Ported into sources from `os migrate meta --out`._ - -## 2 · Semantic residue (decided) - -### `connector.fieldMappings[].transform` — RESOLVED -- **Site:** `src/connectors/sap.ts:24` -- **Prescription:** -- **Options:** import-mapping `transform` · ETL step · delete -- **Decision:** delete — owner confirmed the values arrive pre-scaled. - _Decided by: , ._ -- **Verified:** `os validate` green; connector sync run against staging, 200 rows, values unchanged. - -## 3 · Open decisions - -| Item | Site | Options | Recommendation | Blocking? | -|:--|:--|:--|:--|:--| -| `agent.tools` → which skill | `src/ai/support-bot.ts:12` | `case_management` · new skill | `case_management` | no — parses without it | - -## 4 · Pending, per deployment - -- [ ] `os migrate files-to-references` — media values only warn until it passes. -- [ ] `os migrate value-shapes` — stored reference/JSON values unchecked until it passes. -- [ ] `os migrate meta --stored --apply` — rows rehydrate correctly today; this makes it durable. - -## 5 · Not changed, and why - -- — no occurrences. -``` +→ Moved verbatim to [references/examples-upgrade.md](./references/examples-upgrade.md) § 3.4 The report — the human half. @@ -537,40 +458,7 @@ guarantee. --- -## The v17-canonical shapes, compiled - -What the protocol-16 shapes in this skill's examples look like after the -upgrade. This block is type-checked against the published spec, so it cannot rot -into teaching a shape that no longer compiles: - - -```typescript -import { ObjectSchema } from '@objectstack/spec/data'; -import { defineAgent } from '@objectstack/spec/ai'; - -// `conditionalRequired` → `requiredWhen`; `required` now also states the -// physical constraint explicitly via `storage.notNull`. -export const Lead = ObjectSchema.create({ - name: 'crm_lead', - sharingModel: 'public_read_write', - label: 'Lead', - fields: { - name: { type: 'text', required: true, storage: { notNull: true } }, - status: { type: 'select', required: true, storage: { notNull: true } }, - due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' }, - notes: { type: 'textarea' }, - }, -}); - -// Agent capability is reached through skills — there is no inline tool list. -export const SupportBot = defineAgent({ - name: 'support_bot', - label: 'Support Bot', - role: 'Front-line support triage', - instructions: 'Answer support questions and open cases when needed.', - skills: ['case_management'], -}); -``` +→ Moved verbatim to [references/examples-upgrade.md](./references/examples-upgrade.md) § The v17-canonical shapes, compiled. --- diff --git a/skills/objectstack-upgrade/references/examples-upgrade.md b/skills/objectstack-upgrade/references/examples-upgrade.md new file mode 100644 index 00000000000..63ff811cf90 --- /dev/null +++ b/skills/objectstack-upgrade/references/examples-upgrade.md @@ -0,0 +1,119 @@ +# Upgrade — worked examples and templates (moved verbatim from SKILL.md) + +### 2.3 A worked R1 — the retired field-mapping `transform` + +The shape in a protocol-16 project: + +```jsonc +{ + "connectors": [{ + "name": "sap_erp", + "fieldMappings": [ + { "source": "order_value", "target": "order_total", + "transform": { "type": "javascript", "expression": "value / 100" } } + ] + }] +} +``` + +The chain deletes the key (`field-mapping-transform-removed`) and the schema +tombstones it, so the parse error *is* the prescription: the union had five +members and **no runtime ever executed any of them**. The customer wrote it +because they wanted a transformation, and that need is real regardless. + +The prescription names one live target; the rest is the business decision: + +| If the intent was… | The v17 home is… | +|:--|:--| +| per-row value shaping on an import | **Import mapping** `mapping.fieldMapping[].transform` — a string enum, settings in `params`; the REST import path runs `none`/`constant`/`map`/`split`/`join`, passes `lookup` to reference resolution, rejects `javascript` (400). | +| multi-source, multi-stage transformation | **nothing** — the L2 ETL layer retired at 17, unexecuted. Do it where it runs: warehouse ELT, a `flow`, a job. | +| nothing — the value was already correct | delete the key and record that the transformation never ran. | + +That third row is frequently the truth: the member never executed, so the +connector has been landing raw values for as long as it has been running. +Whether the downstream data is wrong is a question only the owner can answer — +exactly the kind of finding the report exists to surface. + +### 3.4 The report — the human half + +The upgrade is not finished by a passing command; it is finished by a document a +maintainer can read in five minutes and a year from now. Write +`.upgrade/REPORT.md`: + +```markdown +# Protocol 16 → 17 upgrade — + +**Status:** complete | complete with N open decisions +**Spec:** · **Chain:** 16 → 17 +**Verified:** `os validate` green · `tsc --noEmit` green · replay-from-17 applies 0 mechanical changes + +## 1 · Mechanical (applied by the chain) + +| Site | Change | Conversion | +|:--|:--|:--| +| `objects[crm_lead].fields.name` | `required: true` → `+ storage.notNull: true` | `field-required-notnull-explicit` | +| … | | | + +_N sites, M conversions. Ported into sources from `os migrate meta --out`._ + +## 2 · Semantic residue (decided) + +### `connector.fieldMappings[].transform` — RESOLVED +- **Site:** `src/connectors/sap.ts:24` +- **Prescription:** +- **Options:** import-mapping `transform` · ETL step · delete +- **Decision:** delete — owner confirmed the values arrive pre-scaled. + _Decided by: , ._ +- **Verified:** `os validate` green; connector sync run against staging, 200 rows, values unchanged. + +## 3 · Open decisions + +| Item | Site | Options | Recommendation | Blocking? | +|:--|:--|:--|:--|:--| +| `agent.tools` → which skill | `src/ai/support-bot.ts:12` | `case_management` · new skill | `case_management` | no — parses without it | + +## 4 · Pending, per deployment + +- [ ] `os migrate files-to-references` — media values only warn until it passes. +- [ ] `os migrate value-shapes` — stored reference/JSON values unchecked until it passes. +- [ ] `os migrate meta --stored --apply` — rows rehydrate correctly today; this makes it durable. + +## 5 · Not changed, and why + +- — no occurrences. +``` + +## The v17-canonical shapes, compiled + +What the protocol-16 shapes in this skill's examples look like after the +upgrade. This block is type-checked against the published spec, so it cannot rot +into teaching a shape that no longer compiles: + + +```typescript +import { ObjectSchema } from '@objectstack/spec/data'; +import { defineAgent } from '@objectstack/spec/ai'; + +// `conditionalRequired` → `requiredWhen`; `required` now also states the +// physical constraint explicitly via `storage.notNull`. +export const Lead = ObjectSchema.create({ + name: 'crm_lead', + sharingModel: 'public_read_write', + label: 'Lead', + fields: { + name: { type: 'text', required: true, storage: { notNull: true } }, + status: { type: 'select', required: true, storage: { notNull: true } }, + due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' }, + notes: { type: 'textarea' }, + }, +}); + +// Agent capability is reached through skills — there is no inline tool list. +export const SupportBot = defineAgent({ + name: 'support_bot', + label: 'Support Bot', + role: 'Front-line support triage', + instructions: 'Answer support questions and open cases when needed.', + skills: ['case_management'], +}); +``` From 268649f1ed52c6c12788c8a48a8cc64155a02e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 15:12:40 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore(gates):=20follow=20the=20moved=20text?= =?UTF-8?q?=20=E2=80=94=20role-word=20baseline=20rows=20and=20the=20scaffo?= =?UTF-8?q?ld-emission-policy=20carrier=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role-word ledger keeps its 123 occurrences: the one in the automation entry and the one in the upgrade entry now sit in the reference files their sections moved to verbatim. The scaffold emission policy's published restatement row points at references/bootstrap.md, where the Complete Working Example's package.json fence now lives, so the fence stays enforced. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- scripts/role-word-baseline.json | 4 ++-- scripts/sync-scaffold-emission-policy.mjs | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index f4bf00adcbc..2b9bef2686d 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -34,7 +34,7 @@ "content/docs/releases/v17/17-0.mdx": 2, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 1, - "skills/objectstack-automation/SKILL.md": 1, + "skills/objectstack-automation/references/state-machines-and-approvals.md": 1, "skills/objectstack-data/SKILL.md": 1, "skills/objectstack-data/references/data-hooks.md": 3, "skills/objectstack-data/rules/relationships.md": 1, @@ -42,5 +42,5 @@ "skills/objectstack-query/rules/filters.md": 8, "skills/objectstack-ui/rules/list-views.md": 1, "skills/objectstack-ui/rules/navigation.md": 1, - "skills/objectstack-upgrade/SKILL.md": 1 + "skills/objectstack-upgrade/references/examples-upgrade.md": 1 } diff --git a/scripts/sync-scaffold-emission-policy.mjs b/scripts/sync-scaffold-emission-policy.mjs index a0c83cc37f5..62206d25743 100644 --- a/scripts/sync-scaffold-emission-policy.mjs +++ b/scripts/sync-scaffold-emission-policy.mjs @@ -71,7 +71,8 @@ // // The bundled template was never the only hand-written restatement of this // policy. The other one is the "Complete Working Example" `package.json` fence -// in `skills/objectstack-platform/SKILL.md` — the copy-paste manifest every +// in `skills/objectstack-platform/references/bootstrap.md` (moved there VERBATIM +// out of the entry SKILL.md by the #19715 split) — the copy-paste manifest every // agent that loads that skill reads — and no gate read it (#16767). It sat at // the retired `^6.0.0` longer than the bundled template did, for the ordinary // reason: the carrier with no reader is where the next drift lands. @@ -160,7 +161,10 @@ export const POLICY_STAMPS = [ */ export const PUBLISHED_RESTATEMENTS = [ { - path: 'skills/objectstack-platform/SKILL.md', + // The section moved verbatim out of `skills/objectstack-platform/SKILL.md` + // into this reference file (#19715, decision batch #213 item 1); the row + // follows the text, and a renamed heading still reds below. + path: 'skills/objectstack-platform/references/bootstrap.md', heading: '## Complete Working Example', label: '**`package.json`**:', fence: 'json', From ef0b6182918202c0c6147c04836e64f16a27fe21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 00:17:29 +0000 Subject: [PATCH 3/3] docs(skills): the automation pointer line keeps teaching the meta state-introspection route packages/rest's repo-level test pins skills/objectstack-automation/SKILL.md as a site that teaches /api/v1/meta/object/:name/state/:field, the path the REST ledger row for meta.getLegalNextStates declares; the split had moved the only spelling into references/state-machines-and-approvals.md. The pointer line for that block now carries the one spelling (net 0 lines, the reference is untouched), and the entry's token-ratchet row is re-locked at its new landed count. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- scripts/check-skills-token-ratchet.mjs | 9 ++++++++- skills/objectstack-automation/SKILL.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 11af8031ea7..375400d58c9 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -313,7 +313,14 @@ export const CEILINGS = new Map([ // `references/state-machines-and-approvals.md` (rows below), one pointer line // each left in place. The moved text is priced in its own rows, so the split // banks the entry's shrink instead of leaving 7,006 tokens of headroom behind. - ['skills/objectstack-automation/SKILL.md', 5762], + // 5762 -> 5785: the pointer line for that block also carries the one route + // spelling a repo test pins to THIS entry file — + // `packages/rest/src/meta-state-route-doc-spelling.test.ts` asserts the + // automation entry teaches `/api/v1/meta/object/:name/state/:field`, and the + // only spelling had moved with the section. +23 tokens, re-locked at the + // landed count of the same split (no ruling needed: the raise is against a + // row this PR itself lowered by 7,006, and the entry stays 448 lines). + ['skills/objectstack-automation/SKILL.md', 5785], // 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above). // The invariant-vs-transition-gate choice, placed in the field-conditional-rule // bullet list where the tool is actually picked. Both wrong picks are silent: diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 70026a41f8e..03f0770ca8e 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -309,7 +309,7 @@ them right the first time: --- -→ State Machines & Approvals moved verbatim to [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) (State Machine — a `state_machine` validation rule · Approvals (Flow Nodes) · Send-back for revision · Recording a decision · Approver Types · Dynamic approvers (`type: 'expression'`) · Node Config (`ApprovalNodeConfigSchema`) · Branching, side-effects & rejection · Approval Best Practices). +→ State Machines & Approvals moved verbatim to [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) (State Machine — a `state_machine` validation rule · Approvals (Flow Nodes) · Send-back for revision · Recording a decision · Approver Types · Dynamic approvers (`type: 'expression'`) · Node Config (`ApprovalNodeConfigSchema`) · Branching, side-effects & rejection · Approval Best Practices) — its state-introspection route is `GET /api/v1/meta/object/:name/state/:field?from=:state`. ---