Skip to content

Commit f74be95

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18680-decorated-claim-release-markers
2 parents 9c84b3c + 0e2ebce commit f74be95

22 files changed

Lines changed: 2810 additions & 173 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: 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`.

‎.changeset/hungry-doors-invent.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Attach four TSDoc blocks to the declarations they describe.
7+
8+
TSDoc binds a block by position, so a block can end up describing a declaration
9+
it does not document, or none at all. Four had: three in
10+
`packages/rest/src/rest-server.ts` (the `resolveProtocol` paragraph stacked above
11+
`resolveHostnameCached`'s own block, the exported `RestServer` class overview
12+
orphaned by the `RestEnvRegistry` block, and the `registerSharingEndpoints` route
13+
table orphaned by the analytics block) and one in
14+
`packages/runtime/src/http-dispatcher.ts`, where the block above
15+
`resolveActiveOrganizationId` still described `resolveCallerUserId`, a sibling
16+
deleted with the multi-tenant `/cloud` control plane.
17+
18+
No runtime behaviour changes and no API surface moves. This is a `patch` rather
19+
than `skip-changeset` because the block text was measured to ship: each of the
20+
four appears in the published `dist/index.d.ts` and `dist/index.d.cts` of its
21+
package, both of which are inside `files: ["dist", ...]`. Anyone reading
22+
`@objectstack/rest` or `@objectstack/runtime` declarations in an editor was being
23+
shown a description of the wrong function.
24+
25+
Clause-②: no

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -848,10 +848,13 @@ Content-Type: application/json
848848
```
849849

850850
**Behavior:**
851-
- Maximum batch size: 200 operations by default (configurable via `maxBatchSize`).
852-
Over the cap is `400 BATCH_TOO_LARGE`, carrying the `count` sent and the `max`
853-
allowed. The same cap and the same code apply to every bulk write route
854-
(`createMany` / `updateMany` / `deleteMany` / per-object `batch`)
851+
- Maximum batch size: **200** operations. The cap is `batch.maxBatchSize`, and it
852+
is **embedder-only** (#15543): it is written only by a host that constructs the
853+
`RestServerConfig` itself, never by `os serve` or the dev plugin, so a
854+
CLI-started deployment always gets the default of 200. Over the cap is
855+
`400 BATCH_TOO_LARGE`, carrying the `count` sent and the `max` allowed. The
856+
same cap and the same code apply to every bulk write route (`createMany` /
857+
`updateMany` / `deleteMany` / per-object `batch`)
855858
- The entire batch runs inside **one engine transaction** — if any operation
856859
fails, all are rolled back (commit-all-or-nothing). The batch is always
857860
atomic; an explicit `"atomic": false` is rejected with `400 BATCH_NOT_ATOMIC`

‎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 |

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,20 +1258,34 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
12581258
// ADR-0087 conversion that would otherwise strip it (`permission-allow-
12591259
// restore-purge-removed`) is `retiredFromLoadPath: true`, so it does not run
12601260
// inside `normalizeStackInput` and the key reaches this tier intact.
1261+
//
1262+
// [#17936] Crossed onto the runtime publish gate for `permission` writes, and
1263+
// the measurement the previous `surfaceReason` held it back for is TAKEN. That
1264+
// reason asked one question — does the gate's `body` reach this rule BEFORE
1265+
// the per-type `safeParse`, whose residue stage strips the only evidence it
1266+
// reads? It does, and not by luck: `saveMetaItem` keeps the AUTHORED body
1267+
// verbatim by design (`parsed.data` would strip the Studio-only auxiliary
1268+
// fields an overlay rides with) and grafts back exactly two normalizations,
1269+
// each a walk over the authored keys that adds and removes nothing else. So
1270+
// `assertRuntimeAuthoringRules` is handed the raw document, the gate passes it
1271+
// straight through as `item`, and the residue is present in the snapshot this
1272+
// rule reads. Pinned end to end at the door
1273+
// (`metadata-protocol`'s `protocol.runtime-authoring-gate.test.ts`, #17936
1274+
// block) and at this layer (`runtime-gate.permission-residue.test.ts`) — the
1275+
// phantom-check risk that reason named is answered by measurement, not by
1276+
// argument. Why it had to cross at all: ruling D's population — a Studio /
1277+
// REST `/meta` / MCP author who never runs `os lint` — has no other door.
1278+
// Advisory only, so it rides the 2xx the write earns and can never refuse one.
1279+
// `permission` is the only declared type because `stack.permissions` is the
1280+
// only collection the rule reads.
12611281
{
12621282
name: 'validateRetiredPermissionResidue',
12631283
tier: 'advisory',
12641284
input: 'normalized',
12651285
commands: ALL,
12661286
source: 'packages/lint/src/validate-retired-permission-residue.ts',
1267-
surfaces: CLI_ONLY,
1268-
surfaceReason:
1269-
'Ruled scope: the signal belongs at the authoring door over RAW SOURCE, which is ' +
1270-
'where the authored and the built path are distinguishable. Crossing it needs a measurement ' +
1271-
"this round did not take — whether the gate's `body` reaches it BEFORE the per-type " +
1272-
'`safeParse`, whose residue stage strips the only evidence this rule reads. Post-parse the ' +
1273-
'rule is structurally silent, so wiring it there without that reading would publish a ' +
1274-
'phantom check, not coverage.',
1287+
surfaces: CLI_AND_RUNTIME,
1288+
runtimeTypes: ['permission'],
12751289
run: (stack) =>
12761290
validateRetiredPermissionResidue(stack).map((f) => ({
12771291
severity: f.severity,

0 commit comments

Comments
 (0)