softr-vibe-coding 2.13.4 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [2.14.0] - 2026-10-06
8
+ - Release 2.14.0
9
+ - Add Softr runtime and Workflows facts verified on a 2026-09-18/19 production build
10
+
11
+ ## [2.13.5] - 2026-10-06
12
+ - Release 2.13.5
13
+ - Correct the MCP FILTER-condition claim and eight other conflicts found in the 2026-10-06 audit
14
+
7
15
  ## [2.13.4] - 2026-10-06
8
16
  - Release 2.13.4
9
17
  - Add in-block modal above Softr's bars (shadcn Dialog sits under the top bar)
package/README.md CHANGED
@@ -169,7 +169,7 @@ Create a contact form that creates records in our Airtable Contacts table
169
169
  softr-vibe-coding/
170
170
  ├── SKILL.md # Main skill
171
171
  │ # Workflow, code structure, visual baseline,
172
- │ # components, settings, 28 hard constraints
172
+ │ # components, settings, 29 hard constraints
173
173
  │
174
174
  ├── ui-ux-guidelines.md # Design reference
175
175
  │ # 26 sections: hierarchy, color, typography,
@@ -190,13 +190,22 @@ softr-vibe-coding/
190
190
  │ │ # browsing (Airtable/Sheets/Notion/Supabase),
191
191
  │ │ # Softr DB schema + record tools incl. deletes,
192
192
  │ │ # app management/scaffolding, Workflows suite
193
- │ │ # (26 tools, 418-node catalog), per-application
193
+ │ │ # (28 tools, 418-node catalog), per-application
194
194
  │ │ # MCP servers, auth, permissions; what the server
195
195
  │ │ # enforces on block data endpoints, "Preview as"
196
196
  │ │ # role testing, search-replace on 100KB+ blocks
197
197
  │ │ # (Sep 18 2026); Oct 1 2026: tool rename map,
198
198
  │ │ # push verification by sourceSha256, stub tools
199
- │ │ # after a resume, update_field/update_table fixes
199
+ │ │ # after a resume, update_field/update_table fixes;
200
+ │ │ # Oct 6 2026: MCP-written FILTER conditions are
201
+ │ │ # inert (set them in Studio), Workflows tools as
202
+ │ │ # workflow_*, denied by-id fetch per backend;
203
+ │ │ # Workflows engine facts (CUSTOM_CODE contract,
204
+ │ │ # string-array loops, sample-based validation,
205
+ │ │ # replace_node new ids, re-firing triggers,
206
+ │ │ # write modes, serialExecution, continueOnError),
207
+ │ │ # Softr DB row-gating recipe, what a push leaves
208
+ │ │ # alone, DATETIME create shape, offset paging
200
209
  │ ├── browser-checks.md # Checking a pushed block in a browser with
201
210
  │ │ # the agent-browser CLI (ask before installing):
202
211
  │ │ # preview cookie, shadow-DOM refs grepped in the
@@ -267,15 +276,22 @@ softr-vibe-coding/
267
276
  │ # select: as a module-scope identifier, the union-of-
268
277
  │ # selects read payload (a conditional select is not
269
278
  │ # privacy), Actions per table (Sep 18 2026);
270
- │ # block Visibility gates its endpoints (Oct 5 2026)
279
+ │ # block Visibility gates its endpoints (Oct 5 2026);
280
+ │ # every row carries its record id (Oct 6 2026)
271
281
  ├── reading.md # useRecords, filtering, sorting, pagination,
272
282
  │ # metrics, charts, current user; no detail-page
273
283
  │ # auto-scoping, useRecords ignores enabled:false,
274
- │ # server-side linked-record filters (Sep 18 2026)
284
+ │ # server-side linked-record filters (Sep 18 2026);
285
+ │ # where/orderBy aliases resolve per hook, operator
286
+ │ # semantics, filters fail open, userGroups poll,
287
+ │ # excluded useRecord = no record (Oct 6 2026)
275
288
  ├── writing.md # Mutations, sequential write queues, uploads,
276
- │ # linked record format, cross-table writes
289
+ │ # linked record format, cross-table writes;
290
+ │ # Actions register per table (Sep 18 2026)
277
291
  ├── fields.md # getFieldValue(), field type shapes, record
278
- │ # structure, debug utilities
292
+ │ # structure, debug utilities; date-only fields
293
+ │ # parsed as local dates, multi-value lookup shape
294
+ │ # (Oct 6 2026)
279
295
  ├── rest-api.md # useProxyFetch + useQuery (full docs)
280
296
  ├── softr-database.md # Native DB — field IDs, no rate limits
281
297
  ├── airtable.md # Column names, PAT vs OAuth, rate limits
@@ -334,7 +350,7 @@ The skill enforces these automatically, but good to know (verified live against
334
350
  - No `import React from 'react'` — use named imports (`import { useState } from "react"`)
335
351
  - Must use `export default function Block()`
336
352
  - Wrap layout in `<div className="container py-0"><div className="content">` for app/content blocks (house convention for width alignment with native blocks) — the platform default is actually full width, so full-bleed marketing blocks (heroes, banners, footers) legitimately omit the wrappers and own their gutters
337
- - Only ONE `useRecords` call per **datasource** — but a block can connect to several sources; declare them with `datasource.define()` and pass `from:` on every hook
353
+ - One `useRecords` per **connection** (house rule) — a block can connect to several sources, including the same table twice; declare them with `datasource.define()` and pass `from:` on every hook
338
354
  - `fetchNextPage` never in the render body (infinite loop) — call it from an event handler (Load More `onClick`) or a guarded `useEffect`
339
355
  - All hooks declared before any conditional `return` — React error #310
340
356
  - Every field value rendered in JSX must pass through `getFieldValue()`
package/SKILL.md CHANGED
@@ -72,6 +72,10 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
72
72
  - Multi-datasource block: every `select:` / `fields:` value is a **plain module-scope identifier** — no ternary, no inline `q.select({...})` inside the hook options (the query returns `fields: {}`; Hard Constraint 24)
73
73
  - Detail page: the record is fetched with `useRecord({ select, recordId, enabled: !!recordId })` using `useCurrentRecordId()`, and the code checks `data.id === recordId` before rendering — never `useRecords({ count: 1 })`, which returns the table's FIRST row (Hard Constraint 25)
74
74
  - No list query relies on `enabled: false` — `useRecords` fetches anyway; conditional list queries live in a child component mounted only when needed, or carry a match-nothing `where` (Hard Constraint 26)
75
+ - Every alias a hook's `where` / `orderBy` names is in **that hook's own** `select` — anything else crashes the block at runtime (Hard Constraint 29)
76
+ - Every `where` has been **seen to narrow** the result (compare row counts with and without it) — a filter on a field outside the connection's read-select union is silently ignored and returns everything ([datasources/reading.md](datasources/reading.md#filters-fail-open))
77
+ - Role checks read `window.__softr_current_user.userGroups` from state with a bounded poll, and role-dependent UI waits until it settles — the global has no change event and an early `[]` means not loaded yet ([datasources/reading.md](datasources/reading.md#current-user))
78
+ - Date-only values are parsed with `toLocalDate()`, never `new Date()` — midnight UTC renders a day early west of Greenwich ([datasources/fields.md](datasources/fields.md#date-only-fields-arrive-as-midnight-utc))
75
79
  - No field is "hidden" from some viewers by a conditional / second `select` on the same connection — the browser receives the union of every read select on that connection (Hard Constraint 23)
76
80
  - All imports use named imports (no `import React from 'react'`)
77
81
  - `export default function Block()` is present
@@ -614,9 +618,15 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
614
618
  changing the wrapper to take the hook's *result* instead.)
615
619
  11. **Airtable, Notion, Google Sheets: use field NAMES, not IDs** — `q.select()` values are field names for these three sources; Softr Database and Supabase use field IDs (Supabase = SQL column name). Getting this wrong fails silently: the block compiles and saves, then renders empty. See [datasources/airtable.md](datasources/airtable.md).
616
620
  12. **Record fields nested under `fields`** — Access via `record.fields.alias`, not `record.alias`.
617
- 13. **ONE `useRecords` per datasource** — filter client-side rather than issuing several queries against
618
- the same table. A block CAN connect to multiple data sources and call `useRecords` once per source;
619
- declare them with `datasource.define()` and pass `from:` on every hook. See
621
+ 13. **One `useRecords` per connection [house]** — not a documented platform limit (Softr's developer
622
+ guide shows one `useRecords` per datasource but states no rule; checked 2026-10-06). Read each
623
+ connection once and filter client-side when the table is small and every viewer may see all of
624
+ it anyway. When the data must differ, add connections rather than queries: a second table gets
625
+ its own connection, and so does a private read of the same table (Hard Constraint 23). A
626
+ server-side `where` is better for large tables and linked children, but it is a request
627
+ parameter, never access control. A query mounted in a child component (Hard Constraint 26) is
628
+ that connection's one read; don't also read the connection in the parent. Declare connections
629
+ with `datasource.define()` and pass `from:` on every hook. See
620
630
  [datasources/multi-datasource.md](datasources/multi-datasource.md). Multiple `useMetric` calls OK.
621
631
  14. **React functional components only** — No class components.
622
632
  15. **Do NOT `import React from 'react'`** — Use named imports for hooks.
@@ -650,7 +660,9 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
650
660
  A push that returns `errors: null` can still have left public write access on the block.
651
661
  **If any action is still broader than intended, report it WITH its severity and let the builder decide.**
652
662
  Check the page's own VIEW permission first (`application_page_get_permissions`): a page gated to logged-in
653
- users makes an open action housekeeping, a public page makes it a real hole. Surface the list
663
+ users makes an open action housekeeping, a public page makes it a real hole. Report the block's own
664
+ Visibility with it -- it gates the block's reads and sets ADD_RECORD's default, but whether it
665
+ refuses writes on its own is untested. Surface the list
654
666
  either way -- page, block, action type, data source -- and note that a human sets them on the
655
667
  block's Actions tab. Do not unilaterally block a publish; it is not your app.
656
668
  22. **Blocks cannot import each other -- cross-block consistency is discipline, not architecture [house]**
@@ -700,6 +712,11 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
700
712
  them takes global CSS across Softr's page structure as well as print CSS in the block. Never
701
713
  an in-page "print view" either (Leo rejected it by name). Verified live 2026-09-30. See
702
714
  [references/printing.md](references/printing.md).
715
+ 29. **`where` / `orderBy` may only name aliases from the same hook's `select`** -- aliases resolve per
716
+ hook, not per connection. Naming any other alias crashes the whole block at runtime ("Could not
717
+ find an alias for subject \"undefined\"") although the push compiles clean. Seen live
718
+ 2026-09-18 on a `useMetric`. See
719
+ [datasources/reading.md](datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select).
703
720
 
704
721
  ## Style Conventions
705
722
 
@@ -32,13 +32,35 @@ Property priority: `label` first (most common in Softr formatted fields), then `
32
32
 
33
33
  Apply `getFieldValue()` everywhere you read fields:
34
34
  - Table cells, badges, tooltips: `<td>{getFieldValue(f.subject)}</td>`
35
- - Date parsing: `new Date(getFieldValue(f.dueDate))` -- raw value might be `{label: "2025-12-01"}`
35
+ - Date parsing: `toLocalDate(getFieldValue(f.dueDate))` for date-only fields ([below](#date-only-fields-arrive-as-midnight-utc)), `new Date(...)` for real timestamps -- raw value might be `{label: "2025-12-01"}`
36
36
  - Number parsing: `parseInt(getFieldValue(f.count), 10)` -- formula numbers come back as objects
37
37
  - String methods: `getFieldValue(f.status).toLowerCase()`
38
38
  - Inside `useMemo` normalizers, BEFORE storing into state -- prevents the object from propagating
39
39
 
40
40
  For companion helpers (`getLinkedNames`, `getLinkedItems`) used in helper block consumers, see [../references/helper-blocks.md](../references/helper-blocks.md).
41
41
 
42
+ ### Date-only fields arrive as midnight UTC
43
+
44
+ *Verified live 2026-09-18 (Softr Database).* A date field without a time arrives as midnight UTC.
45
+ `new Date()` parses it as UTC, so anywhere west of Greenwich (New York, for one) date-fns renders
46
+ the **previous day**. Parse date-only values as local dates, and keep the default parse for real
47
+ timestamps:
48
+
49
+ ```jsx
50
+ var DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]00:00(?::00(?:\.0+)?)?(?:Z|\+00:00)?)?$/;
51
+
52
+ function toLocalDate(raw) {
53
+ if (typeof raw === "string") {
54
+ var m = raw.match(DATE_ONLY_RE);
55
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
56
+ }
57
+ return new Date(raw);
58
+ }
59
+ ```
60
+
61
+ A real timestamp at exactly midnight UTC matches the pattern too, so call `toLocalDate` only on
62
+ fields you know are date-only.
63
+
42
64
  ## Debugging Error #31
43
65
 
44
66
  If React crashes with error #31 ("Objects are not valid as a React child"), open console on the first record that crashes and run:
@@ -65,6 +87,7 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
65
87
  | User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
66
88
  | Attachment | `{ filename, id, type, url }` |
67
89
  | Formula | `string or number` |
90
+ | Lookup (multi-value) | array of **strings**, one element per linked record: `["#1042#"]`, and `[]` when empty (verified live 2026-09-19, Softr Database). `getFieldValue()` joins it for display; a `contains` filter on it tests each element ([reading.md](reading.md#operator-semantics-on-the-server)) |
68
91
 
69
92
  ## Record Structure
70
93
 
@@ -78,7 +101,7 @@ var name = f.firstName || "";
78
101
 
79
102
  ## Debug Utilities
80
103
 
81
- Two throwaway diagnostic blocks you can drop into a page to diagnose data problems. Neither is meant for production -- delete or hide them once the issue is resolved. During development, drop them on a `/debug` page that's only visible to admins, or on a hidden page you navigate to manually.
104
+ Two throwaway diagnostic blocks you can drop into a page to diagnose data problems. Neither is meant for production -- delete or hide them once the issue is resolved. During development, drop them on a `/debug` page whose VIEW permission is limited to admins. A page that is merely left out of the navigation is still reachable by URL, and its blocks' endpoints answer anyone the page lets in (see [softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints)).
82
105
 
83
106
  ### Field Inspector Block
84
107
 
@@ -123,7 +146,7 @@ export default function Block() {
123
146
 
124
147
  4. **Inline in Studio (one field at a time)** -- in the Data tab, click a field's name to open its edit drawer. The field ID appears next to the "Field name" label (e.g. `ID: 37fts`). Fastest for spot-checking a single field.
125
148
 
126
- 5. **Softr Database REST API with `fieldNames=true`** -- runtime inspection from inside a Vibe Coding block (internal-portal blocks only, since this exposes a PAT in client code):
149
+ 5. **Softr Database REST API with `fieldNames=true`** -- runtime inspection from inside a Vibe Coding block (only on a page limited to admins, and removed afterwards: the PAT sits in the block's source, and anyone who can load the page can read it and use it against the whole database, past every page, block and Source-condition gate):
127
150
 
128
151
  ```jsx
129
152
  import { useEffect, useState } from "react";
@@ -94,7 +94,7 @@ it behaves as a **union** of both branches, not a choice between them. Which is
94
94
  *Verified live 2026-09-18; the block-visibility gate 2026-10-05.*
95
95
 
96
96
  The records endpoint is per block + connection —
97
- `/blocks/<blockId>/datasources/<dataSourceId>/records` — and it returns the **UNION of every field
97
+ `/blocks/<blockId>/datasources/<connection>/records` (`<connection>` was recorded as the connection's id in the 2026-09-18 Softr Database capture and seen as its alias in a 2026-10-05 HubSpot capture; unresolved, and it only matters when reading a network log) — and it returns the **UNION of every field
98
98
  named by any READ `q.select` attributed to that connection**. Two selects on one connection do
99
99
  NOT produce two payloads: every read hook on that connection gets all the fields, for every
100
100
  viewer.
@@ -106,6 +106,18 @@ the block — it is simply not rendered. Anyone can read it in the network tab.
106
106
  What does *not* join the union: a mutation hook's `fields:` select. Write-only fields stay out of
107
107
  the read payload.
108
108
 
109
+ **Every row carries its record id, whatever the select.** The union governs *fields*; the records
110
+ endpoint returns each row's own record id beside them however narrow the select is. A connection
111
+ whose only select was one email field still gave anyone who crafted the request every row's id
112
+ with its email (noted in a production block's security review, 2026-09-19). So wherever knowing a
113
+ record id lets someone act (a by-id fetch, an update addressed by `recordId`, a link write that a
114
+ Source condition then keys on), treat ids as access keys: a connection hands every row it releases,
115
+ with its id, to every viewer allowed to call it. Narrowing the select does not withhold ids; only
116
+ fewer rows (a Source condition) or a gated page or block does. A link field in a select ships the
117
+ *linked* records' ids too (`{ id, label }`). To filter by a link without shipping them, filter on
118
+ a readonly key looked up through the link instead
119
+ ([reading.md](reading.md#operator-semantics-on-the-server)).
120
+
109
121
  **Remedy.** Connect the **same table a second time** — Softr allows it, and the second connection
110
122
  gets its own `dataSourceId` — and read the private field only through that connection, from a
111
123
  hook that non-privileged browsers never run:
@@ -146,8 +158,10 @@ user and logged-out visitors 403 on list and by-id. The gate belongs to the bloc
146
158
  connect the same table to an ungated block and it is open again. See
147
159
  [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
148
160
 
149
- Alias → field attribution is per connection, so two selects on different connections may reuse
150
- an alias name (`customer` on both) without colliding — including in `where` filters.
161
+ Two selects on different connections may reuse an alias name (`customer` on both) without
162
+ colliding. Aliases resolve **per hook**, though: a hook's `where` / `orderBy` may name only aliases
163
+ from that hook's own `select`, or the block crashes at runtime — see
164
+ [reading.md](reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select).
151
165
 
152
166
  ## Mutation Actions register per TABLE, not per connection
153
167
 
@@ -206,3 +220,11 @@ right tool for:
206
220
  `crew-feedback-form.jsx` — a public feedback form that reads a person from **People** by an
207
221
  `email` URL param, resolves a **Shifts** record from a job-code param, and writes a row to
208
222
  **Feedback** linking both. One block, three sources, no helpers, no `window` globals.
223
+
224
+ **Read it against the rules above before copying it.** On a public page every logged-out visitor
225
+ may call the People connection, and the endpoint returns every row the Source conditions allow,
226
+ with every field the block's read selects name. The `email` URL param narrows nothing on the
227
+ server: it ends up in a `where`, which is a request parameter the caller controls, and there is no
228
+ logged-in user for a Source condition to match. Select only what the form must show, assume every
229
+ row of it is public, and if that is not acceptable resolve the person server-side (a Softr
230
+ Workflow) instead of reading People from the block.
@@ -9,9 +9,9 @@ Fetching, filtering, sorting, pagination, metrics, charts, and current user.
9
9
  - [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record) — the detail-page pattern; no auto-scoping
10
10
  - [useLinkedRecords -- Fetch Linked/Related Options](#uselinkedrecords----fetch-linkedrelated-options)
11
11
  - [useFieldOptions -- Fetch Single/Multi-Select Choices](#usefieldoptions----fetch-singlemulti-select-choices)
12
- - [Filtering](#filtering) — incl. [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
12
+ - [Filtering](#filtering) — incl. [operator semantics on the server](#operator-semantics-on-the-server), [filters fail open](#filters-fail-open), [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
13
13
  - [Sorting](#sorting)
14
- - [Current User](#current-user)
14
+ - [Current User](#current-user) — user groups need a short, bounded poll
15
15
  - [Metrics](#metrics)
16
16
  - [Chart Data](#chart-data)
17
17
 
@@ -65,7 +65,7 @@ var isRefetching = result.isRefetching;
65
65
  var items = (data && data.pages) ? data.pages.flatMap(function(p) { return p.items; }) : [];
66
66
  ```
67
67
 
68
- **CRITICAL:** Only ONE `useRecords` call **per datasource**. Fetch that table's data in one call and filter client-side. Multiple `useMetric` calls ARE allowed.
68
+ **House rule: one `useRecords` per connection.** It is not a documented platform limit — Hard Constraint 13 in SKILL.md says when to filter client-side, when to add a connection, and when a server-side `where` is the better choice. Multiple `useMetric` calls ARE allowed.
69
69
 
70
70
  **CRITICAL:** The options object must be an **inline literal** at the call site. Passing it
71
71
  through a variable or a wrapper function (`useRecords(buildOpts())`) **fails to compile** —
@@ -80,7 +80,9 @@ A block can connect to **several data sources** and call `useRecords` once per s
80
80
  *Verified live 2026-09-18 (network capture).* `useRecords({ ..., enabled: false })` **fetches
81
81
  anyway** — with a literal `false` and with a variable alike. `useRecord` is different: it honours
82
82
  `enabled: false` and issues no request. (`useLinkedRecords`, `useMetric` and `useChartData` were
83
- not probed — don't assume either behaviour for them.)
83
+ not probed — don't assume either behaviour for them.) The official developer guide still
84
+ describes `enabled` on `useRecords` as an "optional boolean to defer loading" (checked
85
+ 2026-10-06); the capture says otherwise, so trust it until a newer one does not.
84
86
 
85
87
  So `enabled` cannot make a list query conditional, and it cannot keep a query away from viewers
86
88
  who should not run it. Two things that do work:
@@ -110,10 +112,12 @@ import { useState, useEffect } from "react";
110
112
  var result = useRecords({ select: select, count: 100 });
111
113
 
112
114
  useEffect(function() {
113
- if (result.hasNextPage && !result.isFetchingNextPage && result.status === "success") {
115
+ // `!result.error` is defensive: never re-request a page while the hook reports an error
116
+ // (how a failed later page surfaces has not been verified).
117
+ if (result.hasNextPage && !result.isFetchingNextPage && result.status === "success" && !result.error) {
114
118
  result.fetchNextPage();
115
119
  }
116
- }, [result.hasNextPage, result.isFetchingNextPage, result.status, result.fetchNextPage]);
120
+ }, [result.hasNextPage, result.isFetchingNextPage, result.status, result.error, result.fetchNextPage]);
117
121
  ```
118
122
 
119
123
  ## useRecord -- Fetch a Single Record
@@ -146,6 +150,14 @@ the server which record the page is "about". Consequences:
146
150
  returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
147
151
  request is made) and verify `data.id === recordId` before rendering or, worse, writing.
148
152
 
153
+ **A record the connection's Source conditions exclude comes back as no record, not as an error**
154
+ (verified live 2026-09-18, Softr Database). The by-id request answers HTTP 200 with an empty
155
+ body, not 403 or 404, so `useRecord` reports no error and holds no record. Render that as "not
156
+ found"; never wait for a 403/404 to learn the viewer was refused. A production block also sends
157
+ any denial-shaped error (401/403/404, or a JSON parse error on an empty body) to the same "not
158
+ found" state, in case a later build answers differently, and keeps the error panel with a retry
159
+ for real failures.
160
+
149
161
  **A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
150
162
  to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
151
163
  via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
@@ -171,7 +183,7 @@ var result = useLinkedRecords({
171
183
  field: "category", // the ALIAS from q.select(), NOT the raw field ID
172
184
  sortOrder: "ASC", // "ASC" | "DESC"
173
185
  search: "", // optional search string
174
- enabled: true, // defer loading until needed
186
+ enabled: true, // documented as deferral; not verified live (see the useRecords note)
175
187
  count: 50, // optional page size — default 100, max 1000
176
188
  });
177
189
 
@@ -257,6 +269,98 @@ where: q.and(
257
269
  )
258
270
  ```
259
271
 
272
+ ### Operator semantics on the server
273
+
274
+ *Softr Database: `is` verified live 2026-09-18, `contains` 2026-09-19.* What the operators do once
275
+ the filter reaches the server:
276
+
277
+ - **Text `is` is case-insensitive.** `q.text("email").is("Ann@Example.com")` matches
278
+ `ann@example.com`. Compare client-side when case matters.
279
+ - **`contains` is a case-insensitive substring test, and on a multi-value lookup it tests each
280
+ element** — never the elements joined into one string. Multi-value lookups arrive in the browser
281
+ as arrays of strings ([fields.md](fields.md#common-field-type-shapes)). To match one whole value inside a
282
+ lookup, wrap every value in delimiters it cannot contain (a formula such as
283
+ `CONCATENATE("#", {Order No}, "#")`, looked up through the link), search for the delimited
284
+ value, and re-check the exact value client-side: an undelimited `contains("1042")` also
285
+ matches `10420`.
286
+ - **`contains("")` returned 0 rows, not every row** (measured 2026-09-19 against a lookup field; a
287
+ plain text field was not probed). Don't build on it either way. When a hook must match nothing
288
+ until a value exists, give it an explicit sentinel, as in option 2 under
289
+ [`useRecords` ignores `enabled: false`](#userecords-ignores-enabled-false); for `contains` the
290
+ sentinel must not be a substring of any real value either.
291
+
292
+ ```jsx
293
+ var KEY_NONE = "#no-key#"; // no real "#<number>#" key can contain this
294
+ var orderKey = orderNo ? "#" + orderNo + "#" : "";
295
+
296
+ // orderKeys = a lookup, through the link, of the order's "#<number>#" key formula
297
+ var lines = useRecords({ from: ds.lines, select: lineSelect, count: 100,
298
+ where: q.text("orderKeys").contains(orderKey || KEY_NONE) });
299
+
300
+ // The server test is a substring test: keep only rows whose lookup holds the exact key.
301
+ // (items = the flattened pages of `lines`)
302
+ var mine = !orderKey ? [] : items.filter(function(r) {
303
+ var v = r.fields.orderKeys;
304
+ var keys = Array.isArray(v) ? v : (v ? [String(v)] : []);
305
+ return keys.indexOf(orderKey) !== -1;
306
+ });
307
+ ```
308
+
309
+ A key like this also lets a block filter by a link without selecting the link field, which would
310
+ ship the linked records' ids to the browser
311
+ ([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)).
312
+
313
+ ### Filter and sort aliases must be in the same hook's select
314
+
315
+ *Seen live 2026-09-18 (Softr Database, on a `useMetric`).* Aliases are resolved **per hook**, not
316
+ per connection. A `where` or `orderBy` that names an alias missing from that hook's own `select`
317
+ crashes the whole block at runtime ("Could not find an alias for subject \"undefined\"" and
318
+ Softr's "Oh snap" panel), although the push compiled clean:
319
+
320
+ ```jsx
321
+ // WRONG — "status" is not in this hook's select: compiles, then crashes the block
322
+ var countSelect = q.select({ orderNo: "FIELD_ID1" });
323
+ var open = useMetric({ select: countSelect, metric: metric.count(), where: q.text("status").is("Open") });
324
+
325
+ // CORRECT — every alias the where / orderBy names is in the hook's own select
326
+ var countSelect = q.select({ orderNo: "FIELD_ID1", status: "FIELD_ID2" });
327
+ ```
328
+
329
+ Adding the field to the select also adds it to the connection's read payload
330
+ ([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
331
+ so put a filter on a private field on the connection that is allowed to carry it.
332
+
333
+ ### Filters fail open
334
+
335
+ *Verified live 2026-09-19 (Softr Database).* A filter on a field that is **not in the
336
+ connection's read-select union**
337
+ ([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects))
338
+ is **silently ignored**: no error, and the query returns everything, as if there were no `where`.
339
+ (This was recorded for the filter a block sends with its request, the hook's `where`. Source
340
+ conditions were not part of the finding.) How a block's own `where` can name a field outside the
341
+ union while passing the per-hook alias rule above was not established: the session that found it
342
+ also sent hand-built requests to the endpoint, which can name any field. Either way there is no
343
+ way to filter on a field without shipping it: leave it out of every read select and the filter
344
+ stops applying.
345
+
346
+ The two rules fail in opposite directions. An alias missing from the hook's own `select` crashes
347
+ the block ([above](#filter-and-sort-aliases-must-be-in-the-same-hooks-select)); a field missing from
348
+ the connection's union drops the filter without a word. A block that compiles and renders
349
+ plausible rows has passed the first check and proved nothing about the second.
350
+
351
+ Treat every `where` as unproven until you have seen it narrow:
352
+
353
+ 1. Confirm each field the `where` names is in a read select on that connection.
354
+ 2. Load the block as a viewer who can see more rows than the filter should leave (an admin is
355
+ usually easiest) and compare the count with and without the `where`, or read the response in
356
+ the network tab: 7 rows without it and 3 with it, not 7 and 7.
357
+ 3. Where an ignored filter would make the block show the wrong rows (another order's lines on
358
+ this order's page), apply the same condition client-side to every row as well, so the block
359
+ stays correct even if the server returns everything.
360
+
361
+ A `where` is not access control in any case ([Current User](#current-user)); this is about the
362
+ block showing the rows it says it shows.
363
+
260
364
  ### Filtering by a linked record (server-side)
261
365
 
262
366
  *Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
@@ -275,9 +379,14 @@ var comments = useRecords({
275
379
  ```
276
380
 
277
381
  On the wire the alias is resolved to the field id:
278
- `{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Alias → field
279
- attribution is **per datasource**, so two selects on different connections may use the same alias
280
- name for different fields and each filter still resolves against its own connection.
382
+ `{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Aliases resolve
383
+ **per hook** ([above](#filter-and-sort-aliases-must-be-in-the-same-hooks-select)), so two selects on
384
+ different connections may use the same alias name for different fields, and each filter resolves
385
+ against its own hook's `select`.
386
+
387
+ **`isOneOf` filters a link the same way**, for "linked to any of these" (verified live 2026-09-18):
388
+ `q.array("order").isOneOf(orderIds)` goes out as `operator: "IS_ONE_OF"` with the id array as
389
+ `value`, next to `hasAllOf([id])`.
281
390
 
282
391
  `orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
283
392
  `enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
@@ -336,6 +445,54 @@ var userGroups = softrUser.userGroups || [];
336
445
  var isPremium = userGroups.some(function(g) { return g.name === "Premium Member"; });
337
446
  ```
338
447
 
448
+ **`window.__softr_current_user` has no change event.** It is a plain global the Softr shell fills
449
+ in, and nothing re-renders the block when it lands. An empty `userGroups` early on means the
450
+ shell is not ready yet, not that the user has no groups; read once at mount, an admin can be
451
+ settled as a non-admin for good. The shell is usually ready before the block's data arrives. Two
452
+ production blocks (2026-09-18) cover the case where it isn't: they hold the user in state, poll
453
+ with a bound, and treat an empty list as not loaded yet, since every logged-in user carries at
454
+ least Softr's predefined groups.
455
+
456
+ ```jsx
457
+ import { useState, useEffect } from "react";
458
+
459
+ // Module scope. An empty userGroups means the shell is still filling in: return null until then.
460
+ function readShellUser() {
461
+ var u = window.__softr_current_user || null;
462
+ if (!u || !Array.isArray(u.userGroups) || u.userGroups.length === 0) return null;
463
+ return u;
464
+ }
465
+
466
+ // In Block():
467
+ var [shellUser, setShellUser] = useState(readShellUser());
468
+ var [groupsSettled, setGroupsSettled] = useState(!!readShellUser());
469
+
470
+ useEffect(function() {
471
+ if (groupsSettled) return;
472
+ var tries = 0;
473
+ var timer = setInterval(function() {
474
+ tries += 1;
475
+ var found = readShellUser();
476
+ if (found) {
477
+ clearInterval(timer);
478
+ setShellUser(found);
479
+ setGroupsSettled(true);
480
+ } else if (tries >= 14) { // about 2 s at 150 ms, then settle with no groups
481
+ clearInterval(timer);
482
+ setGroupsSettled(true);
483
+ }
484
+ }, 150);
485
+ return function() { clearInterval(timer); };
486
+ }, [groupsSettled]);
487
+
488
+ var userGroups = (shellUser && shellUser.userGroups) || [];
489
+ var isAdmin = userGroups.some(function(g) { return g.name === "Admin"; });
490
+ ```
491
+
492
+ Gate anything role-dependent on `groupsSettled` (show a skeleton until then), so a viewer never
493
+ flashes the wrong panel. The bound settles a viewer whose list never fills in, instead of leaving
494
+ them on a skeleton.
495
+
339
496
  ## Metrics
340
497
 
341
498
  ```jsx
@@ -85,6 +85,10 @@ Because the parser only inspects your hooks and `q.select` mappings (not the JSX
85
85
 
86
86
  All mutation hooks expose an `enabled` boolean. You must check it before rendering any mutation UI or calling the mutate function.
87
87
 
88
+ The snippets below write `fields: q.select({...})` inline for brevity, as single-datasource
89
+ shorthand. In a block with more than one connection, hoist every `fields:` select to module scope
90
+ and pass the identifier ([multi-datasource.md](multi-datasource.md#select-must-be-a-plain-module-scope-identifier)).
91
+
88
92
  ### useRecordCreate
89
93
 
90
94
  ```jsx
@@ -677,7 +681,7 @@ Three remaining alternatives, for the cases multi-datasource doesn't cover:
677
681
  **Update:** PATCH `/{recordId}` with `{ fields: { fieldId1: "newValue" } }`
678
682
 
679
683
  Notes:
680
- - API key is exposed in client-side code -- acceptable for internal portals only
684
+ - The API key ships in the block's source. Anyone who can load the page can read it and use it against the whole database, past page VIEW, block Visibility and Source conditions alike. Use this only on a page limited to a trusted group, and only for what the hooks or a Softr Workflow cannot do
681
685
  - When updating linked records or multi-selects, read existing values first, merge, then write
682
686
  - Use `fieldNames=true` on GET for human-readable field names
683
687
  - Rate limits: Reads 40 req/s, Writes 30 req/s
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.13.4",
3
+ "version": "2.14.0",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "bin/cli.js"
@@ -19,9 +19,11 @@ Run through this catalog before delivering any block. Every row is a violation o
19
19
  | Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
20
20
  | Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
21
21
  | Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
22
- | "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<dsId>/records`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission gates the endpoint and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
22
+ | "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<connection>/records`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission and the block's own Visibility gate the endpoint (403 on list and by-id; block gate verified 2026-10-05), and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
23
23
  | `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
24
24
  | `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
25
+ | `where: q.text("status")…` or `orderBy` naming an alias that is not in **that hook's own** `select` | Crashes the whole block at runtime — "Could not find an alias for subject \"undefined\"", Softr's "Oh snap" panel — though the push compiled clean (seen live 2026-09-18 on a `useMetric`). Aliases resolve per hook, not per connection: add the field to the hook's select (it then joins the connection's read payload). Hard Constraint 29; see [datasources/reading.md](../datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select) |
26
+ | Trusting that a `where` ran because the block compiled and shows plausible rows | A filter on a field that is not in the connection's read-select union is **silently ignored**: no error, and the query returns everything (verified live 2026-09-19). The alias rule (row above) catches only aliases missing from the hook's own select, and it fails loudly; this one fails open. Prove each `where` narrows: as a viewer who can see more rows than it should leave (an admin, say), compare the count with and without it (7 → 3, not 7 → 7), and apply the condition client-side as well wherever an ignored filter would show the wrong rows. See [datasources/reading.md](../datasources/reading.md#filters-fail-open) |
25
27
  | `useRecords({ ..., enabled: false })` / `enabled: someFlag` to defer or withhold a list query | **`useRecords` ignores `enabled: false`** — literal or variable, it fetches anyway (verified live 2026-09-18). `useRecord` honours it. To make a list query conditional, mount the hook in a **child component rendered only when needed** (the only option that sends no request), or give it a match-nothing `where`. Never rely on `enabled` to keep a table away from viewers who should not load it. See [datasources/reading.md](../datasources/reading.md#userecords-ignores-enabled-false) |
26
28
 
27
29
  ## Mutations
@@ -57,6 +59,7 @@ Run through this catalog before delivering any block. Every row is a violation o
57
59
  |---|---|
58
60
  | `field.toLowerCase()` on selects | `getFieldValue(field).toLowerCase()` |
59
61
  | `item.fields.formula === true` | Formula booleans: `=== "1"` |
62
+ | `new Date(value)` / date-fns `format(new Date(value))` on a **date-only** field | Date-only values arrive as midnight UTC, so west of Greenwich they render one day early (verified 2026-09-18). Parse them with `toLocalDate()` — see [datasources/fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc). Keep `new Date()` for real timestamps |
60
63
 
61
64
  ## Hooks & React
62
65
 
@@ -110,6 +113,7 @@ Run through this catalog before delivering any block. Every row is a violation o
110
113
  | Anti-Pattern | Correct Approach |
111
114
  |---|---|
112
115
  | `currentUser.role` for tiers | `window.__softr_current_user.userGroups` |
116
+ | Reading `window.__softr_current_user.userGroups` once at mount and treating `[]` as "no groups" | The global has **no change event**: nothing re-renders the block when the shell fills it in, and an early empty `userGroups` means not loaded yet. Read once, an admin can be settled as a non-admin for good. Hold it in state, poll with a bound (about 2 s), and gate role-dependent UI on the poll having settled. Pattern from two production blocks, 2026-09-18; see [datasources/reading.md](../datasources/reading.md#current-user) |
113
117
 
114
118
  ## Editable Settings
115
119
 
@@ -83,7 +83,11 @@ function getLinkedItems(f) {
83
83
  if (x && typeof x === "object") return { id: x.id || "", title: x.label || x.name || x.title || "" };
84
84
  return { id: "", title: String(x) };
85
85
  }).filter(function(o) { return o.id || o.title; });
86
- return [];
86
+ if (typeof f === "object") { /* a single link arrives as one { id, label } object */
87
+ var t = f.label || f.name || f.title || "";
88
+ return (f.id || t) ? [{ id: f.id || "", title: t }] : [];
89
+ }
90
+ return [{ id: "", title: String(f) }];
87
91
  }
88
92
  ```
89
93
 
@@ -362,7 +366,7 @@ When you change a helper's output shape (e.g., `advisorOffice` from array to str
362
366
  1. Document the published shape as a comment at the top of the helper file and update all consumers in the same commit.
363
367
  2. Version the namespace (`__myapp_projects_v2`) -- old consumers keep reading v1 until migrated.
364
368
 
365
- Defensive consumers can use `Array.isArray(x) ? x.map(...) : x` when shape might vary, but don't lean on this -- it hides bugs.
369
+ Defensive consumers can use `Array.isArray(x) ? x.map(...) : x` when shape might vary, but don't lean on this -- it hides bugs. (That is about the shape of a global *you* publish. Linked-record values read from Softr are different: they arrive as a single `{ id, label }` object or as an array, and must always be normalised -- see [fields.md](../datasources/fields.md).)
366
370
 
367
371
  ### useState, Not useRef, for IDs Consumed by useMemo
368
372
 
@@ -50,8 +50,8 @@ map below was checked against the tool lists the server delivered on 2026-09-30
50
50
  **The Workflows tools have since followed** (when exactly is not known; first seen 2026-10-05). The 2026-10-05 roster delivered all 28 as `workflow_*`:
51
51
  `workflow_create`, `workflow_get`, `workflow_list`, `workflow_publish`, `workflow_update_node_inputs`,
52
52
  `workflow_get_node_specifications`, `workflow_list_node_types`, `workflow_test_node` and the rest,
53
- i.e. area first, then the old verb and object. [Workflows](#workflows) below still lists the
54
- pre-rename names. Translate them that way. That roster had no `get_workspace_integrations`;
53
+ i.e. area first, then the old verb and object. [Workflows](#workflows) below uses the new names
54
+ (re-checked against the live roster 2026-10-06: all 28 present). That roster had no `get_workspace_integrations`;
55
55
  `integration_list` covers it.
56
56
 
57
57
  Most new names are the old words reordered. These are the ones you would not guess:
@@ -195,7 +195,7 @@ For block-building work you need **Applications & Forms: Full access** (to creat
195
195
 
196
196
  ## Vibe coding block tools
197
197
 
198
- Before writing any block code through the MCP, call `vibe_coding_block_get_docs` — it returns the current version of the [Vibe Coding Developer Guide](https://docs.softr.io/vibe-coding-developer-guide), which is the authority on hook signatures if it and this skill ever disagree.
198
+ Before writing any block code through the MCP, call `vibe_coding_block_get_docs` — it returns the current version of the [Vibe Coding Developer Guide](https://docs.softr.io/vibe-coding-developer-guide), which is the authority on hook signatures if it and this skill ever disagree. On runtime *behaviour* the guide's prose can lag a live capture, and where it does this skill says so: the guide still describes `useRecords({ enabled })` as a way to defer loading (checked 2026-10-06), while a 2026-09-18 network capture showed `useRecords` fetching anyway ([reading.md](../datasources/reading.md#userecords-ignores-enabled-false)). Trust the capture until a newer one says otherwise.
199
199
 
200
200
  | Group | Tools |
201
201
  |---|---|
@@ -250,9 +250,13 @@ context. Keep the local mirror in step mechanically rather than by hand:
250
250
  sent, which is what makes it usable here: on this path you never see the merged file yourself.
251
251
 
252
252
  One encoding trap: JSON `\uXXXX` escapes inside the ops are **decoded to the real characters** on
253
- Softr's side (`"—"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
253
+ Softr's side (`"\u2014"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
254
254
  ops to it *after* JSON-decoding them, never as the escaped text, or the final comparison
255
255
  fails on every non-ASCII character.
256
+ The same decoding happens to an agent's own tool-call arguments: a `\u2014` typed into a
257
+ file-writing tool lands on disk as `—` (it put a wrong example into this very paragraph
258
+ twice, 2026-09-18 and 2026-10-06). When a file must hold a literal backslash-u sequence, build the
259
+ backslash at runtime (`chr(92)` in Python) and check the bytes afterwards.
256
260
 
257
261
  **Reach for the full replace when the change is structural** — reordering JSX, moving logic between
258
262
  components, adding a hook — where being sure of "the exact current text" of a dozen scattered fragments
@@ -334,6 +338,22 @@ default permissions (see the next section for why that can be a security problem
334
338
  the restoration). If page-level visibility is the access control in your app, record that decision
335
339
  so nobody chases the reset after every round; if it is not, re-tighten and read back.
336
340
 
341
+ **What a push leaves alone, and when it goes live** (our observations on Softr Database, not
342
+ from the docs):
343
+
344
+ - **A code push does not clear Source conditions** (verified 2026-09-18). Only the Actions reset.
345
+ - **Disconnecting and reconnecting a data source does.** `vibe_coding_block_disconnect_data_source`,
346
+ then `vibe_coding_block_connect_data_source` with the same table, brings the block back bound to
347
+ that table with an **empty** condition (2026-09-10). Other blocks bound to the same data source id
348
+ kept theirs. That makes it a way to clear a broken condition when the filter tool cannot be called,
349
+ and it also means a reconnect done for any other reason drops the row gate: read the conditions
350
+ back afterwards. The next code push re-derives the block's Actions at the defaults, as any push does.
351
+ - **A push lands in the draft.** The live app serves it only after the next app publish, and that
352
+ publish, whoever runs it, takes every other pending draft change live with it, unversioned
353
+ settings edits included. Before calling a block "staged", compare the app's last publish time
354
+ (`application_get`) with the version's `createdAt` (`vibe_coding_block_list_versions`): on
355
+ 2026-09-01 a block we believed staged went live with a publish 28 minutes after it was saved.
356
+
337
357
  ### The array-argument rejection, and why it is a security issue
338
358
 
339
359
  **Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*
@@ -421,13 +441,17 @@ preserves explicitly set permissions across a recompile, so every recompile need
421
441
  the call errors rather than lying, but an agent that batches calls can easily miss which one failed.
422
442
  5. If any action is still broader than intended (typically ADD_RECORD at `ALL_USERS`), **report it
423
443
  and let the builder decide.** Check the page's own VIEW permission first with
424
- `application_page_get_permissions`, because that is what sets the severity:
444
+ `application_page_get_permissions`, because that is what sets the severity, and report the
445
+ block's own Visibility with it (`vibe_coding_block_get_settings`): it gates the block's reads
446
+ and sets ADD_RECORD's default (2026-10-05), but whether it refuses writes on its own is untested
447
+ ([below](#what-the-server-enforces-on-a-blocks-data-endpoints)):
425
448
  - **Page VIEW is gated** (e.g. `LOGGED_IN_USERS`) — an anonymous visitor cannot load the page at
426
449
  all, so exploiting the open action means calling its endpoint directly, and the realistic worst
427
450
  case is junk records rather than data exposure or deletion. Housekeeping: worth fixing on the
428
451
  next Studio pass, not worth holding a release for.
429
- - **Page VIEW is `ALL_USERS`** — the action permission is the only gate left. That is a genuine
430
- hole and deserves to be called one.
452
+ - **Page VIEW is `ALL_USERS`** — the action permission is the only gate known to hold on writes
453
+ (the block's Visibility may also refuse them; untested). That is a genuine hole and deserves
454
+ to be called one.
431
455
 
432
456
  Report page, block, action type, data source and current group; say which of the two cases applies;
433
457
  note that a human sets them on the block's Actions tab in Studio. Then stop — **do not unilaterally
@@ -438,8 +462,10 @@ preserves explicitly set permissions across a recompile, so every recompile need
438
462
  **is** enforced on the block's datasource **records** endpoint (verified live 2026-09-18 — a
439
463
  viewer who cannot view the page gets a 403 whose message names "block/action visibility rules";
440
464
  see [below](#what-the-server-enforces-on-a-blocks-data-endpoints)). The *action* (write) endpoint
441
- was not exercised separately; the message wording suggests the same gate covers it, but that part
442
- is inference. So the reason to treat a gated page as low-severity is still the practical
465
+ refuses outsiders too: on 2026-10-05 a replayed PATCH from outside the block's group got 403
466
+ ([hubspot.md](../datasources/hubspot.md#writing)). The block and its actions were both limited to
467
+ that group, so which rule refused it is not known, and whether page VIEW alone gates writes is
468
+ untested. So the reason to treat a gated page as low-severity is still the practical
443
469
  difficulty and low blast radius — and note that "gated to logged-in users" keeps out anonymous
444
470
  visitors only: any logged-in user can view that page, and therefore reach its endpoints. Say that
445
471
  plainly rather than implying the action is safe.
@@ -462,15 +488,15 @@ Both edit paths recompile, so both reset Action permissions either way (Hard Con
462
488
  *Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
463
489
  captured from the app iframe); the block-visibility row verified 2026-10-05 (HubSpot; preview link,
464
490
  impersonated users, direct POSTs).* A block's data lives behind per-connection endpoints —
465
- `/blocks/<blockId>/datasources/<dataSourceId>/records` for lists, `/records/<id>` for one record —
466
- and these are the gates that actually exist on them:
491
+ `/blocks/<blockId>/datasources/<connection>/records` for lists, `/records/<id>` for one record —
492
+ and these are the gates that actually exist on them (`<connection>` was recorded as the connection's id in the 2026-09-18 Softr Database capture and seen as its alias in a 2026-10-05 HubSpot capture; unresolved, and it only matters when reading a network log):
467
493
 
468
494
  | Gate | Enforced server-side? |
469
495
  |---|---|
470
496
  | **Page VIEW permission** | **Yes.** A viewer who cannot view the page gets **403** ("block/action visibility rules…") from the block's datasource endpoint — crafting the request by hand does not get around it |
471
497
  | **The block's Visibility** (`predefinedUserGroup` + `customUserGroupIds`; `vibe_coding_block_set_visibility`) | **Yes.** A viewer outside the block's group gets the same **403**, on list and by-id, even where the page lets them in. The body reads like a write error on a read: "You cannot add or edit a record because either the block/action visibility rules, user group conditions, or the user/record data in the datasource has changed." Per block: the same table on an ungated block stays open. Five code pushes left the setting intact |
472
- | **The connection's Source conditions** (Source tab / `vibe_coding_block_set_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate** |
473
- | A `where` filter in the block's code | No — it is a request parameter the caller controls |
498
+ | **The connection's Source conditions** (Source tab / `vibe_coding_block_set_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate.** A by-id request for a record the condition excludes answers differently per backend: **HTTP 200 with an empty body** on Softr Database (2026-09-18), **404** on HubSpot (2026-10-05). Treat both as "not found" |
499
+ | A `where` filter in the block's code | No — it is a request parameter the caller controls. It can also **fail open**: on 2026-09-19 (Softr Database) a request filter on a field that was not in the connection's read-select union was silently ignored, with no error, and the query returned everything. Confirm the filtered field is selected on the connection and prove the `where` narrows the result (compare counts with and without it); see [reading.md](../datasources/reading.md#filters-fail-open). A `where` naming an alias missing from its own hook's `select` fails the other way and crashes the block (Hard Constraint 29) |
474
500
  | Which fields the block *renders*, a second / conditional `q.select`, `enabled: false` on `useRecords` | No — the endpoint returns the union of the connection's read selects to anyone allowed to call it (see [multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)) |
475
501
 
476
502
  The consequence to design around: **on a page any logged-in user may view, every datasource
@@ -506,7 +532,8 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
506
532
  field in this form (Leo set it in the Source tab, and it read back that way), and the same form
507
533
  works when written with `vibe_coding_block_set_data_source_record_filters`.
508
534
  **It fails closed:** a user whose field is empty, or who has no record in the users' data source,
509
- gets 0 rows, and a by-id request for a record outside the condition returns 404.
535
+ gets 0 rows, and a by-id request for a record outside the condition returns 404 (on HubSpot;
536
+ Softr Database answers HTTP 200 with an empty body).
510
537
  - **Use AND between rules.** With one rule OR and AND behave the same, but a second rule added
511
538
  under OR widens access (2026-10-05).
512
539
  - **The braced user-field spellings fail.** On 2026-09-18, on Softr Database, eleven spellings were
@@ -528,6 +555,22 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
528
555
  untested (Softr Database so far), store an email on the record and compare it with
529
556
  `{USER:::EMAIL}`. Staff who need every row get a group-gated block with unfiltered connections
530
557
  ([above](#what-the-server-enforces-on-a-blocks-data-endpoints)), not a wider condition.
558
+ - **A Softr Database recipe for "everyone named on the record, plus staff"** (verified 2026-09-18
559
+ in a production app):
560
+ - `CONTAINS` is a case-insensitive **substring** test. It works against a FORMULA text field and
561
+ against a LOOKUP of one (subject `type: "TEXT"`). So a formula that joins every email on the
562
+ record (lower-cased, comma-delimited) gates rows with `<formula> CONTAINS {USER:::EMAIL}`,
563
+ substring trap included (above).
564
+ - Related tables follow the parent through a LOOKUP of that formula, with the same condition on
565
+ the lookup.
566
+ - A LOOKUP that brings an email over a link field works with `IS_ONE_OF {USER:::EMAIL}`.
567
+ - There is no group token, so a **constant** formula listing the staff emails stands in for one:
568
+ `<access formula> CONTAINS {USER:::EMAIL}` OR `<staff formula> CONTAINS {USER:::EMAIL}`. This is
569
+ the OR widening warned about above, chosen on purpose. A staff-only connection carries the
570
+ second rule alone. Adding a staff member then takes two edits: the user group and the formula.
571
+ - The MCP cannot edit a formula after creation, so the staff list is changed in Studio.
572
+ - `vibe_coding_block_set_data_source_record_filters` takes one flat level: the rules joined by a
573
+ single AND or a single OR, no nested groups.
531
574
 
532
575
  For HubSpot specifics (association-based scoping, owner fields), see
533
576
  [../datasources/hubspot.md](../datasources/hubspot.md#row-scoping--who-sees-which-records).
@@ -554,6 +597,11 @@ From the official MCP docs — these hold for MCP-driven and Studio-driven edits
554
597
  - **Changing the code resets action permissions.** Any code change rebuilds the block's record actions at default visibility — restrictions to user groups must be re-applied. (This is Hard Constraint 21 in SKILL.md, now officially documented: tighten Action permissions only after the LAST redeploy.) The defaults: ADD_RECORD follows the block's own visibility, while UPDATE_RECORD and DELETE_RECORD are reset to logged-in users (per Softr, 2026-10-01).
555
598
  - **A block with an unconnected data source saves without complaint**, then errors when the page loads. If a freshly created block looks broken but the code seems right, check its data source connection first.
556
599
 
600
+ Ours, not from the docs: **Source conditions and Action permissions are not versioned.** The version
601
+ history cannot date a change to either (noted 2026-09-10). Whether restoring a version brings back an
602
+ older condition we have not tested. What a code push does and does not reset is in
603
+ [Verifying a push](#verifying-a-push--the-deployed-source-is-the-only-proof).
604
+
557
605
  ## Application management tools
558
606
 
559
607
  The Applications area goes well beyond reads (roster as delivered 2026-10-01; behavior not individually exercised unless stated):
@@ -716,7 +764,14 @@ Known limits and behaviors (per official docs):
716
764
  is no evidence that nothing changed.
717
765
  - **Field descriptions are readable** since 2026-10-01 (per Softr). Before then a description
718
766
  could be written but no read returned it.
719
- - Limits: 100 records per `database_create_records` call, 200 records per read (silently capped, not an error), 2 group-by fields in `database_aggregate_records`. For big tables prefer a filter or aggregate over paging.
767
+ - **`database_create_field` for a DATETIME takes `options: {"includeTime": true}`** (ours, verified
768
+ 2026-09-18). The options shape `database_list_fields` returns for an existing DATETIME field is
769
+ rejected, so do not copy a field definition from a read into a create.
770
+ - **SINGLE_LINE_TEXT fields carry a 1,024-character `maxLength`** (ours: two fields of a production
771
+ table, found in a 2026-09-10 schema audit and confirmed live 2026-09-18, recorded in a block's code
772
+ comment). A block that appends to such a field has to keep the total under it; what a longer write
773
+ does was not tested. Use LONG_TEXT for anything that grows.
774
+ - Limits: 100 records per `database_create_records` call, 200 records per read (silently capped, not an error), 2 group-by fields in `database_aggregate_records`. For big tables prefer a filter or aggregate over paging. The read cap is per call, not a ceiling: `database_list_records` takes `offset`, and on 2026-09-01 `limit` 200 with `offset` 0 to 2,400 read a 2,549-row table in 13 calls, every record id unique, no gap or overlap (ours).
720
775
 
721
776
  Typical Vibe Coding uses: "list every field on `Wigs` with id, name, type, and dropdown options", "what's the option id for `Payment status` = 'Partially paid'?", "show 3 sample records so we know value shapes", "verify the field id in my `q.select()` exists". This eliminates the field-id-typo / wrong-option-uuid class of bugs entirely.
722
777
 
@@ -726,13 +781,13 @@ Softr Workflows are automations built from trigger + action nodes, and the MCP c
726
781
 
727
782
  | Group | Tools |
728
783
  |---|---|
729
- | Workflow lifecycle | `create_workflow`, `get_workflow`, `get_workflow_url`, `list_workflows`, `rename_workflow`, `update_workflow_configuration`, `publish_workflow`, `unpublish_workflow`, `test_workflow` |
730
- | Node management | `add_node`, `add_branch_node`, `create_branch`, `delete_node`, `duplicate_node`, `rename_node`, `reorder_node`, `reorder_multiple_nodes`, `replace_node`, `replace_trigger_node`, `update_node_inputs`, `update_node_note`, `update_node_continue_on_error`, `update_node_retry` |
731
- | Discovery / testing | `list_node_types`, `get_node_specifications`, `get_dynamic_input_options`, `test_node`, `get_node_output` |
784
+ | Workflow lifecycle | `workflow_create`, `workflow_get`, `workflow_get_url`, `workflow_list`, `workflow_rename`, `workflow_update_configuration`, `workflow_publish`, `workflow_unpublish`, `workflow_test` |
785
+ | Node management | `workflow_add_node`, `workflow_add_branch_node`, `workflow_create_branch`, `workflow_delete_node`, `workflow_duplicate_node`, `workflow_rename_node`, `workflow_reorder_node`, `workflow_reorder_multiple_nodes`, `workflow_replace_node`, `workflow_replace_trigger_node`, `workflow_update_node_inputs`, `workflow_update_node_note`, `workflow_update_node_continue_on_error`, `workflow_update_node_retry` |
786
+ | Discovery / testing | `workflow_list_node_types`, `workflow_get_node_specifications`, `workflow_get_dynamic_input_options`, `workflow_test_node`, `workflow_get_node_output` |
732
787
 
733
- These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as `workflow_*`
734
- (`create_workflow` → `workflow_create`, `update_node_inputs` → `workflow_update_node_inputs`); see
735
- [the rename note](#tool-names--the-2026-10-01-rename).
788
+ These are the current names (live roster, 2026-10-06). Until 2026-10-01 they had no `workflow_`
789
+ prefix (`create_workflow` → `workflow_create`), and older notes, including project docs, still use
790
+ those; see [the rename note](#tool-names--the-2026-10-01-rename).
736
791
 
737
792
  **The node catalog is huge** — live-enumerated 2026-08-31: **418 node types (58 triggers + 360 actions) across 56 applications.** The parts that matter most for this skill:
738
793
 
@@ -744,7 +799,7 @@ These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as
744
799
 
745
800
  See SKILL.md's NavigationAction action-types list.
746
801
  - **Softr-native actions:** `BRANCH`, `FILTER`, `WAIT`, `LOOP_ACTION_GROUP` (run each list item through the same steps), `SOFTR_SEND_EMAIL`, `CALL_API` (REST), `WEBPAGE_SCRAPPER`, `PDF_TO_TEXT`, `COMPRESS_FILES` (zip + download link), `TRANSFORM_DATA`, `RESPONDED_TO_WEBHOOK` (custom HTTP response to the webhook caller); Softr DB record CRUD incl. bulk update/delete and find; Softr Apps user management (find / create / delete / deactivate / activate / invite user, send push notification).
747
- - **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow.
802
+ - **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow. Its input and output contract (an object `inputData`, the result under `$.body`) is the `CUSTOM_CODE` bullet in the build-loop findings below.
748
803
  - **AI actions:** Softr AI, OpenAI, Anthropic, Gemini, and Mistral each ship Write / Summarize / Categorize / Custom-prompt nodes; OpenAI adds gpt-image-2 image generation. Pinecone, Firecrawl, Replicate, and Linkup nodes exist too.
749
804
  - **Integration apps (top of 56):** Stripe (36 nodes), QuickBooks (24), ActiveCampaign (23), SharePoint (22), Asana (18), Gmail/Attio/Brevo/Resend (12 each), ClickUp/Zendesk (10), Airtable/Notion/Cal.com/HubSpot/Xero/DocuSign/Apollo (9 each), Sheets/Excel (8), monday/SQL/Jira (7), Slack/Telegram (6), plus Salesforce, Coda, Calendly, Twilio, Zoom, Linear, Trello, form tools (Typeform/Tally/Jotform/Fillout), and more.
750
805
 
@@ -754,19 +809,46 @@ These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as
754
809
  - **Test-first is mandated:** every testable node needs a test run before its outputs become referenceable by downstream nodes. Each node carries a `testRunMode` — `REAL_ONLY`, `MOCK_ONLY`, or `MOCK_AND_REAL` — so some nodes can only be tested against real side effects while others mock. See the test-safety rules under build-loop findings below before testing anything against a production workspace.
755
810
  - **Workflows are owned by a workspace, and can now be pinned to an app** (verified 2026-10-05 from the tool definitions). `workflow_create` still requires a `workspaceId`; its optional `applicationId` "pins the workflow to it, so it is listed on that app's Workflows tab", and `workflow_list({ applicationId })` lists the workflows pinned to an app. Leave `applicationId` out for a workflow that belongs to the workspace as a whole. Pinning or not, `application_preview` / `application_publish` do not apply to workflows. Link a workflow as `https://studio.softr.io/workflow/{workflowId}`.
756
811
 
757
- **Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows):**
812
+ **Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows; bullets dated later come from a second production build, 2026-09-18/19):**
758
813
 
759
- - **`create_workflow` instantiates an OLD version of the trigger node.** Immediately call `replace_trigger_node` with the **same trigger type** — the replacement lands at the current version with the current inputs. Example: `updateField` on `SOFTR_TABLES_RECORD_UPDATED` (fire only when a specific field changed) only exists at v1.2.0; the version `create_workflow` instantiates doesn't have it.
760
- - **FILTER node conditions are set via `update_node_inputs` with inputName `"condition"`** — the value is an `{operator, conditions: [...]}` object. The condition is stored on the FILTER node's **outgoing path**, the same way the Studio builder wires it.
761
- - **The official MCP docs disagree:** "Branch and filter conditions can't be set through MCP yet ... deciding what sends a run down each path is something you finish in the builder" (docs.softr.io/mcp/workflows, checked 2026-10-05).
762
- - **What stands on each side:** our 2026-09-01 build did set them this way. The FILTER spec today declares `inputs: {}`, but live FILTER nodes still keep their condition on the outgoing path, so the empty spec does not refute the mechanism.
763
- - **Until it's re-checked:** after setting a condition over MCP, read the workflow back (`workflow_get`) and confirm the path condition is there. If it isn't, finish the condition in the builder.
814
+ - **`workflow_create` instantiates an OLD version of the trigger node.** Immediately call `workflow_replace_trigger_node` with the **same trigger type** — the replacement lands at the current version with the current inputs. Example: `updateField` on `SOFTR_TABLES_RECORD_UPDATED` (fire only when a watched field changes; it takes an UPDATED_AT-type field, see "Trigger scope" below) only exists at v1.2.0; the version `workflow_create` instantiates doesn't have it. Do it before anything references the trigger: the replacement gets a **new node id** (see the `workflow_replace_node` bullet below).
815
+ - **A FILTER condition written over MCP is inert — set it in Studio** (corrected 2026-10-06; this bullet used to say the MCP writes it). `workflow_update_node_inputs` with inputName `"condition"` accepts an `{operator, conditions: [...]}` object and stores it in the node's `inputs.condition`, a field the engine does not read. The engine evaluates the condition on the FILTER node's **outgoing path** (the `paths` entry whose `fromActionId` is the filter), and only the Studio builder writes that. **A filter built over MCP passes every run.** A 2026-09-19 audit of this very build showed it three ways: the one workflow actually running had empty `inputs` and its whole condition on the path; two others, edited in Studio afterwards, held one condition in `inputs.condition` and a different one on the path, so Studio reads and writes only the path.
816
+ - **The official MCP docs agree:** "Branch and filter conditions can't be set through MCP yet ... deciding what sends a run down each path is something you finish in the builder" (docs.softr.io/mcp/workflows, checked 2026-10-05), and the FILTER spec declares `inputs: {}`. BRANCH conditions were not tested separately here; treat them the same way.
817
+ - **How to build one:** add the FILTER over MCP if that is convenient, then open it in Studio, set its clauses and save. Never trust `inputs.condition` as a record of what the filter does; it can disagree with the path.
818
+ - **How to check one:** read the workflow back with `workflow_get` and find the `paths` entry whose `fromActionId` is the filter; the condition must be there. FILTER nodes cannot be run with `workflow_test_node`, so this read-back is the only check before a real run.
764
819
  - **`LOOP_ACTION_GROUP`'s `loopVariables.items` must reference a plain array**, e.g. `$.records` — a `[*]` projection (e.g. `$.records[*].fields.X`) is rejected by the validator. Per-item references **inside** the loop use `{loopActionGroup.<id>:::loopVariables.items.fields.<fieldId>}` (use the bracket form for ids that start with a digit).
765
- - **`update_node_inputs` batches validate against the STORED node state**, not the batch-in-progress — an update that depends on another update in the same batch fails validation. Split dependent updates into sequential calls.
820
+ - **Over a plain array of strings** (a `CUSTOM_CODE` node's `$.body.<key>`, say), the item *is* the value: reference it as bare `{loopActionGroup.<id>:::loopVariables.items}`, nothing after `items` (2026-09-19; accepted by the validator, not yet exercised by a run).
821
+ - **References into the loop are rejected until the source node's saved sample holds at least one item** (the validator says so). Test the source node on a record that yields a non-empty array before wiring the steps inside the loop.
822
+ - **To put a step inside the loop**, call `workflow_add_node` with `compositeNodeId: <loopNodeId>`. The step lands in the loop's own `actions` / `paths`, not the workflow's.
823
+ - **An empty loop does not stop the run** (recorded 2026-09-19). Zero items means zero iterations and a normal completion, and the steps after the loop still run. A guard stamp placed after a loop therefore fires even when nobody was emailed, and consumes the notice. A gate on the item count has to cover the loop and the stamp together; gating only the stamp leaves the guard unset, and the next edit sends again.
824
+ - **`workflow_update_node_inputs` batches validate against the STORED node state**, not the batch-in-progress — an update that depends on another update in the same batch fails validation. Split dependent updates into sequential calls.
825
+ - **`CUSTOM_CODE` contract** (2026-09-18/19; found by testing, documented nowhere we know of):
826
+ - `inputData` must be a JSON **object**, name → value. The `[{key, value}]` shape other `KEY_VALUE_MAP` inputs take fails the run with `script_args must be an object.`
827
+ - The code reads `inputData.<name>` and ends with a top-level `return { … }`; the body runs wrapped in a function.
828
+ - **The engine wraps what you return:** the node's output is `{ body: <returned object>, statusCode: 200 }`, so downstream references read **`$.body.<key>`**, never `$.<key>`.
829
+ - **The order of `inputData` keys is not kept.** The stored map came back reordered (2026-09-19). If the code must read some inputs after others, order them in the code (by key name, say), and assert on sets and counts in tests, not on order.
830
+ - **There is no native split / list / array-from-text step.** `workflow_list_node_types` has none, and `TRANSFORM_DATA` takes per-field formulas rather than producing a list. Turning a text field of comma-separated addresses into an array a loop can walk takes a `CUSTOM_CODE` node.
831
+ - Testing it: see the test-safety rules below.
832
+ - **Reference validation runs against saved samples** (2026-09-18/19). `workflow_update_node_inputs` checks every `{outputs.<nodeId>:::$.path}` reference against the referenced node's **saved test sample** and rejects a path that does not resolve there: `path "$.fields.<fieldId>.label" does not resolve against node "…"'s sample output`. A record trigger's sample is not yours to choose: re-running `workflow_test_node` on the trigger returns the same record every time. If that record has the field empty (a blank SELECT has no `.label`), the reference cannot be written over MCP at all. Studio's variable picker offers the path regardless of the sample, so add such a reference in Studio.
833
+ - **Pass link fields bare.** A `[*].label` projection on a link field (`$.fields.<linkId>[*].label`) does not resolve when the link is empty, and kills the node. Pass the bare field (`$.fields.<linkId>`) into a `CUSTOM_CODE` node and read the labels in code (2026-09-19).
834
+ - **`workflow_replace_node` swaps a node's type in place and gives it a new node id** (2026-09-19). The node keeps its place in the graph, which is how a step can be rebuilt as a different type without deleting anything, but every `{outputs.<old id>:::…}` reference to it now points at nothing. Before replacing a node, read the workflow with `workflow_get` and search every input for its id: a node whose output is interpolated into an email body would leave those values blank. `workflow_replace_trigger_node` does the same to the trigger (2026-09-18): new id, so every reference to the trigger has to be re-pointed and the trigger re-tested, which is why it belongs right after `workflow_create`. Both leave the discarded node's sample behind in `nodeSamples`. Nothing references it and it changes nothing, no tool removes it, and it is not evidence that the current node was ever tested.
835
+ - **A workflow's own record write re-fires its record-updated trigger** (seen on a production workflow, 2026-09-01). When the trigger watches the table's last-modified field, the workflow's final write is itself an edit. Two guard patterns, both used in our builds:
836
+ - **A send-once stamp:** a "…Sent" date field that the filter requires to be empty, written once at the end, outside any loop. It is the dedupe and the loop guard in one.
837
+ - **Clear the request in the same write:** when the trigger reacts to a request field, the write that acts on it also empties it (`CLEAR_VALUE`), so the re-fire finds nothing to do.
838
+
839
+ Never remove the guard, and never relax the filter to something that stays true after the write. The filter itself has to be set in Studio (see the FILTER bullet above).
840
+ - **Trigger scope** (2026-09-18):
841
+ - `SOFTR_TABLES_RECORD_UPDATED`'s optional `updateField` takes a field of type **UPDATED_AT** (a last-modified field), not any field. A table without one cannot set it, and the trigger then fires on every edit of every record. It is a plain select input: `workflow_get_dynamic_input_options` rejects it.
842
+ - **A workflow has exactly one trigger**, and "Record added" (`SOFTR_TABLES_NEW_RECORD`) and "Record updated" (`SOFTR_TABLES_RECORD_UPDATED`) are separate trigger types, so reacting to both takes two workflows. Whether creating a record also emits an "updated" event is unknown; make the pair idempotent so it does not matter.
843
+ - **Record-write field modes** (written over MCP 2026-09-18). Each field in a Softr Database record write carries a `modificationType`. `ADD_VALUE` adds to a multi-value field (multi-select, multi-link) and keeps what is there; `REPLACE_VALUE` overwrites the whole value, so on a multi-link it drops every earlier link; `CLEAR_VALUE` empties the field (sent with `value: ""`). Use `ADD_VALUE` to give a record one more link or option. These write steps have not run yet (testing one performs the real write), so how `ADD_VALUE` treats a value that is already present is unverified.
844
+ - **`serialExecution: true`** in a workflow's configuration (set with `workflow_update_configuration`, confirmed by read-back, 2026-09-18) makes runs queue instead of overlapping. We set it on a workflow that appends to a multi-link field: two runs at the same moment would each read-modify-write the same array, and one append could be lost.
845
+ - **`continueOnError` is stored on the path, not on the node** (2026-09-19). Turned on with `workflow_update_node_continue_on_error`, it shows up as a `SUCCEEDED OR FAILED` condition on the node's outgoing `paths` entry. Without it a failed step ends the run, so a guard stamp after it never happens and the whole notice goes out again on the next edit. On the **last step inside a loop** the entry has the condition but **no `toActionId`**, and whether the engine reads that as "carry on with the next item" is unproven. Test it with one deliberately bad item mid-list and check that the items after it still ran.
846
+ - **Time-based sends** (a proof of concept, 2026-09-01): a formula field flips (to `"yes"`, say) on the target day, a filtered view picks up the records where it has flipped, and a "Record enters view" trigger fires when one enters.
766
847
  - **Test-safety rules** (which `testRunMode` means what in practice):
767
848
  - Record-**write** nodes (`SOFTR_TABLES_UPDATE_RECORD` etc.) are `REAL_ONLY` — **never test them against a production workspace**; the test performs the real write.
768
849
  - `SOFTR_SEND_EMAIL` is `MOCK_AND_REAL` — **always pass `mode: "mock"`**.
769
850
  - Triggers and `GET_RECORDS` are `REAL_ONLY` but read-only-safe; a record-updated / enters-view trigger test just samples an existing record.
851
+ - `CUSTOM_CODE` is `REAL_ONLY` too, but a node whose code calls no integration and writes nothing is safe to test, and testing it is how you get the non-empty sample later references need (2026-09-19).
770
852
 
771
853
  **Why this matters to block work:** Softr Workflows are now the Softr-native answer to the "block writes to its own table, backend cascades the rest" pattern — for **Softr Database backends** what [airtable-automations.md](airtable-automations.md) is for Airtable backends. See the cross-table alternatives in [../datasources/writing.md](../datasources/writing.md#cross-table-operations).
772
854