diff --git a/.changeset/v17-verification-defects-docs.md b/.changeset/v17-verification-defects-docs.md new file mode 100644 index 0000000000..8cfbeefe9d --- /dev/null +++ b/.changeset/v17-verification-defects-docs.md @@ -0,0 +1,25 @@ +--- +--- + +docs+test: v17 verification defects — ReDoS assertion load-insensitivity, doc drift (#4485, #4476, #4486, #4452) + +Release-nothing: touches only `.md`/`.mdx` prose and one `.test.ts` file. No +package source, no public export, no protocol change — so no package needs a +version bump. + +- **#4485** `protocol-handshake.test.ts` — the ReDoS guard bounded the + pathological scan with an absolute 50ms wall clock, which measures machine + load rather than the parser: under the full-repo run (~130 parallel turbo + tasks) it exceeded 50ms on a healthy tree and reddened PRs that never touched + `@objectstack/metadata-core`. The behavioural assertions are kept; the + wall-clock proxy is replaced by a scaling check (same adversarial shapes at 1x + and 8x length), so load largely cancels out of the ratio. +- **#4476** Seventeen passages dated the v17 query-surface removals to + `@objectstack/spec` 18. They ship in 17; `spec-changes.json` gives + `toMajor: 17`. +- **#4486** The `IDataEngine` doc block dropped the trailing + `options?: BaseEngineOptions` from all four read methods — the very parameter + #4251 added, against a failure mode that raises no error. +- **#4452** `service-automation`'s README taught a flow DSL that never existed + (node type names, interpolation dialect, and nested `steps` all wrong); + rewritten from the schemas and executors. README only — no package code. diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index bee266d2ef..7f256cd5f9 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -256,7 +256,7 @@ backend chooses, exactly as before. ### Keyset Pagination — a `where` predicate on the sort key -`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): nothing on the server +`query.cursor` was **removed in `@objectstack/spec` 17** (#4286): nothing on the server ever read it, so a cursor query silently returned the same first page every time. The key is tombstoned and `QueryBuilder.cursor()` was removed with it. Express the keyset directly — seek past the last row instead of offsetting: @@ -422,7 +422,7 @@ come later behind a driver capability flag without changing these semantics. ## Joins — removed -`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049 +`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049 enforce-or-remove): no driver's `find()` ever executed a join — the SQL, in-memory, and MongoDB drivers all ignored the array, so it only ever declared a capability that did not run. The key is tombstoned: authoring it is a `tsc` error, and a query carrying it @@ -532,7 +532,7 @@ by `@objectstack/plugin-pinyin-search`) recomputes the column on demand. ## Window Functions — removed from the request surface -`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286): +`query.windowFunctions` was **removed in `@objectstack/spec` 17** (#4286): `ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query` route never routed it anywhere, so sending it had no effect. The key is tombstoned — a query carrying it fails to parse with the upgrade prescription — and the @@ -565,7 +565,7 @@ report/dashboard metadata. ### Distinct Records — removed flag, three live spellings -The top-level `query.distinct` flag was **removed in `@objectstack/spec` 18** (#4286): +The top-level `query.distinct` flag was **removed in `@objectstack/spec` 17** (#4286): no driver's `find()` ever applied it, and its only observable effect was mis-wired — it silently suppressed the REST list count while still returning duplicate rows (the count is truthful again). The key is tombstoned and `QueryBuilder.distinct()` was diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index 4ab68c7496..c73ada4e30 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -280,7 +280,7 @@ console.log(field.maxLength?.toString() ?? 'no limit'); 4. **Avoid deep nesting** — Limit nested `$and`/`$or` depth 5. **Use keyset pagination** — For large datasets, seeking past the last row is faster than a deep `offset`. Express the keyset as a `where` predicate on the - sort key (the `cursor` query property was removed in `@objectstack/spec` 18, + sort key (the `cursor` query property was removed in `@objectstack/spec` 17, #4286 — nothing ever read it) ```typescript diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 12d31bd2ba..038afe0e02 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -21,6 +21,7 @@ The canonical `IDataEngine` interface uses **QueryAST-aligned parameter names** ```typescript import type { + BaseEngineOptions, EngineQueryOptions, DataEngineInsertOptions, EngineUpdateOptions, @@ -31,11 +32,12 @@ import type { } from '@objectstack/spec/data'; export interface IDataEngine { - // Query - find(objectName: string, query?: EngineQueryOptions): Promise; - findOne(objectName: string, query?: EngineQueryOptions): Promise; - count(objectName: string, query?: EngineCountOptions): Promise; - aggregate(objectName: string, query: EngineAggregateOptions): Promise; + // Query (reads take the execution context in a TRAILING options argument — + // the same position the write methods take theirs) + find(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; + findOne(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; + count(objectName: string, query?: EngineCountOptions, options?: BaseEngineOptions): Promise; + aggregate(objectName: string, query: EngineAggregateOptions, options?: BaseEngineOptions): Promise; // Mutation (write ops also accept in-process WriteObservabilityOptions — see `update`) insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise; @@ -65,6 +67,20 @@ export interface IDataEngine { All query methods use canonical **QueryAST parameter names**: `where`, `fields`, `orderBy`, `limit`, `offset`, `expand`. + +**Reads take the execution context in the trailing `options` argument**, the same +position the write methods take theirs — `find`, `findOne`, `count` and `aggregate` +all accept `options?: BaseEngineOptions`. + +This matters because the mistake it prevents is silent. The same `{ context }` object +is correct as the third argument to `insert`, and passing it as the third argument to +`find` used to be **dropped without error** — so an intended `isSystem` bypass simply +vanished, and control-plane reads started coming back empty once org-scoping hooks +landed (#4251). + +`query.context` remains supported. When **both** are given, `options.context` wins. + + ### find Executes a structured query with filtering, sorting, pagination, and field selection. Returns an array of records. diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index 64d8d9ba91..1dd4344cf5 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -95,7 +95,7 @@ on the `find()` path: `top` is the exception that *is* honored — the engine normalises it to `limit`. The #4286 sweep (ADR-0049 enforce-or-remove) settled every other declared-but-inert -member. **Removed** — tombstoned in `@objectstack/spec` 18, so a query carrying one +member. **Removed** — tombstoned in `@objectstack/spec` 17, so a query carrying one fails to parse with the upgrade prescription and authoring it is a `tsc` error: `joins` (related records are read through `expand`), `windowFunctions` (a SQL-driver door remains: `SqlDriver.findWithWindowFunctions()`), `cursor` (express the keyset as @@ -723,7 +723,7 @@ the driver's raw rows. ### Distinct -`query.distinct` was **removed in `@objectstack/spec` 18** (#4286): no driver ever +`query.distinct` was **removed in `@objectstack/spec` 17** (#4286): no driver ever rendered `SELECT DISTINCT`, and the flag's only observable effect was mis-wired — it silently suppressed the REST list count (`total`/`hasMore` degraded to a page-local estimate) while still returning duplicate rows. The key is tombstoned and @@ -793,7 +793,7 @@ expansion ignores them. ### Joins — removed (#4286) -`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049 +`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049 enforce-or-remove): no driver ever read it, so a query carrying `joins` silently ran as a single-table query. The key is tombstoned — authoring it is a `tsc` error, and a query that still carries it (even as an empty array) fails to parse with the upgrade @@ -805,7 +805,7 @@ joined in application code. ### Window Functions — removed from the request surface (#4286) -`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286): `find()` +`query.windowFunctions` was **removed in `@objectstack/spec` 17** (#4286): `find()` never applied it, so every OVER clause it declared was silently dropped. The key is tombstoned, and the `WindowFunction` / `WindowSpec` / `WindowFunctionNode` exports left with it — they declared `field` / `over` / `frame` members that no executor ever @@ -871,7 +871,7 @@ every page full and every row real (objectui#3106, #4363). A query with no ### Keyset Pagination -`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): no driver ever +`query.cursor` was **removed in `@objectstack/spec` 17** (#4286): no driver ever implemented keyset pagination, so a cursor was accepted and ignored and every page came back identical — a caller looping "until `hasMore` is false" never terminated. The key is tombstoned (on `EngineQueryOptions` too) and `QueryBuilder.cursor()` was removed diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 11af8ae3d4..7eb98c58cf 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -291,7 +291,7 @@ Every route carries the `/api/v1` prefix. When project scoping is enabled each r - Client SDK supports bearer token header — but token validation requires the auth plugin - Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered - Fine-grained authorization (RLS, sharing) lives in `plugin-security` / `plugin-sharing`, not in the auth plugin. Territory-style access is expressed as an RLS dynamic-membership set (`ExecutionContext.rlsMembership`, e.g. `id in current_user.territory_account_ids`) rather than as a dedicated territory module -- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is always denied** (ADR-0056 D2). The deployment-wide opt-out is gone: `api.requireAuth` was retired in `@objectstack/spec` 18 (#3963) and is now a tombstoned key that fails validation rather than reopening the data plane. The single decision lives in `@objectstack/core` (`security/anonymous-deny.ts`, 401 `UNAUTHENTICATED`), and every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration instead: control-plane paths via the auth-gate allowlist, public forms via `publicFormGrant` (ADR-0056 Option A), share links via a capability token validated then read as SYSTEM, `book.audience: 'public'` reads via the audience gate, and MCP via an OAuth token or API key. +- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is always denied** (ADR-0056 D2). The deployment-wide opt-out is gone: `api.requireAuth` was retired in `@objectstack/spec` 17 (#3963) and is now a tombstoned key that fails validation rather than reopening the data plane. The single decision lives in `@objectstack/core` (`security/anonymous-deny.ts`, 401 `UNAUTHENTICATED`), and every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration instead: control-plane paths via the auth-gate allowlist, public forms via `publicFormGrant` (ADR-0056 Option A), share links via a capability token validated then read as SYSTEM, `book.audience: 'public'` reads via the audience gate, and MCP via an OAuth token or API key. - **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism). **A sharing rule must state its criteria** (#3896): all three write paths reject a match-all shape, a stored criteria-less rule matches nothing, and its materialised grants are revoked on the next reconcile. --- @@ -439,7 +439,7 @@ There is no MSW package in this repo — browser API mocking is a devDependency - [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). Canonical vocabulary only — legacy aliases removed from the enum (ADR-0090 D4); unset custom-object OWD resolves to `private` (ADR-0090 D1) - [x] Sharing Rule evaluator — criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); every authorable recipient maps 1:1 onto an enforced `expandRecipient` branch (`plugin-sharing/sharing-rule-service.ts`) — `user`, `team`, `position`, `business_unit`, and `unit_and_subordinates` (business-unit-subtree widening, ADR-0057 D5 / ADR-0090 D3). Under ADR-0078 enforce-or-remove, `criteria` is now the only rule *type* (owner-type rules were removed from the authoring surface because the static materialiser cannot track live membership), the `group` recipient was renamed to `team`, `guest` was removed, and `queue` stays reserved in the runtime contract but deliberately non-authorable - [x] Everyone-baseline suggestion — a permission set may set `isDefault: true` as the install-time suggestion to bind it to the built-in `everyone` position; resolved per-request as an additive baseline, no fallback cliff (ADR-0090 D5) -- [x] Default-deny for anonymous traffic — the global default-deny landed (ADR-0056 D2) and the `api.requireAuth` opt-out was then **removed** in `@objectstack/spec` 18 (#3963): the key is tombstoned and rejected at parse time, the deny decision is centralised in `@objectstack/core` `security/anonymous-deny.ts`, and public forms self-authorize via `publicFormGrant` (Option A) +- [x] Default-deny for anonymous traffic — the global default-deny landed (ADR-0056 D2) and the `api.requireAuth` opt-out was then **removed** in `@objectstack/spec` 17 (#3963): the key is tombstoned and rejected at parse time, the deny decision is centralised in `@objectstack/core` `security/anonymous-deny.ts`, and public forms self-authorize via `publicFormGrant` (Option A) - [ ] Studio RLS visual editor - [ ] Per-user×org permission cache - [ ] Audit UI / denied-access logging diff --git a/docs/v17-docs-sweep.md b/docs/v17-docs-sweep.md index fd497df566..af81f6636d 100644 --- a/docs/v17-docs-sweep.md +++ b/docs/v17-docs-sweep.md @@ -58,6 +58,7 @@ merely *references* changed code, use the `docs-accuracy-audit` workflow the | Node `18` as a floor | states an out-of-date prerequisite | engines-node-22 | | `PortalSchema`, `AuditConfig`, Capabilities-descriptor cluster, `FeatureFlagSchema`, `DEFAULT_*_ROUTES`, report `aria`/`performance`, `ReportColumn/GroupingSchema` | teaches a pruned cluster | prune-* family | | `GetTranslationsRequest` `namespace`/`keys` filters | teaches the dropped filters | i18n-translations-request-drop-phantom-filters | +| `` `@objectstack/spec` 18 `` (any v17 removal dated to **18**) | dates a removal that ships in **17** to the next major — the reader plans for it a release late and their upgrade breaks. Check the *number*, not just the surface name: `spec-changes.json` `toMajor` is the arbiter | #4286, #3963 | ## Run log @@ -108,4 +109,27 @@ merely *references* changed code, use the `docs-accuracy-audit` workflow the - **Not yet swept:** `examples/**` inline prose and `docs/**` (internal); lower-priority — user-facing `content/docs` + `skills` covered first. +### 2026-08-01 — run 3 (version-number pass, #4476) + +- **Watermark:** framework `0f9faa2` (origin/main, post-#4489). +- **Fingerprint added:** the `@objectstack/spec` 18 row above. Runs 1-2 matched on + *surface names* and so read straight past a passage that named the right surface + and the wrong release. The number is the actionable half of a removal notice. +- **Fixed (drift → corrected):** 17 passages dating v17 removals to 18 → 17. + `spec-changes.json` gives `toMajor: 17` for all five surfaces involved, and this + tree is `17.0.0-rc.1` / `PROTOCOL_VERSION = '17.0.0'` with the keys already + `[RETIRED]` in `authorable-surface.json` — a removal cannot already be retired in + a 17 build and also ship in 18. + - `protocol/objectql/query-syntax.mdx` (5) · `data-modeling/queries.mdx` (4) · + `deployment/troubleshooting.mdx` (1) — the #4286 query surfaces. + - `skills/objectstack-query/SKILL.md` (3), `rules/aggregation.md` (1), + `rules/pagination.md` (1) — same wrong number in the **agent-facing** skill, + which #4476's fingerprint list did not cover. Highest-leverage of the set: an + agent authoring queries reads these as ground truth. + - `releases/implementation-status.mdx` (2) — same error shape on a *different* + change, `api.requireAuth` (#3963), also `toMajor: 17`. +- **Method note for the next run:** #4476 listed nine locations; a bare-pattern grep + found seventeen. Grep the pattern repo-wide (including `skills/`), do not work a + fingerprint list file-by-file. + diff --git a/packages/metadata-core/src/protocol-handshake.test.ts b/packages/metadata-core/src/protocol-handshake.test.ts index 9d3e395f02..4afe818d0f 100644 --- a/packages/metadata-core/src/protocol-handshake.test.ts +++ b/packages/metadata-core/src/protocol-handshake.test.ts @@ -64,16 +64,65 @@ describe('rangeAdmitsMajor', () => { expect(rangeAdmitsMajor('workspace:*', 11)).toBeNull(); }); - it('bounds pathological input (ReDoS-safe) without a slow scan', () => { + it('bounds pathological input (ReDoS-safe) without catastrophic backtracking', () => { // The engines string is externally authored; the comparator/hyphen parsing // must not degrade on adversarial input (CodeQL alerts 837/838). - const overlong = '<' + '\t'.repeat(100_000); - const hyphenBomb = 'a\t-\t' + '\t'.repeat(100_000); - const start = performance.now(); - expect(rangeAdmitsMajor(overlong, 11)).toBeNull(); - expect(rangeAdmitsMajor(hyphenBomb, 11)).toBeNull(); - expect(rangeAdmitsMajor('>=11.0.0 ' + ' '.repeat(100_000) + '<13.0.0', 11)).toBeNull(); - expect(performance.now() - start).toBeLessThan(50); + // + // The adversarial shapes: an overlong comparator, a hyphen-range "bomb", and + // a comparator pair separated by a huge whitespace run. + const shapes = (scale: number) => [ + '<' + '\t'.repeat(scale), + 'a\t-\t' + '\t'.repeat(scale), + '>=11.0.0 ' + ' '.repeat(scale) + '<13.0.0', + ]; + + // 1. Behaviour: every shape is *unrecognized*, never a false rejection. + // This is the assertion that actually pins the contract. + for (const input of shapes(100_000)) { + expect(rangeAdmitsMajor(input, 11)).toBeNull(); + } + + // 2. Cost: the parse must stay linear in the input length. + // + // This deliberately asserts *no absolute wall-clock bound*. The previous + // 50ms ceiling measured machine load rather than the parser: under the + // full-repo run (~130 parallel turbo tasks) it exceeded 50ms on a healthy + // tree and reddened PRs that never touched this package (#4485). + // + // What the guard is really for is catastrophic backtracking — a regression + // that makes parsing *superlinear* in the input. So measure the scaling + // instead: the same shapes at 1x and 8x length. Load largely cancels out of + // a ratio, and min-of-N discards the samples the scheduler interrupted. + // + // Healthy (linear) parsing tracks the input at ~8x; measured repeatedly at + // 8.3-8.5x. The 40x ceiling therefore keeps ~5x headroom over healthy while + // still catching even a merely *quadratic* regression (which lands near + // 64x), let alone an exponential one — which would not finish at all. + // + // The two timings are taken back-to-back inside one iteration and the ratio + // is reduced by *minimum*, not the timings independently: a scheduler steal + // that lands in only one of the two windows would skew a ratio built from + // separately-minimised timings (observed reddening at 3x CPU + // oversubscription), whereas the cheapest single pair is the one iteration + // that ran cleanest end to end. + // + // NB: a ratio of pathological-to-benign input would NOT work here: a benign + // 16-char range parses ~300x faster than a 100k-char one purely because it + // is 100k characters shorter, which is linear scaling behaving correctly. + const small = shapes(100_000); + const big = shapes(800_000); + const scan = (inputs: readonly string[]): number => { + const t = performance.now(); + for (const input of inputs) rangeAdmitsMajor(input, 11); + return performance.now() - t; + }; + + for (let i = 0; i < 10; i++) scan(small); // warm the JIT before measuring + let ratio = Infinity; + for (let i = 0; i < 20; i++) { + ratio = Math.min(ratio, scan(big) / scan(small)); + } + expect(ratio).toBeLessThan(40); }); }); diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index ea143c31ee..41dfd1f9cc 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -31,228 +31,156 @@ const stack = defineStack({ ## Flow Types -ObjectStack supports three types of flows: +`type` declares how a flow starts: -### 1. Autolaunched Flows -Triggered automatically by record changes: +| `type` | Starts when | +|:---|:---| +| `record_change` | a record is created / updated / deleted — bound on the `start` node | +| `schedule` | a cron schedule fires | +| `screen` | a user runs it interactively and supplies input | +| `autolaunched` | another flow, an action or an API call invokes it | +| `api` | it is exposed as an API-callable flow | -```typescript -const autoFlow = defineFlow({ - name: 'welcome_email', - type: 'autolaunched', - trigger: { - object: 'user', - when: 'after_insert', - }, - steps: [ - { - type: 'action', - action: 'send_email', - inputs: { - to: '{!trigger.record.email}', - subject: 'Welcome to ObjectStack!', - body: 'Hello {!trigger.record.name}...', - }, - }, - ], -}); -``` +## Flow Structure + +A flow is a **directed graph**: a flat list of `nodes` joined by a flat list of +`edges`. Nodes never contain child steps — branching, looping and error paths are +all expressed as edges between top-level nodes. -### 2. Screen Flows -Interactive flows with user input: +The record-change binding lives on the `start` node's `config` +(`{ objectName, triggerType, condition }`), not at the flow top level. ```typescript -const screenFlow = defineFlow({ - name: 'create_opportunity', - type: 'screen', - steps: [ +const escalateCase = { + name: 'escalate_high_priority_case', + label: 'Escalate High Priority Case', + type: 'record_change', + version: 1, + status: 'active', + + nodes: [ { - type: 'screen', - fields: [ - { name: 'account_id', label: 'Account', type: 'lookup', object: 'account' }, - { name: 'amount', label: 'Amount', type: 'currency' }, - { name: 'close_date', label: 'Close Date', type: 'date' }, - ], + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'crm_case', + triggerType: 'record-after-write', // created OR updated + }, }, { - type: 'record_create', - object: 'opportunity', - fields: { - account_id: '{!screen.account_id}', - amount: '{!screen.amount}', - close_date: '{!screen.close_date}', - stage: 'prospecting', + id: 'check_priority', + type: 'decision', + label: 'Is High Priority?', + // Conditions are bare CEL — no braces. Each `label` MUST match an + // out-edge's `label` exactly, or the branch cannot route (#4414). + config: { + conditions: [ + { label: 'High', expression: "record.priority == 'high'" }, + { label: 'Otherwise', expression: 'true' }, + ], }, }, - ], -}); -``` - -### 3. Scheduled Flows -Run on a schedule (cron syntax): - -```typescript -const scheduledFlow = defineFlow({ - name: 'daily_report', - type: 'scheduled', - schedule: '0 9 * * *', // Every day at 9 AM - steps: [ { - type: 'query', - object: 'order', - filters: [ - { field: 'created_at', operator: 'yesterday' }, - ], - output: 'orders', + id: 'load_owner', + type: 'get_record', + label: 'Load Owner', + config: { + objectName: 'sys_user', + filter: { id: '{record.owner_id}' }, + fields: ['id', 'name', 'email'], + outputVariable: 'owner', + }, }, { - type: 'action', - action: 'send_email', - inputs: { - to: 'admin@company.com', - subject: 'Daily Orders Report', - body: 'Total orders: {!orders.length}', + id: 'flag_case', + type: 'update_record', + label: 'Flag Case', + config: { + objectName: 'crm_case', + filter: { id: '{record.id}' }, + // Field values interpolate — braces required. + fields: { escalated: true, escalation_note: 'Escalated to {owner.name}' }, }, }, + { id: 'end', type: 'end', label: 'End' }, ], -}); -``` - -## Flow Steps - -### Record Operations - -```typescript -// Create record -{ - type: 'record_create', - object: 'contact', - fields: { - name: '{!input.name}', - email: '{!input.email}', - }, - output: 'new_contact', -} - -// Update record -{ - type: 'record_update', - object: 'account', - recordId: '{!trigger.recordId}', - fields: { - status: 'active', - }, -} -// Delete record -{ - type: 'record_delete', - object: 'task', - recordId: '{!input.taskId}', -} + edges: [ + { id: 'e1', source: 'start', target: 'check_priority', type: 'default' }, + // Branching is an edge, not a nested step list. A decision routes by + // matching its branch `label` to an out-edge `label`. + { id: 'e2', source: 'check_priority', target: 'load_owner', label: 'High', type: 'conditional' }, + { id: 'e3', source: 'check_priority', target: 'end', label: 'Otherwise', type: 'conditional' }, + { id: 'e4', source: 'load_owner', target: 'flag_case', type: 'default' }, + { id: 'e5', source: 'flag_case', target: 'end', type: 'default' }, + ], +}; ``` -### Query Step +## Node Types -```typescript -{ - type: 'query', - object: 'opportunity', - filters: [ - { field: 'account_id', operator: 'eq', value: '{!trigger.record.account_id}' }, - { field: 'stage', operator: 'eq', value: 'closed_won' }, - ], - sort: [{ field: 'amount', direction: 'desc' }], - limit: 10, - output: 'opportunities', -} -``` +The built-in node type ids (`FLOW_BUILTIN_NODE_TYPES`, from `FlowNodeAction` in +`@objectstack/spec`): -### Decision (Conditional) Step +`start` · `end` · `decision` · `assignment` · `loop` · `create_record` · +`update_record` · `delete_record` · `get_record` · `http` · `notify` · `script` · +`screen` · `wait` · `subflow` · `map` · `connector_action` · `parallel_gateway` · +`join_gateway` · `boundary_event` -```typescript -{ - type: 'decision', - conditions: [ - { - label: 'High Value', - expression: '{!trigger.record.amount} > 10000', - steps: [ - { type: 'action', action: 'notify_sales_manager' }, - ], - }, - { - label: 'Medium Value', - expression: '{!trigger.record.amount} > 1000', - steps: [ - { type: 'action', action: 'assign_to_sales_rep' }, - ], - }, - ], - defaultSteps: [ - { type: 'action', action: 'auto_approve' }, - ], -} -``` +`type` is validated against the **live action registry** at `registerFlow()`, not +against a closed enum, so plugin-registered node types are equally legal. -### Loop Step +The registry is also why `FlowNodeAction` is not the whole list: the ADR-0031 +structured constructs **`parallel`** and **`try_catch`** ship built-in executors +(`builtin/parallel-node.ts`, `builtin/try-catch-node.ts`) without appearing in +that enum. See [Advanced Features](#advanced-features) below. -```typescript -{ - type: 'loop', - collection: '{!query_results}', - variable: 'item', - steps: [ - { - type: 'record_update', - object: 'task', - recordId: '{!item.id}', - fields: { - status: 'completed', - }, - }, - ], -} -``` +The CRUD quartet's `config` — the shape most often written from memory, and the +one this README used to get wrong: -### Custom Action Step +| Node | `config` keys | +|:---|:---| +| `get_record` | `objectName`, `filter`, `fields`, `limit`, `outputVariable` | +| `create_record` | `objectName`, `fields`, `outputVariable` | +| `update_record` | `objectName`, `filter`, `fields` — **no** `outputVariable`; the executor does not read one | +| `delete_record` | `objectName`, `filter` | -```typescript -{ - type: 'action', - action: 'calculate_tax', - inputs: { - amount: '{!opportunity.amount}', - region: '{!account.billing_region}', - }, - output: 'tax_amount', -} -``` +`filter` is an **object** of field/value pairs (`{ id: '{record.id}' }`), not an +array of `{ field, operator, value }` triples; operator objects such as +`{ "$ne": null }` are legal values. There is no `recordId` key — select by id +through `filter`. Unknown keys are rejected at `registerFlow()`. -## Variable Expressions +For every other node's `config`, and for loops, parallel blocks, subflows, waits +and error handling, see the maintained reference — **[Flows](/content/docs/automation/flows.mdx)**. +This README deliberately does not keep a second copy of that per-node reference. -Access variables in flow steps using `{!variable.path}` syntax: +## Expressions -```typescript -// Trigger record fields -'{!trigger.record.name}' -'{!trigger.record.account.industry}' +A flow mixes **two dialects**, and the rule is short: **every condition is CEL; +braces are for values.** -// Screen input -'{!screen.fieldName}' +| Where | Dialect | Write it like | +|:---|:---|:---| +| Start-node `condition` | CEL — bare, no braces | `record.amount > 500` | +| Edge `condition` | CEL — bare, no braces | `record.status == 'open'` | +| Decision `conditions[].expression` | CEL — bare, no braces | `order_amount > 10000` | +| Field values in `create_record` / `update_record` | Interpolation — braces required | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | -// Query results -'{!query_results[0].name}' -'{!query_results.length}' +Value bindings: `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, +`{TODAY()}`, `{TODAY() + 90}`. -// Step outputs -'{!step_name.output_field}' +The two failure modes to memorize: -// System variables -'{!now}' -'{!today}' -'{!currentUser.id}' -``` +1. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the + literal text into the field. Write `'{TODAY() + 7}'`. +2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Since #4336 + conditions reject this loudly: `registerFlow()` / `objectstack validate` + refuse the flow with a CEL error naming the reference. Before that they were + compared as text and were silently always-true or always-false. + +There is no `{!…}` dialect. That is Salesforce syntax; the platform has never +parsed it. ## Service API @@ -326,46 +254,64 @@ POST /api/v1/automation/triggers/:name # Trigger a flow ## Advanced Features -### Parallel Execution +`loop`, `parallel` and `try_catch` are **structured control-flow constructs** +(ADR-0031). Each owns its body as a single-entry/single-exit **region** carried in +`config` — a nested `{ nodes, edges }` sub-graph, *not* a `steps` array — so the +outer graph stays acyclic. A region runs in the enclosing variable scope; the +container's ordinary out-edges are the continuation. + +### Loop ```typescript -const flow = defineFlow({ - name: 'parallel_processing', - steps: [ - { - type: 'parallel', - branches: [ - { - name: 'branch1', - steps: [{ type: 'action', action: 'process_a' }], - }, - { - name: 'branch2', - steps: [{ type: 'action', action: 'process_b' }], - }, - ], +{ + id: 'notify_each', + type: 'loop', + label: 'For each task', + config: { + collection: '{tasks}', // template/variable resolving to an array + iteratorVariable: 'task', // current item, visible inside the body + indexVariable: 'i', // optional zero-based index + maxIterations: 500, // hard cap (clamped to the engine ceiling) + body: { + nodes: [{ id: 'send', type: 'notify', label: 'Notify', config: { /* … */ } }], + edges: [], }, - ], -}); + }, +} +``` + +### Parallel Execution + +Branches run concurrently and join implicitly when all complete — there is no +author-visible split/join gateway. + +```typescript +{ + id: 'fan_out', + type: 'parallel', + label: 'Notify in parallel', + config: { + branches: [ // ≥ 2 regions + { name: 'Email', nodes: [{ id: 'email', type: 'notify', label: 'Email', config: { /* … */ } }], edges: [] }, + { name: 'Slack', nodes: [{ id: 'slack', type: 'notify', label: 'Slack', config: { /* … */ } }], edges: [] }, + ], + }, +} ``` ### Error Handling ```typescript { + id: 'guarded', type: 'try_catch', - trySteps: [ - { type: 'action', action: 'risky_operation' }, - ], - catchSteps: [ - { - type: 'action', - action: 'send_error_notification', - inputs: { - error: '{!error.message}', - }, - }, - ], + label: 'Charge with fallback', + config: { + try: { nodes: [{ id: 'charge', type: 'http', label: 'Charge', config: { /* … */ } }], edges: [] }, + catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] }, + errorVariable: '$error', + retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 }, + }, } ``` @@ -373,28 +319,40 @@ const flow = defineFlow({ ```typescript { + id: 'validate', type: 'subflow', - flowName: 'validate_address', - inputs: { - street: '{!input.street}', - city: '{!input.city}', + label: 'Validate Address', + config: { + flowName: 'validate_address', + input: { street: '{input.street}', city: '{input.city}' }, + outputVariable: 'validated_address', }, - output: 'validated_address', } ``` -### Wait Step +### Wait + +`wait` suspends the run durably. Its contract is the node-level +`waitEventConfig` block — **not** `config`: ```typescript { + id: 'hold', type: 'wait', - duration: { hours: 24 }, - nextSteps: [ - { type: 'action', action: 'send_reminder' }, - ], + label: 'Wait 24h', + waitEventConfig: { + eventType: 'timer', // 'timer' | 'signal' | 'webhook' | 'manual' | 'condition' + timerDuration: 'PT24H', // ISO 8601 duration + }, } ``` +The node resumes down its ordinary out-edges; there is no `nextSteps` key. + +> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the +> protocol as the **interop** representation and map onto these constructs on +> import/export — they are not the native authoring model. + ## Best Practices 1. **Keep Flows Simple**: Break complex logic into multiple flows diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index 26de4c66ab..7bd93ea504 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -278,7 +278,7 @@ Sort with `orderBy` — an array of sort nodes: ### Keyset Pagination (Performant) -> ⛔ **`query.cursor` was REMOVED in `@objectstack/spec` 18 (#4286).** No +> ⛔ **`query.cursor` was REMOVED in `@objectstack/spec` 17 (#4286).** No > engine or driver ever read it — a query carrying `cursor` silently returned > **page 1 forever**. The key is tombstoned (a query carrying it fails to > parse with the prescription) and `QueryBuilder.cursor()` is gone. Do keyset @@ -443,7 +443,7 @@ Load related records through lookup/master_detail fields: ## Joins -> ⛔ **REMOVED in `@objectstack/spec` 18 (#4286, ADR-0049).** `query.joins` +> ⛔ **REMOVED in `@objectstack/spec` 17 (#4286, ADR-0049).** `query.joins` > (and the `JoinNode`/`JoinType`/`JoinStrategy` vocabulary) is gone from the > `QueryAST` schema — no engine or driver ever consumed it, so it only ever > declared a capability that did not run. The key is tombstoned: authoring it @@ -504,7 +504,7 @@ auto-default of name/title + short-text fields), resolved server-side. ## Window Functions (Analytics) -> ⛔ **REMOVED from the request surface in `@objectstack/spec` 18 (#4286).** +> ⛔ **REMOVED from the request surface in `@objectstack/spec` 17 (#4286).** > `query.windowFunctions` is gone from the `QueryAST` schema — the engine > never routed it to any driver, so every OVER clause it declared was > silently dropped. The key is tombstoned (a query carrying it fails to diff --git a/skills/objectstack-query/rules/aggregation.md b/skills/objectstack-query/rules/aggregation.md index 98157cd7fe..d3fd174df7 100644 --- a/skills/objectstack-query/rules/aggregation.md +++ b/skills/objectstack-query/rules/aggregation.md @@ -157,7 +157,7 @@ const [active] = await engine.aggregate('user', { ## Window Functions -> ⛔ **REMOVED in `@objectstack/spec` 18 (#4286, ADR-0049).** The `QueryAST` +> ⛔ **REMOVED in `@objectstack/spec` 17 (#4286, ADR-0049).** The `QueryAST` > schema no longer declares `windowFunctions` — the engine never routed the > property to any driver, so it was silently dropped. The key is tombstoned: > a query carrying it fails to parse with the upgrade prescription. The one diff --git a/skills/objectstack-query/rules/pagination.md b/skills/objectstack-query/rules/pagination.md index 6cb5312dde..6ffa5a79e6 100644 --- a/skills/objectstack-query/rules/pagination.md +++ b/skills/objectstack-query/rules/pagination.md @@ -9,7 +9,7 @@ Guide for implementing pagination in ObjectStack queries. | Offset | UI page navigation, small datasets | Simple, random page access | Slow on large offsets, drift on inserts | | Keyset (manual `where`) | Infinite scroll, real-time feeds | Consistent results, O(1) performance | No random page access | -> ⛔ **The `cursor` query property was REMOVED in `@objectstack/spec` 18 +> ⛔ **The `cursor` query property was REMOVED in `@objectstack/spec` 17 > (#4286).** No engine or driver ever read it: a query carrying `cursor` > silently returned **page 1 forever**. The key is tombstoned — a query > carrying it fails to parse with the prescription — and