softr-vibe-coding 2.13.5 → 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,10 @@ 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
+
7
11
  ## [2.13.5] - 2026-10-06
8
12
  - Release 2.13.5
9
13
  - Correct the MCP FILTER-condition claim and eight other conflicts found in the 2026-10-06 audit
package/README.md CHANGED
@@ -199,7 +199,13 @@ softr-vibe-coding/
199
199
  │ │ # after a resume, update_field/update_table fixes;
200
200
  │ │ # Oct 6 2026: MCP-written FILTER conditions are
201
201
  │ │ # inert (set them in Studio), Workflows tools as
202
- │ │ # workflow_*, denied by-id fetch per backend
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
203
209
  │ ├── browser-checks.md # Checking a pushed block in a browser with
204
210
  │ │ # the agent-browser CLI (ask before installing):
205
211
  │ │ # preview cookie, shadow-DOM refs grepped in the
@@ -270,18 +276,22 @@ softr-vibe-coding/
270
276
  │ # select: as a module-scope identifier, the union-of-
271
277
  │ # selects read payload (a conditional select is not
272
278
  │ # privacy), Actions per table (Sep 18 2026);
273
- │ # 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)
274
281
  ├── reading.md # useRecords, filtering, sorting, pagination,
275
282
  │ # metrics, charts, current user; no detail-page
276
283
  │ # auto-scoping, useRecords ignores enabled:false,
277
284
  │ # server-side linked-record filters (Sep 18 2026);
278
- │ # where/orderBy aliases resolve per hook (Oct 6 2026)
285
+ │ # where/orderBy aliases resolve per hook, operator
286
+ │ # semantics, filters fail open, userGroups poll,
287
+ │ # excluded useRecord = no record (Oct 6 2026)
279
288
  ├── writing.md # Mutations, sequential write queues, uploads,
280
289
  │ # linked record format, cross-table writes;
281
290
  │ # Actions register per table (Sep 18 2026)
282
291
  ├── fields.md # getFieldValue(), field type shapes, record
283
292
  │ # structure, debug utilities; date-only fields
284
- │ # parsed as local dates (Oct 6 2026)
293
+ │ # parsed as local dates, multi-value lookup shape
294
+ │ # (Oct 6 2026)
285
295
  ├── rest-api.md # useProxyFetch + useQuery (full docs)
286
296
  ├── softr-database.md # Native DB — field IDs, no rate limits
287
297
  ├── airtable.md # Column names, PAT vs OAuth, rate limits
package/SKILL.md CHANGED
@@ -73,6 +73,8 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
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
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))
76
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))
77
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)
78
80
  - All imports use named imports (no `import React from 'react'`)
@@ -87,6 +87,7 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
87
87
  | User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
88
88
  | Attachment | `{ filename, id, type, url }` |
89
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)) |
90
91
 
91
92
  ## Record Structure
92
93
 
@@ -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:
@@ -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
 
@@ -112,10 +112,12 @@ import { useState, useEffect } from "react";
112
112
  var result = useRecords({ select: select, count: 100 });
113
113
 
114
114
  useEffect(function() {
115
- 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) {
116
118
  result.fetchNextPage();
117
119
  }
118
- }, [result.hasNextPage, result.isFetchingNextPage, result.status, result.fetchNextPage]);
120
+ }, [result.hasNextPage, result.isFetchingNextPage, result.status, result.error, result.fetchNextPage]);
119
121
  ```
120
122
 
121
123
  ## useRecord -- Fetch a Single Record
@@ -148,6 +150,14 @@ the server which record the page is "about". Consequences:
148
150
  returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
149
151
  request is made) and verify `data.id === recordId` before rendering or, worse, writing.
150
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
+
151
161
  **A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
152
162
  to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
153
163
  via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
@@ -259,6 +269,47 @@ where: q.and(
259
269
  )
260
270
  ```
261
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
+
262
313
  ### Filter and sort aliases must be in the same hook's select
263
314
 
264
315
  *Seen live 2026-09-18 (Softr Database, on a `useMetric`).* Aliases are resolved **per hook**, not
@@ -279,6 +330,37 @@ Adding the field to the select also adds it to the connection's read payload
279
330
  ([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
280
331
  so put a filter on a private field on the connection that is allowed to carry it.
281
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
+
282
364
  ### Filtering by a linked record (server-side)
283
365
 
284
366
  *Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
@@ -302,6 +384,10 @@ On the wire the alias is resolved to the field id:
302
384
  different connections may use the same alias name for different fields, and each filter resolves
303
385
  against its own hook's `select`.
304
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])`.
390
+
305
391
  `orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
306
392
  `enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
307
393
  component that only renders once the parent record has loaded.
@@ -359,6 +445,54 @@ var userGroups = softrUser.userGroups || [];
359
445
  var isPremium = userGroups.some(function(g) { return g.name === "Premium Member"; });
360
446
  ```
361
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
+
362
496
  ## Metrics
363
497
 
364
498
  ```jsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.13.5",
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"
@@ -23,6 +23,7 @@ Run through this catalog before delivering any block. Every row is a violation o
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
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) |
26
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) |
27
28
 
28
29
  ## Mutations
@@ -112,6 +113,7 @@ Run through this catalog before delivering any block. Every row is a violation o
112
113
  | Anti-Pattern | Correct Approach |
113
114
  |---|---|
114
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) |
115
117
 
116
118
  ## Editable Settings
117
119
 
@@ -338,6 +338,22 @@ default permissions (see the next section for why that can be a security problem
338
338
  the restoration). If page-level visibility is the access control in your app, record that decision
339
339
  so nobody chases the reset after every round; if it is not, re-tighten and read back.
340
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
+
341
357
  ### The array-argument rejection, and why it is a security issue
342
358
 
343
359
  **Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*
@@ -480,7 +496,7 @@ and these are the gates that actually exist on them (`<connection>` was recorded
480
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 |
481
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 |
482
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" |
483
- | A `where` filter in the block's code | No — it is a request parameter the caller controls |
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) |
484
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)) |
485
501
 
486
502
  The consequence to design around: **on a page any logged-in user may view, every datasource
@@ -539,6 +555,22 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
539
555
  untested (Softr Database so far), store an email on the record and compare it with
540
556
  `{USER:::EMAIL}`. Staff who need every row get a group-gated block with unfiltered connections
541
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.
542
574
 
543
575
  For HubSpot specifics (association-based scoping, owner fields), see
544
576
  [../datasources/hubspot.md](../datasources/hubspot.md#row-scoping--who-sees-which-records).
@@ -565,6 +597,11 @@ From the official MCP docs — these hold for MCP-driven and Studio-driven edits
565
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).
566
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.
567
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
+
568
605
  ## Application management tools
569
606
 
570
607
  The Applications area goes well beyond reads (roster as delivered 2026-10-01; behavior not individually exercised unless stated):
@@ -727,7 +764,14 @@ Known limits and behaviors (per official docs):
727
764
  is no evidence that nothing changed.
728
765
  - **Field descriptions are readable** since 2026-10-01 (per Softr). Before then a description
729
766
  could be written but no read returned it.
730
- - 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).
731
775
 
732
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.
733
777
 
@@ -755,7 +799,7 @@ those; see [the rename note](#tool-names--the-2026-10-01-rename).
755
799
 
756
800
  See SKILL.md's NavigationAction action-types list.
757
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).
758
- - **`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.
759
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.
760
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.
761
805
 
@@ -765,19 +809,46 @@ those; see [the rename note](#tool-names--the-2026-10-01-rename).
765
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.
766
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}`.
767
811
 
768
- **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):**
769
813
 
770
- - **`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 specific field changed) only exists at v1.2.0; the version `workflow_create` instantiates doesn't have it.
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).
771
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.
772
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.
773
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.
774
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.
775
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).
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.
776
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.
777
847
  - **Test-safety rules** (which `testRunMode` means what in practice):
778
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.
779
849
  - `SOFTR_SEND_EMAIL` is `MOCK_AND_REAL` — **always pass `mode: "mock"`**.
780
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).
781
852
 
782
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).
783
854