softr-vibe-coding 2.13.4 → 2.13.5

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.13.5] - 2026-10-06
8
+ - Release 2.13.5
9
+ - Correct the MCP FILTER-condition claim and eight other conflicts found in the 2026-10-06 audit
10
+
7
11
  ## [2.13.4] - 2026-10-06
8
12
  - Release 2.13.4
9
13
  - 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,16 @@ 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
200
203
  │ ├── browser-checks.md # Checking a pushed block in a browser with
201
204
  │ │ # the agent-browser CLI (ask before installing):
202
205
  │ │ # preview cookie, shadow-DOM refs grepped in the
@@ -271,11 +274,14 @@ softr-vibe-coding/
271
274
  ├── reading.md # useRecords, filtering, sorting, pagination,
272
275
  │ # metrics, charts, current user; no detail-page
273
276
  │ # auto-scoping, useRecords ignores enabled:false,
274
- │ # server-side linked-record filters (Sep 18 2026)
277
+ │ # server-side linked-record filters (Sep 18 2026);
278
+ │ # where/orderBy aliases resolve per hook (Oct 6 2026)
275
279
  ├── writing.md # Mutations, sequential write queues, uploads,
276
- │ # linked record format, cross-table writes
280
+ │ # linked record format, cross-table writes;
281
+ │ # Actions register per table (Sep 18 2026)
277
282
  ├── fields.md # getFieldValue(), field type shapes, record
278
- │ # structure, debug utilities
283
+ │ # structure, debug utilities; date-only fields
284
+ │ # parsed as local dates (Oct 6 2026)
279
285
  ├── rest-api.md # useProxyFetch + useQuery (full docs)
280
286
  ├── softr-database.md # Native DB — field IDs, no rate limits
281
287
  ├── airtable.md # Column names, PAT vs OAuth, rate limits
@@ -334,7 +340,7 @@ The skill enforces these automatically, but good to know (verified live against
334
340
  - No `import React from 'react'` — use named imports (`import { useState } from "react"`)
335
341
  - Must use `export default function Block()`
336
342
  - 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
343
+ - 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
344
  - `fetchNextPage` never in the render body (infinite loop) — call it from an event handler (Load More `onClick`) or a guarded `useEffect`
339
345
  - All hooks declared before any conditional `return` — React error #310
340
346
  - Every field value rendered in JSX must pass through `getFieldValue()`
package/SKILL.md CHANGED
@@ -72,6 +72,8 @@ 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
+ - 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
77
  - 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
78
  - All imports use named imports (no `import React from 'react'`)
77
79
  - `export default function Block()` is present
@@ -614,9 +616,15 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
614
616
  changing the wrapper to take the hook's *result* instead.)
615
617
  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
618
  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
619
+ 13. **One `useRecords` per connection [house]** — not a documented platform limit (Softr's developer
620
+ guide shows one `useRecords` per datasource but states no rule; checked 2026-10-06). Read each
621
+ connection once and filter client-side when the table is small and every viewer may see all of
622
+ it anyway. When the data must differ, add connections rather than queries: a second table gets
623
+ its own connection, and so does a private read of the same table (Hard Constraint 23). A
624
+ server-side `where` is better for large tables and linked children, but it is a request
625
+ parameter, never access control. A query mounted in a child component (Hard Constraint 26) is
626
+ that connection's one read; don't also read the connection in the parent. Declare connections
627
+ with `datasource.define()` and pass `from:` on every hook. See
620
628
  [datasources/multi-datasource.md](datasources/multi-datasource.md). Multiple `useMetric` calls OK.
621
629
  14. **React functional components only** — No class components.
622
630
  15. **Do NOT `import React from 'react'`** — Use named imports for hooks.
@@ -650,7 +658,9 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
650
658
  A push that returns `errors: null` can still have left public write access on the block.
651
659
  **If any action is still broader than intended, report it WITH its severity and let the builder decide.**
652
660
  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
661
+ users makes an open action housekeeping, a public page makes it a real hole. Report the block's own
662
+ Visibility with it -- it gates the block's reads and sets ADD_RECORD's default, but whether it
663
+ refuses writes on its own is untested. Surface the list
654
664
  either way -- page, block, action type, data source -- and note that a human sets them on the
655
665
  block's Actions tab. Do not unilaterally block a publish; it is not your app.
656
666
  22. **Blocks cannot import each other -- cross-block consistency is discipline, not architecture [house]**
@@ -700,6 +710,11 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
700
710
  them takes global CSS across Softr's page structure as well as print CSS in the block. Never
701
711
  an in-page "print view" either (Leo rejected it by name). Verified live 2026-09-30. See
702
712
  [references/printing.md](references/printing.md).
713
+ 29. **`where` / `orderBy` may only name aliases from the same hook's `select`** -- aliases resolve per
714
+ hook, not per connection. Naming any other alias crashes the whole block at runtime ("Could not
715
+ find an alias for subject \"undefined\"") although the push compiles clean. Seen live
716
+ 2026-09-18 on a `useMetric`. See
717
+ [datasources/reading.md](datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select).
703
718
 
704
719
  ## Style Conventions
705
720
 
@@ -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:
@@ -78,7 +100,7 @@ var name = f.firstName || "";
78
100
 
79
101
  ## Debug Utilities
80
102
 
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.
103
+ 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
104
 
83
105
  ### Field Inspector Block
84
106
 
@@ -123,7 +145,7 @@ export default function Block() {
123
145
 
124
146
  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
147
 
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):
148
+ 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
149
 
128
150
  ```jsx
129
151
  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.
@@ -146,8 +146,10 @@ user and logged-out visitors 403 on list and by-id. The gate belongs to the bloc
146
146
  connect the same table to an ungated block and it is open again. See
147
147
  [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
148
148
 
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.
149
+ Two selects on different connections may reuse an alias name (`customer` on both) without
150
+ colliding. Aliases resolve **per hook**, though: a hook's `where` / `orderBy` may name only aliases
151
+ from that hook's own `select`, or the block crashes at runtime — see
152
+ [reading.md](reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select).
151
153
 
152
154
  ## Mutation Actions register per TABLE, not per connection
153
155
 
@@ -206,3 +208,11 @@ right tool for:
206
208
  `crew-feedback-form.jsx` — a public feedback form that reads a person from **People** by an
207
209
  `email` URL param, resolves a **Shifts** record from a job-code param, and writes a row to
208
210
  **Feedback** linking both. One block, three sources, no helpers, no `window` globals.
211
+
212
+ **Read it against the rules above before copying it.** On a public page every logged-out visitor
213
+ may call the People connection, and the endpoint returns every row the Source conditions allow,
214
+ with every field the block's read selects name. The `email` URL param narrows nothing on the
215
+ server: it ends up in a `where`, which is a request parameter the caller controls, and there is no
216
+ logged-in user for a Source condition to match. Select only what the form must show, assume every
217
+ row of it is public, and if that is not acceptable resolve the person server-side (a Softr
218
+ Workflow) instead of reading People from the block.
@@ -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:
@@ -171,7 +173,7 @@ var result = useLinkedRecords({
171
173
  field: "category", // the ALIAS from q.select(), NOT the raw field ID
172
174
  sortOrder: "ASC", // "ASC" | "DESC"
173
175
  search: "", // optional search string
174
- enabled: true, // defer loading until needed
176
+ enabled: true, // documented as deferral; not verified live (see the useRecords note)
175
177
  count: 50, // optional page size — default 100, max 1000
176
178
  });
177
179
 
@@ -257,6 +259,26 @@ where: q.and(
257
259
  )
258
260
  ```
259
261
 
262
+ ### Filter and sort aliases must be in the same hook's select
263
+
264
+ *Seen live 2026-09-18 (Softr Database, on a `useMetric`).* Aliases are resolved **per hook**, not
265
+ per connection. A `where` or `orderBy` that names an alias missing from that hook's own `select`
266
+ crashes the whole block at runtime ("Could not find an alias for subject \"undefined\"" and
267
+ Softr's "Oh snap" panel), although the push compiled clean:
268
+
269
+ ```jsx
270
+ // WRONG — "status" is not in this hook's select: compiles, then crashes the block
271
+ var countSelect = q.select({ orderNo: "FIELD_ID1" });
272
+ var open = useMetric({ select: countSelect, metric: metric.count(), where: q.text("status").is("Open") });
273
+
274
+ // CORRECT — every alias the where / orderBy names is in the hook's own select
275
+ var countSelect = q.select({ orderNo: "FIELD_ID1", status: "FIELD_ID2" });
276
+ ```
277
+
278
+ Adding the field to the select also adds it to the connection's read payload
279
+ ([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
280
+ so put a filter on a private field on the connection that is allowed to carry it.
281
+
260
282
  ### Filtering by a linked record (server-side)
261
283
 
262
284
  *Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
@@ -275,9 +297,10 @@ var comments = useRecords({
275
297
  ```
276
298
 
277
299
  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.
300
+ `{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Aliases resolve
301
+ **per hook** ([above](#filter-and-sort-aliases-must-be-in-the-same-hooks-select)), so two selects on
302
+ different connections may use the same alias name for different fields, and each filter resolves
303
+ against its own hook's `select`.
281
304
 
282
305
  `orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
283
306
  `enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
@@ -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.13.5",
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,10 @@ 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) |
25
26
  | `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
27
 
27
28
  ## Mutations
@@ -57,6 +58,7 @@ Run through this catalog before delivering any block. Every row is a violation o
57
58
  |---|---|
58
59
  | `field.toLowerCase()` on selects | `getFieldValue(field).toLowerCase()` |
59
60
  | `item.fields.formula === true` | Formula booleans: `=== "1"` |
61
+ | `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
62
 
61
63
  ## Hooks & React
62
64
 
@@ -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
@@ -421,13 +425,17 @@ preserves explicitly set permissions across a recompile, so every recompile need
421
425
  the call errors rather than lying, but an agent that batches calls can easily miss which one failed.
422
426
  5. If any action is still broader than intended (typically ADD_RECORD at `ALL_USERS`), **report it
423
427
  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:
428
+ `application_page_get_permissions`, because that is what sets the severity, and report the
429
+ block's own Visibility with it (`vibe_coding_block_get_settings`): it gates the block's reads
430
+ and sets ADD_RECORD's default (2026-10-05), but whether it refuses writes on its own is untested
431
+ ([below](#what-the-server-enforces-on-a-blocks-data-endpoints)):
425
432
  - **Page VIEW is gated** (e.g. `LOGGED_IN_USERS`) — an anonymous visitor cannot load the page at
426
433
  all, so exploiting the open action means calling its endpoint directly, and the realistic worst
427
434
  case is junk records rather than data exposure or deletion. Housekeeping: worth fixing on the
428
435
  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.
436
+ - **Page VIEW is `ALL_USERS`** — the action permission is the only gate known to hold on writes
437
+ (the block's Visibility may also refuse them; untested). That is a genuine hole and deserves
438
+ to be called one.
431
439
 
432
440
  Report page, block, action type, data source and current group; say which of the two cases applies;
433
441
  note that a human sets them on the block's Actions tab in Studio. Then stop — **do not unilaterally
@@ -438,8 +446,10 @@ preserves explicitly set permissions across a recompile, so every recompile need
438
446
  **is** enforced on the block's datasource **records** endpoint (verified live 2026-09-18 — a
439
447
  viewer who cannot view the page gets a 403 whose message names "block/action visibility rules";
440
448
  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
449
+ refuses outsiders too: on 2026-10-05 a replayed PATCH from outside the block's group got 403
450
+ ([hubspot.md](../datasources/hubspot.md#writing)). The block and its actions were both limited to
451
+ that group, so which rule refused it is not known, and whether page VIEW alone gates writes is
452
+ untested. So the reason to treat a gated page as low-severity is still the practical
443
453
  difficulty and low blast radius — and note that "gated to logged-in users" keeps out anonymous
444
454
  visitors only: any logged-in user can view that page, and therefore reach its endpoints. Say that
445
455
  plainly rather than implying the action is safe.
@@ -462,14 +472,14 @@ Both edit paths recompile, so both reset Action permissions either way (Hard Con
462
472
  *Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
463
473
  captured from the app iframe); the block-visibility row verified 2026-10-05 (HubSpot; preview link,
464
474
  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:
475
+ `/blocks/<blockId>/datasources/<connection>/records` for lists, `/records/<id>` for one record —
476
+ 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
477
 
468
478
  | Gate | Enforced server-side? |
469
479
  |---|---|
470
480
  | **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
481
  | **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** |
482
+ | **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" |
473
483
  | A `where` filter in the block's code | No — it is a request parameter the caller controls |
474
484
  | 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
485
 
@@ -506,7 +516,8 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
506
516
  field in this form (Leo set it in the Source tab, and it read back that way), and the same form
507
517
  works when written with `vibe_coding_block_set_data_source_record_filters`.
508
518
  **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.
519
+ gets 0 rows, and a by-id request for a record outside the condition returns 404 (on HubSpot;
520
+ Softr Database answers HTTP 200 with an empty body).
510
521
  - **Use AND between rules.** With one rule OR and AND behave the same, but a second rule added
511
522
  under OR widens access (2026-10-05).
512
523
  - **The braced user-field spellings fail.** On 2026-09-18, on Softr Database, eleven spellings were
@@ -726,13 +737,13 @@ Softr Workflows are automations built from trigger + action nodes, and the MCP c
726
737
 
727
738
  | Group | Tools |
728
739
  |---|---|
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` |
740
+ | Workflow lifecycle | `workflow_create`, `workflow_get`, `workflow_get_url`, `workflow_list`, `workflow_rename`, `workflow_update_configuration`, `workflow_publish`, `workflow_unpublish`, `workflow_test` |
741
+ | 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` |
742
+ | Discovery / testing | `workflow_list_node_types`, `workflow_get_node_specifications`, `workflow_get_dynamic_input_options`, `workflow_test_node`, `workflow_get_node_output` |
732
743
 
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).
744
+ These are the current names (live roster, 2026-10-06). Until 2026-10-01 they had no `workflow_`
745
+ prefix (`create_workflow` → `workflow_create`), and older notes, including project docs, still use
746
+ those; see [the rename note](#tool-names--the-2026-10-01-rename).
736
747
 
737
748
  **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
749
 
@@ -756,13 +767,13 @@ These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as
756
767
 
757
768
  **Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows):**
758
769
 
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.
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.
771
+ - **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
+ - **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
+ - **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
+ - **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
775
  - **`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.
776
+ - **`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.
766
777
  - **Test-safety rules** (which `testRunMode` means what in practice):
767
778
  - 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
779
  - `SOFTR_SEND_EMAIL` is `MOCK_AND_REAL` — **always pass `mode: "mock"`**.