Skip to content

Commit 05cdeb2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18670-refinement-projection-census
2 parents a0127cf + 41f9f46 commit 05cdeb2

29 files changed

Lines changed: 3741 additions & 107 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
`validateRetiredPermissionResidue` now runs at the runtime authoring door on `permission` writes, at advisory tier — so a Studio / REST `/meta` / MCP author who writes `allowRestore: false` or `allowPurge: false` and never runs `os lint` is told the line has no effect (#17936, out of #17425 ruling D).
6+
7+
Clause-②: no
8+
9+
The rule was registered `CLI_ONLY` on an open question its own `surfaceReason` recorded: does the gate's `body` reach it BEFORE the per-type `safeParse`, whose residue stage strips the only evidence it reads? Wiring it without that reading would have published a phantom check. **Measured: it does reach it.** `saveMetaItem` keeps the AUTHORED body verbatim on purpose — `parsed.data` would strip the Studio-only auxiliary fields an overlay rides with — and grafts back exactly two normalizations (filter `operator` spellings, the form `groups` → `sections` key move), each a walk over the authored keys that adds and removes nothing else. So `assertRuntimeAuthoringRules` is handed the raw document, the gate passes it through as `item`, and the residue is present in the snapshot the rule reads.
10+
11+
What changes for a caller:
12+
13+
- A `permission` publish carrying either retired key **still succeeds** and now returns one `advisories[]` entry per occurrence, in the door's existing six-key diagnostics envelope (`{severity, rule, where, path, message, hint}`) — the shape Studio and MCP already render for a 422's `issues[]`. `rule` is `permission-retired-lifecycle-residue`, `path` is the name-keyed `permissions.<set>.objects.<object>.<key>`, and `hint` is the tombstone's own prescription, read from the schema rather than retyped.
14+
- ⛔ **Never a refusal.** The rule is advisory tier; the accept set is untouched, and a value that is *not* the retired default (`true`, `0`, `null`) is still refused by the tombstone at the parse, with its prescription attached, exactly as before.
15+
- **Draft saves are unchanged** (#4463 D1), and so is every other metadata type: `permission` is the only declared `runtimeTypes` member, because `stack.permissions` is the only collection the rule reads.
16+
- **The CLI door is unchanged** — `os validate` / `os build` / `os lint` run the rule exactly as they did, with the same positional `permissions[i]…` path. The name-keying is the runtime gate's wire rewrite and does not reach the commands.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
fix(service-automation): on the synchronous path, a child run that REFUSES stops its parent, in `subflow` and in `map` alike (#18110, #18555)
6+
7+
**Clause-②: yes (widening)** — `NodeExecutionResult` is barrel-exported from this package's single entry point, and it gains two new optional members. Nothing previously accepted is refused and nothing is retired, so this is a widening of the published executor contract, not a narrowing. Contract-review tier.
8+
9+
A child flow that runs to completion in one go and ends on an `end` node declaring `outcome: 'refused'` used to roll up to its parent as an ordinary success. `subflow-node.ts` branched only on `child.status === 'paused'` and `!child.success`; a refused child is neither (`{ success: true, status: 'refused' }` — *a refusal is a successful evaluation that says no*), so it fell through the success exit. The parent walked the node's out-edges, recorded `completed` and fired its **own** `successMessage` over the child's refusal — the author got the exact opposite of what they wrote, fail-open. `map-node.ts` had the identical branch set and the identical hole: a refusing row let every row after it through.
10+
11+
- **New on `NodeExecutionResult`: `refuse?: boolean` and `refusalMessage?: string`.** The executor-facing half of the unwinding protocol `suspend?: boolean` already uses. A node that sets `refuse` terminates its run as `refused` — a terminal status this package has published since #15788, so **no new status value** and nothing authorable changes.
12+
- **`subflow` and `map` both set it** when their child run returns `status: 'refused'`. One channel, two call sites.
13+
- **The child's `selected` / `acted` / `unmeasuredEffect` rollup (#4354) survives the refusal**, because the engine throws the refusal signal from the same position it throws the suspend signal: after the node's success step is pushed, after its `childSteps` are folded and after its output is written back. A child that refused really can have written rows before it said no.
14+
- ⛔ **A refusal is still not a failure.** It does not consume retry budget, is not routable by a `fault` edge, and is not counted in `nodes[].failures`.
15+
- **Region-boundary diagnostic, text only**: the message a structured region raises when a refusal tries to cross it now names whichever node carried the refusal, instead of asserting it was an `end` node — which, for a refusing `subflow`/`map` inside a region, sent the author looking for a node that was not in their region. Region **semantics** are unchanged.
16+
17+
**Scope — the RESUMED leg is not covered.** This fixes the path where the child run finishes inside the parent's own `engine.execute` call and its outcome is read from that return value. A child that durably PAUSES first — a nested `approval` / `screen` / `wait` — and only refuses when it is later resumed still reaches its parent through the resume machinery, which reads the child's outcome at different seams and does not consult `status: 'refused'` at any of them. Both of those seams pre-date this change and neither is a regression of it, but neither is closed by it either, and the resumed leg is the one a screen flow actually takes. A follow-up card covers it: #18714.
18+
19+
For third-party node executors this is additive: an executor that never sets `refuse` behaves exactly as before.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(approvals): the record-lock refusal names the record, not its primary key (#18153)
6+
7+
Clause-②: no
8+
9+
A record held by a live approval refused the write with
10+
`record '<id>' of '<apiName>' is locked while an approval is in progress`. The
11+
console copies that sentence into a toast verbatim, so an end user read an
12+
opaque primary key and a machine identifier — neither of which tells them an
13+
approval has the record — and a deny-path toast is exactly the string that ends
14+
up in screenshots, screen recordings and support tickets.
15+
16+
It now reads `Opportunity 'Acme renewal' is locked while an approval is in
17+
progress, and cannot be edited until that approval is complete`, degrading to
18+
`This Opportunity is locked …` when the object declares no resolvable title and
19+
to `This record is locked …` when the registry is unreachable — ⛔ never back to
20+
the id. The record id and the object's API name are not deleted: they move to
21+
the CONSOLE (`logger.info`, alongside the pending request's id), which is where
22+
a support path reads them and where a screen recording does not.
23+
24+
**No read was added.** Both halves were already in hand at the refusal: the
25+
object's `label` and its ADR-0079 title pointer come from the engine's in-memory
26+
registry (`getSchema`), and the record itself is `ctx.previous`, the pre-image
27+
the engine has already read — measured on all four update shapes (by-id,
28+
`updateManyData`, predicate `multi`, unscoped `multi`), every one of which
29+
dispatches the hook per row with `previous` bound. Deliberately NOT used: a
30+
system-context read of the record on the deny path (it would title a row the
31+
caller may not be allowed to READ — the very state this lock exists to gate) and
32+
the `payload_json` snapshot (served redacted per reader).
33+
34+
**Nothing else moved.** `RECORD_LOCKED` and its `409` are unchanged and pinned
35+
in both directions, the `CODE: message` envelope is unchanged, and the three
36+
OPERATOR-facing refusals in the same file — the two `PENDING_LOCK_LIMIT` cap
37+
messages and the unanswerable-intersection message — still name the object's API
38+
name, which is the useful thing to say to whoever has to rescope that write.
39+
They are pinned byte for byte so a later "harmonise the lock's messages" sweep
40+
cannot fold them into the end-user shape.
41+
42+
A client asserting on the old sentence's text will need updating; a client
43+
branching on `error.code` or the 409 needs no change.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`UserSchema.image` and `OrganizationSchema.logo` are declared `z.string().url().nullish()` — a URL string, `null`, or the key absent are all accepted — so the user and organization bodies this platform serves parse against the schemas it publishes (#18509).
6+
7+
Both were `z.string().url().optional()`: a URL string or the key's absence, and `null` refused. Both columns are better-auth-owned and nullable — `sys_user.image` and `sys_organization.logo` are each `Field.url({ required: false })`, reaching SQLite as `varchar(255)` with `notnull=0` — and better-auth SELECTs them and serialises them present-and-null for a user who never set an avatar and an organization created without a logo.
8+
9+
Measured through a real `AuthManager` (better-auth 1.7.3) over a real `ObjectQL` on a real `SqliteWasmDriver`, with the platform's own `sys_user` / `sys_organization` object definitions:
10+
11+
```
12+
/auth/sign-up/email -> user.image = null
13+
/auth/get-session -> user.image = null
14+
/auth/organization/create -> logo = null
15+
/auth/organization/list -> [0].logo = null
16+
/auth/organization/get-full-organization
17+
-> logo = null
18+
-> members[].user.image = null
19+
20+
UserSchema.safeParse(<the served session user>)
21+
-> [{ path: ["image"], code: "invalid_type",
22+
message: "Invalid input: expected string, received null" }]
23+
OrganizationSchema.safeParse(<the served organization>)
24+
-> [{ path: ["logo"], code: "invalid_type",
25+
message: "Invalid input: expected string, received null" }, … ]
26+
```
27+
28+
Those two paths now parse.
29+
30+
- **Measured, not inferred.** #18509 exists because PR #18501's contract review named these two siblings as *not measured* rather than folding them into the `SessionUserSchema.image` ruling it had. The verdict here comes from the probe above, run the way that ruling's own evidence was taken; the analogy was only ever a reason to look.
31+
- **The declaration was the thing that was wrong.** Prime Directive #12's default — fix the producer, never widen the consumer — rests on the premise it states out loud, that we own both ends. We do not: the nullable columns belong to a third-party model, so PD #12's own exit clause is the operative sentence.
32+
- **A pure widening.** `.nullish()`, not `.nullable()`: the key's ABSENCE is a legal shape today, so `.nullable()` would retire a live shape as the price of admitting `null`. Every body legal before this change is still legal.
33+
- **`.url()` is kept, and it does not fight `null`.** These two declarations carry `.url()`, which `SessionUserSchema.image` did not, so the question had to be answered rather than copied. `.nullish()` wraps the whole `z.string().url()`: `null` and `undefined` are separate branches the URL check never sees, while a present string is still required to be a well-formed URL. Of six inputs — absent, `null`, `''`, a URL, a non-URL, a number — exactly one row moves, and it is the ruled one. `''` and `'not-a-url'` are still refused.
34+
- **No key is added or removed** — both keys were already authored and already published, so no authorable surface moves and nothing is retired.
35+
- **`OrganizationSchema` is not made whole by this.** The same probe found `metadata` served present-and-null and `/auth/organization/create` omitting the required `updatedAt`. Those are separate defects with their own reasoning, filed separately rather than folded in; #18509 asked about `logo`.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
`TursoDriver` in **remote** mode now **refuses** transactions with `NOT_IMPLEMENTED` / `501` instead of accepting them and silently doing nothing with them. Local and embedded-replica modes are unchanged — they inherit `SqlDriver`'s knex transactions and still honour `options.transaction`.
6+
7+
**What was wrong.** `@objectstack/spec`'s `driver.zod.ts` states the delivery mechanism verbatim: *"A transaction handle to be passed to subsequent operations via `options.transaction`."* On the remote transport nothing could receive it. `RemoteTransport` names a transaction in exactly three members (`beginTransaction()`, `commit(t)`, `rollback(t)`) and **zero** of its data methods take an `options` argument at all — against 13 data methods present in the file, which is what makes that zero a reading. So a write issued between `beginTransaction()` and `rollback()` executed on the plain connection, was **already durable**, and the rollback resolved having undone nothing. Every step reported success.
8+
9+
**What refuses now**, on the remote arm only:
10+
11+
- `beginTransaction()`, `commit()` and `rollback()` — the capability is never handed out, so the sequence above cannot start.
12+
- Any driver method that arrives carrying `options.transaction` — `find`, `findOne`, `count`, `aggregate`, `create`, `update`, `upsert`, `delete`, the three bulk methods, `updateMany`, `deleteMany`, `execute`, `syncSchema`, `syncSchemasBatch`, `dropTable`. This second door is not redundant: the engine's `buildDriverOptions` reads `execCtx.transaction` **first**, so a handle threaded through `ExecutionContext` reaches a data method without ever passing through `beginTransaction()`.
13+
14+
The refusal fires on the **handle**, not on remote mode: a remote call with no transaction in it is untouched, which is every call the platform makes today. It is raised before any statement is built, so a refused call costs no round trip and leaves no partial write.
15+
16+
**If this refusal now fires for you, it is telling you that you never had the transaction.** The remedies, in order: use the **local or embedded-replica** transport for work that needs atomicity; or take the non-transactional path deliberately — `engine.transaction()` without `require: true` on a driver with no transactions runs the callback with no rollback and says so (ADR-0119 D1). `NOT_IMPLEMENTED` / `501` rather than a `400` because the request is spelled correctly and the spec declares the members: the gap is the backend's, the same two-class taxonomy this driver already applies to remote `auto_number`, aggregate functions and date buckets.
17+
18+
Implementing real transactions on the remote transport is a separate, larger piece of work and is deliberately **not** part of this change.

‎content/docs/references/identity/identity.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const result = AccountSchema.parse(data);
6363
| **email** | `string` | ✅ | User email address |
6464
| **emailVerified** | `boolean` | optional (default: `false`) | Whether email is verified |
6565
| **name** | `string` | optional | User display name |
66-
| **image** | `string` | optional | Profile image URL |
66+
| **image** | `string \| null` | optional | Profile image URL |
6767
| **createdAt** | `string` | ✅ | Account creation timestamp |
6868
| **updatedAt** | `string` | ✅ | Last update timestamp |
6969

‎content/docs/references/identity/organization.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const result = InvitationSchema.parse(data);
8585
| **id** | `string` | ✅ | Unique organization identifier |
8686
| **name** | `string` | ✅ | Organization display name |
8787
| **slug** | `string` | ✅ | Unique URL-friendly slug (lowercase alphanumeric, hyphens, underscores) |
88-
| **logo** | `string` | optional | Organization logo URL |
88+
| **logo** | `string \| null` | optional | Organization logo URL |
8989
| **metadata** | `Record<string, any>` | optional | Custom metadata |
9090
| **createdAt** | `string` | ✅ | Organization creation timestamp |
9191
| **updatedAt** | `string` | ✅ | Last update timestamp |

0 commit comments

Comments
 (0)