From 4f078fc7c47aa2069f11e6e530eb1fa80a4e00bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:59:18 +0000 Subject: [PATCH 1/7] test(metadata-core): make the ReDoS guard load-insensitive (#4485) The ReDoS assertion in protocol-handshake.test.ts bounded the pathological scan with an absolute 50ms wall clock. Under the full-repo run (~130 parallel turbo tasks) that ceiling measures machine load rather than the parser: it exceeded 50ms on a healthy tree and reddened PRs that never touched this package, leaving the diagnosis cost to whoever happened to be running. The underlying guard is real and stays (CodeQL 837/838). What changes is how it is measured. The three `toBeNull()` behavioural assertions -- the actual contract, that adversarial input is unrecognized rather than falsely rejected -- are kept and now stand on their own. The wall-clock proxy is replaced by a scaling check: the same adversarial shapes at 1x and 8x length, asserting the parse stays linear in the input. Load largely cancels out of a ratio, which is what makes the criterion load-insensitive. Measured: healthy parsing tracks the input at 8.3-8.5x, stable across runs. The 40x ceiling keeps ~5x headroom while still catching a merely quadratic regression (~64x), let alone an exponential one, which would not finish. Two measurement details are load-hardening, both established empirically: timings are taken back-to-back within one iteration and reduced by minimum *ratio* rather than minimising each timing independently (a scheduler steal landing in only one window skewed the latter, observed reddening at 3x CPU oversubscription); and the JIT is warmed so the baseline is not inflated. Note the pathological-to-benign ratio suggested on the issue does not work here: a benign 16-char range parses ~300x faster than a 100k-char one purely because it is 100k characters shorter, so it would fail on a healthy machine. Verification: 5/5 consecutive clean runs, plus 8/8 under 3x CPU oversubscription (the shape that reproduced the original failure). Fixes #4485 --- .../src/protocol-handshake.test.ts | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) 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); }); }); From f4d899032a56e7c77dd7ba17534a2ffdc0d7c2a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 11:02:32 +0000 Subject: [PATCH 2/7] docs: date the v17 query-surface removals to 17, not 18 (#4476) Seventeen passages dated v17 removals to `@objectstack/spec` 18. They ship in 17 -- this train. The number is the actionable half of a removal notice: a reader on 16 asking whether upgrading to 17 breaks their cursor-paginated loop was told the removal is a major away, so they plan for it later and the upgrade breaks. Evidence that 17 is correct: `spec-changes.json` carries `toMajor: 17` for data.query.{cursor,distinct,joins,windowFunctions} and stack.api.requireAuth; this tree is 17.0.0-rc.1 with `PROTOCOL_VERSION = '17.0.0'`; and the keys are already `[RETIRED]` in `authorable-surface.json`. A removal cannot be retired in a 17 build and also ship in 18. #4476 fingerprinted nine locations. Grepping the bare pattern -- which the issue itself recommended over working the list file-by-file -- found eight more: - The nine listed: query-syntax.mdx (4), queries.mdx (4), troubleshooting.mdx. - query-syntax.mdx:98, the #4286 sweep summary paragraph, same error. - skills/objectstack-query/ (5): SKILL.md and the aggregation/pagination rules. These are agent-facing and the highest-leverage of the set -- an agent authoring a query reads them as ground truth. Body prose only, so the frontmatter-derived listings that build-skill-docs.ts generates are unchanged. - implementation-status.mdx (2): the same error shape on a different change, `api.requireAuth` (#3963), which spec-changes.json also puts at toMajor 17. Also records the fingerprint in the sweep run log, as #4476 asks. Runs 1-2 matched on surface names and so read past passages that named the right surface and the wrong release; the new row tells the next run to check the number, with spec-changes.json `toMajor` as the arbiter. Fixes #4476 --- content/docs/data-modeling/queries.mdx | 8 +++---- content/docs/deployment/troubleshooting.mdx | 2 +- .../docs/protocol/objectql/query-syntax.mdx | 10 ++++---- .../docs/releases/implementation-status.mdx | 4 ++-- docs/v17-docs-sweep.md | 24 +++++++++++++++++++ skills/objectstack-query/SKILL.md | 6 ++--- skills/objectstack-query/rules/aggregation.md | 2 +- skills/objectstack-query/rules/pagination.md | 2 +- 8 files changed, 41 insertions(+), 17 deletions(-) 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/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/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 From a090992281fac06f93678bd41409c849f363d119 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 11:03:27 +0000 Subject: [PATCH 3/7] docs: restore the trailing options arg on IDataEngine reads (#4486) The `IDataEngine` block in data-engine.mdx wrote all four read methods with two parameters, dropping the trailing `options?: BaseEngineOptions` that the real contract gives each of them (packages/spec/src/contracts/data-engine.ts :70/85/89/90). The write methods in the same block each carried their own `options`, so the block taught exactly the wrong model -- "writes take options, reads do not" -- and that is the misconception #4251 existed to fix. The parameter is not incidental: the same `{ context }` object is correct as insert's 3rd argument but was SILENTLY DROPPED as find's, so an intended `isSystem` bypass vanished and control-plane reads came back empty once org-scoping hooks landed. Anyone -- human or agent -- writing code from this block was being led back to the pre-#4251 shape, against a failure mode that raises no error. Adds `BaseEngineOptions` to the block's import list (the contract imports it from the same module), and a callout recording the precedence the contract states: `query.context` remains supported, and when both are given `options.context` wins. `content/docs/kernel/contracts/` is hand-written -- only `content/docs/ references/` is generated -- so no generator run is involved. Fixes #4486 --- content/docs/kernel/contracts/data-engine.mdx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) 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. From bed4cf4980daca1ed699b909a50927d7dec3ffce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 11:09:11 +0000 Subject: [PATCH 4/7] docs(service-automation): rewrite the README against the real flow DSL (#4452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's flow sections described a DSL that has never existed. Every node type name was wrong (`record_create` vs `create_record`, `query` vs `get_record`), the interpolation dialect was Salesforce's `{!…}` which the platform does not parse, and branching/looping/error handling were written as nested `steps` arrays -- a shape the schema has no key for. Nothing in it ran. Since #4414 + #4439 this stopped being merely useless: `conditions[].expression` is on the expression ledger, so the README's `'{!trigger.record.amount} > 10000'` is now REFUSED at `registerFlow()` / `objectstack validate`. An author copying it got a CEL error whose advice ("drop the braces") did not fix their node, because the node's entire shape was wrong too. Rewritten from the schemas and executors, not from the old README: - Flows are a DAG of flat `nodes` + `edges`. Branching is an edge, and a decision routes by matching its branch `label` to an out-edge `label` -- the #4414 trap, called out inline. - The record-change binding lives on the `start` node's config (`{ objectName, triggerType, condition }`), not at the flow top level. - CRUD config table: `objectName` (not `object`), `filter` as an OBJECT (not a `filters` array of triples), `outputVariable` (not `output`), no `recordId`. Notes that `update_record` has no `outputVariable` -- the executor reads none. - Both expression dialects stated with the rule that disambiguates them: every condition is bare CEL, braces are for values. Says plainly that `{!…}` is not a dialect here. - `loop` / `parallel` / `try_catch` given their real ADR-0031 region shape (`config.body`, `config.branches`, `config.try`/`catch`), and `wait` its node-level `waitEventConfig` block rather than the invented `duration` + `nextSteps`. - Flow `type` values corrected (`schedule`, not `scheduled`). Per the issue's preference, the per-node reference is NOT duplicated here: the README now points at content/docs/automation/flows.mdx, the maintained one. Keeping a second hand-written node catalog in a package README is the #4027/#3569 shape that produced this drift. `pnpm --filter @objectstack/spec check:generated`: all 8 artifacts up to date. Fixes #4452 --- .../services/service-automation/README.md | 411 ++++++++---------- 1 file changed, 182 insertions(+), 229 deletions(-) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index ea143c31ee..de57de2dab 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -31,228 +31,151 @@ 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 CRUD quartet's `config` — the shape most often written from memory, and the +one this README used to get wrong: -```typescript -{ - type: 'loop', - collection: '{!query_results}', - variable: 'item', - steps: [ - { - type: 'record_update', - object: 'task', - recordId: '{!item.id}', - fields: { - status: 'completed', - }, - }, - ], -} -``` +| 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` | -### Custom Action Step +`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()`. -```typescript -{ - type: 'action', - action: 'calculate_tax', - inputs: { - amount: '{!opportunity.amount}', - region: '{!account.billing_region}', - }, - output: 'tax_amount', -} -``` +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. -## Variable Expressions +## Expressions -Access variables in flow steps using `{!variable.path}` syntax: +A flow mixes **two dialects**, and the rule is short: **every condition is CEL; +braces are for values.** -```typescript -// Trigger record fields -'{!trigger.record.name}' -'{!trigger.record.account.industry}' +| 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}'` | -// Screen input -'{!screen.fieldName}' +Value bindings: `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, +`{TODAY()}`, `{TODAY() + 90}`. -// Query results -'{!query_results[0].name}' -'{!query_results.length}' +The two failure modes to memorize: -// Step outputs -'{!step_name.output_field}' +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 (before that they compared as text and were + silently always-true or always-false), so `registerFlow()` / `objectstack + validate` refuse the flow with a CEL error naming the reference. -// System variables -'{!now}' -'{!today}' -'{!currentUser.id}' -``` +There is no `{!…}` dialect. That is Salesforce syntax; the platform has never +parsed it. ## Service API @@ -326,46 +249,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 +314,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 From 41fab442b18de742a9fda041eb0fc2cd06ea405b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 12:10:51 +0000 Subject: [PATCH 5/7] chore: add release-nothing changeset for the v17 verification docs/test fixes The Check Changeset gate requires every PR to add at least one `.changeset/*.md` relative to base. This branch touches only `.md`/`.mdx` prose and one `.test.ts` file -- verified against the diff, no package source, no public export, no protocol change -- so the empty-frontmatter form is the accurate declaration: it publishes nothing. --- .changeset/v17-verification-defects-docs.md | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/v17-verification-defects-docs.md 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. From 7921048acd630afa30e6fd5d0f3b30140d31b1e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 12:13:05 +0000 Subject: [PATCH 6/7] docs(service-automation): note that parallel/try_catch are built-in too (#4452) The Node Types section presented `FLOW_BUILTIN_NODE_TYPES` (i.e. the `FlowNodeAction` enum) as the built-in set, but the ADR-0031 structured constructs `parallel` and `try_catch` ship registered builtin executors without appearing in that enum -- which is exactly why `FlowNodeSchema.type` validates against the live action registry rather than a closed enum. Left as written, the list contradicted the Advanced Features section just below it. --- packages/services/service-automation/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index de57de2dab..66a4e85819 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -131,6 +131,11 @@ The built-in node type ids (`FLOW_BUILTIN_NODE_TYPES`, from `FlowNodeAction` in `type` is validated against the **live action registry** at `registerFlow()`, not against a closed enum, so plugin-registered node types are equally legal. +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. + The CRUD quartet's `config` — the shape most often written from memory, and the one this README used to get wrong: From eab5083660018a54caaa9c8342227f2bdf8d1869 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 12:13:23 +0000 Subject: [PATCH 7/7] docs(service-automation): fix run-on sentence in the expressions section (#4452) --- packages/services/service-automation/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 66a4e85819..41dfd1f9cc 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -175,9 +175,9 @@ The two failure modes to memorize: 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 (before that they compared as text and were - silently always-true or always-false), so `registerFlow()` / `objectstack - validate` refuse the flow with a CEL error naming the reference. + 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.