Skip to content

Commit 3d150eb

Browse files
committed
Merge origin/main into claude/issue-13871-generate-codegen-ghost-field-types
Keeps the gate-family derivation and the verification on a tree that is at origin/main, per scripts/pm/dispatch-gates.mjs's stale-tree refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
2 parents c421658 + 2aa8456 commit 3d150eb

31 files changed

Lines changed: 2846 additions & 111 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
docs(core): the `AuthzStoreUnavailableError` brand doc states the measured `structuredClone` behaviour instead of claiming survival (#14006)
6+
7+
Documentation only — no runtime change, no type change, no accept/reject
8+
behaviour moves. It ships as a patch because the docblock is a **published
9+
byte**: `tsup`'s declaration rollup carries it into `dist/index.d.ts` and
10+
`dist/index.d.cts`, so it is what a consumer reads on hover.
11+
12+
The brand's docblock justified the string-keyed own property with two reasons
13+
joined by an `and`, of which only the second was true:
14+
15+
> A string-keyed own property (not a `Symbol.for` registry key) so it survives
16+
> `structuredClone`, and so a duplicated copy of this module still brands
17+
> identically.
18+
19+
Measured on Node 22.22.2: the structured-clone algorithm gives `Error` a
20+
dedicated serialization carrying `message`, `stack` and `cause` only, and drops
21+
every other own property — the brand, the ADR-0112 `code`, `status` and
22+
`object` alike (a subclass's own `name` returns as `'Error'`). The
23+
plain-object control is the half that proves it: `{ __brand: true, code: 'C' }`
24+
keeps **both** keys through the same call, so the loss is specific to `Error`,
25+
not general to `structuredClone`.
26+
27+
The property and the reason that actually earns it are kept — a duplicated copy
28+
of the module still brands identically, which is exactly what `instanceof`
29+
cannot do across two installed copies of `@objectstack/core`. The false half is
30+
replaced by the measured behaviour, carrying the reproducible script and the
31+
Node version rather than a second unsourced assertion, and phrased to match
32+
what `service-not-registered.ts` already records for its own brand (one
33+
phrasing across the two modules, not two).
34+
35+
⛔ The clone gap is deliberately NOT "fixed" with a `toJSON` or a custom
36+
serialization: no call site crosses a clone boundary today
37+
(`rethrowAuthzStoreUnavailable` on the rest rethrow paths,
38+
`isAuthzStoreUnavailableError` inside service `catch` blocks — all in-process),
39+
and adding one would widen the module's surface with nothing pulling on it. The
40+
docblock instead names the trap the false claim invited: branching on the brand
41+
across a worker or `postMessage` boundary would answer `false` and fail OPEN on
42+
a security path.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `DataEvent` names the organization the record belongs to, so a tenant-scoped consumer can tell whose event it is
6+
7+
The realtime `DataEvent` payload (`@objectstack/spec/api`, the body of every
8+
`data.record.created` / `data.record.updated` / `data.record.deleted` event)
9+
gains an optional `organizationId`: the organization the record belongs to.
10+
Until now the event carried the object name, the record id and the row body,
11+
and nothing that named the tenant — so a consumer that fans events out per
12+
organization (a webhook subscription, a per-organization realtime subscriber)
13+
had no term to discriminate on short of reading the row body, which is absent
14+
on delete events and is not the consumer's to read.
15+
16+
What a consumer may assume:
17+
18+
- **Present** — exactly that organization, never a guess: the organization the
19+
record belongs to, not the caller's active organization standing in for it.
20+
- **Absent** — the record belongs to no organization. That is every event on a
21+
`single`-posture deployment (no organization wall, nothing stamps the
22+
column) and an organization-less, environment-wide row under a walled
23+
posture. Read it as "not behind any organization wall", never as "unknown,
24+
look it up".
25+
26+
Declared = enforced: the key is optional and nothing else. No default
27+
fabricates a tenant; `null` and the empty string are refused with a located
28+
issue, so "no organization" has exactly one spelling — the key is absent.
29+
30+
Additive and shape-preserving: every event that parsed before parses
31+
identically, and no producer emits the key yet — the ObjectQL engine's publish
32+
site is a separate change that follows this contract. The bulk
33+
`BulkDataEvent` (`data.records.*`) is deliberately untouched: a predicate
34+
write's affected set is its own contract with its own tenant question.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/service-job": patch
3+
---
4+
5+
fix(service-job): leader-elect `type: 'once'` schedules on `DbJobAdapter` (#13918)
6+
7+
`DbJobAdapter.schedule()` decides which adapter owns a scheduled fire, and only
8+
`CronJobAdapter` takes the cluster lock (`runScheduled()` -> `lock.acquire('job:'
9+
+ name, { waitMs: 0 })`). `cron` schedules were routed there from the start and
10+
`interval` schedules since #13686 — but `once` schedules still went to the inner
11+
`IntervalJobAdapter`, a bare `setTimeout` with no lock. On a multi-replica
12+
deployment a one-shot job therefore ran **once per replica**, not once per
13+
cluster, and a one-shot is the worst-shaped of the three: there is no later tick
14+
during which a business-level de-duplication marker could win, so every replica's
15+
copy lands inside the same short window.
16+
17+
`once` now takes the same leader-elected path as `cron` and `interval` whenever a
18+
cron adapter is assembled, and stays registered on the inner adapter via
19+
`register()` (stored, not armed), so `trigger()`, `replay()`, `getExecutions()`
20+
and `listJobs()` answer for it exactly as before. Affected paths in this repo:
21+
the automation wait-node's timer resume and its cold-boot re-arm
22+
(`@objectstack/service-automation`), schedule-triggered flows with an `at`
23+
(`@objectstack/trigger-schedule`), and app-declared jobs with a `once` schedule
24+
(`@objectstack/runtime`).
25+
26+
**Behaviour change, on multi-replica assemblies only.** A `once` job now fires on
27+
one replica per cluster instead of on every replica. Single-node behaviour is
28+
unchanged in both assemblies: with a cron adapter and no cluster driver the lock
29+
is always granted, and with no cron adapter at all the job still fires on the
30+
inner timer exactly as before. The semantics are **at-most-once per cluster**
31+
(maintainer ruling 2026-09-01): election decides who fires, not that the fire
32+
survives — a leader that dies mid-fire loses it, and nothing re-arms it. That
33+
takes nothing away, because the previous unelected `setTimeout` was not persisted
34+
either and the same crash lost it on every replica at once. No re-arm, retry or
35+
persistence mechanism is added.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Restore the `field` key and the curated conflict sentence on the 409 body for an insert refused by a unique constraint
6+
7+
Since the ObjectQL engine began answering a driver's unique violation with its own `DUPLICATE_RECORD` envelope (`status: 409`, `object`, `field`, the driver error on `cause`), `POST /api/v1/data/:object` let that envelope leave `classifyDataError` through the generic declared-status passthrough: still 409, but with `code: 'DUPLICATE_RECORD'`, no `field`, and the engine's own sentence in `error`. The `isUniqueViolationError` arm that names the conflicting column (`field: 'email'`, "A record with this email already exists") was no longer reached for an insert conflict. Measured with the real engine and real drivers: on `driver-sqlite-wasm` the `field` key disappeared from every conflict on a single-column index; on `driver-memory` the sentence changed.
8+
9+
A dedicated arm for the engine's envelope now sits with the other structured 409s (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`), ahead of the passthrough:
10+
11+
- `code` stays `UNIQUE_VIOLATION` — the code every client branching on this conflict already reads.
12+
- `field` is restored whenever the dialect determinably named the column (SQLite, Postgres); composite keys and index-naming dialects (MySQL) carry no `field` key, exactly as before.
13+
- `error` is the curated end-user sentence again; the engine's own sentence rides on `developerMessage`, the same split the `DELETE_RESTRICTED` body uses.
14+
- The body still quotes nothing the driver said — no offending value, no statement, no index name — including on `driver-memory`, whose raw refusal used to echo the offending values as JSON through the passthrough.
15+
16+
`patch`: a restoration of the shipped body's keys and wording; the wire `code` and the status are unchanged. The arm fires for the engine's envelope only; a plugin or sandbox body that throws the registered `DUPLICATE_RECORD` itself keeps the answer it gets today.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
SCIM provisioning multi-writes now run inside one engine transaction, as the adapter's `#3653` scoping note already declared. On `@better-auth/scim` 1.7.2 the SCIM request scope was stamped with `AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback and was not observed at write time (measured: zero `engine.transaction` calls across `POST /scim/v2/Users` and `PATCH /scim/v2/Users/{id}`), so `sys_user`, `sys_scim_subject` and `sys_scim_user` landed as separate autocommits, and a refused deactivation left the SCIM resource reporting `active: false` for an account that was still enabled. `AuthManager.handleRequest` now opens the scope with `run(...)` around every request under `/scim/v2` — exactly as narrow as before; non-SCIM better-auth flows keep their sequential posture. A refused last-administrator deactivation now rolls the vendor's own `scimUser.active = false` write back, so the SCIM resource keeps reading `active: true`. The pin the #14360 suite held on that residual (`scim-deactivation-reconcile-user.test.ts`, face (c)) is flipped from `false` to `true` deliberately with this change, and a new runtime pin (`scim-transaction-scope.test.ts`) observes each SCIM mutation calling `engine.transaction`.

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ issue 任何人不得认领** —— 那意味着分诊还没走到,不是「可
357357
跑到清空排空稳态,阈值吸收突发(实测 40 分钟到 13 张 ≈ 稳态四倍,审计式倾倒形态)——
358358
没有阈值,倾倒只能按到达顺序摊进常规轮,失掉同族同上下文一次读完的批次红利,
359359
队尾同期继续增长;⛔ 二者不可互相替代。
360-
**紧急卡直接分诊** (维护者 2026-08-13):维护者点名或 p0 嫌疑 ⇒ 立即起
361-
`claude-fable-5` 分诊子代理,不等定时轮次;授权面 = 分诊本身(定级/路由/标签/既有评论格式),
360+
**紧急卡直接分诊** (维护者 2026-08-13):维护者点名或 p0 嫌疑 ⇒ 立即起 `CONTRACT_REVIEW_TIER`
361+
分诊子代理,不等定时轮次;授权面 = 分诊本身(定级/路由/标签/既有评论格式),
362362
⛔ 不写码不认领;产出落卡,与定时轮分诊同格式同效力(细则见 `references/dispatch-runbook.md`)。
363363
**跨仓 pin 链的窗口级兜底也在本 sweep**(联动单第一产者仍是接受座位;
364364
⛔ 只立单不执行 bump): objectui pin 落后且其队列已空 ⇒ 在本仓立/刷新 console bump 单;
@@ -587,16 +587,16 @@ closes-list:部分交付 PR 特意写 Refs 而非 Fixes 时它恰为空,正是
587587
**派发模型是 PM 的逐卡显式决定** —— 下限 `sonnet`(机械卡:
588588
正确性由门禁农场机械判定,失败在漏跑门不在判断);默认判断档 `opus`(M/L、
589589
裁决实施、任何带设计判断的卡;拿不准就升一档 —— 错派低档的返工贵过省下的额度);
590-
上限 `fable`(最重协议/流程/编排卡,按卡取用非新默认)。**⛔ 强制条款两条**:
591-
① 凡改**协议语义面**的卡一律 `model: "claude-fable-5"` —— 面 = 本 `SKILL.md` 主文件、
590+
上限 `fable`(最重协议/流程/编排卡,按卡取用非新默认)。**⛔ 强制条款两条**:① 凡改
591+
**协议语义面**的卡一律 `CONTRACT_REVIEW_TIER` 档(`--tier` 输出)—— 面 = 本 `SKILL.md` 主文件、
592592
决策框架拷贝所在文件(以 `check:skill-frame-sync` 强制处为准)与 `.claude/agents/os-dev.md`(2026-08-20
593593
收窄,维护者原话:「接受你的建议」—— fable 当审计师用,不当施工队用);
594594
`references/**` 面、或任一 pm-dispatch 治理面上一行级机械文本改动的卡降为 **opus 施工**
595595
(纯机械一行 PM 酌定可至 sonnet 地板),**补偿控制即既有技能面复核档要求**(ACCEPT 路径分叉
596596
①:复核席跑契约复审档位)—— opus 施工的治理面改动只经 fable 档复核到达维护者,
597597
⛔ 不新增标签不新增链;② 凡**改变契约接受/拒绝行为或扩大公开面**的卡(`domain:spec`
598598
语义面;判据即分诊代裁的机械边界测试与 spec 席内分派判据 —— `references/lanes/spec.md`,
599-
⛔ 不另抄第二份)一律 `claude-fable-5`(维护者 2026-08-12 裁定,
599+
⛔ 不另抄第二份)一律 `CONTRACT_REVIEW_TIER`(维护者 2026-08-12 裁定,
600600
原话:「同意,就按语义面收窄,立卡并通知 spec 席」)—— 契约错毒化一切下游,
601601
全仓最贵(条款②闸门见「入队与落地」)。**负边界(维护者 2026-08-28 裁定)**:
602602
运行时权限/安全**行为**变更不是条款② —— 归人工地板「安全/权限边界」

.github/workflows/cut-rc.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -866,11 +866,11 @@ jobs:
866866
echo "@objectstack/cli@$(jq -r '.version' packages/cli/package.json) is on main at ${LOCAL}."
867867
868868
# Pre-publish gate #2035, kept in step with release.yml's publish job rather
869-
# than dropped for speed. In pre mode (which the guard step proved is active,
870-
# so ALWAYS on this lane) the #3600 amendment makes it advisory: a major
871-
# train exists to ship deliberate surface removals, and a migrated hotcrm
872-
# cannot exist until the rc artifacts it would migrate against are published.
873-
# It reports; it cannot block.
869+
# than dropped for speed. This step is advisory by construction — it has no
870+
# BLOCKING switch — because this lane only ever runs in pre mode (the guard
871+
# step above proved it active). It reports; it cannot block. For why the
872+
# equivalent gate in release.yml is currently advisory too, and what re-arms
873+
# it, see that step's own comment.
874874
- name: Downstream backward-compat smoke (live hotcrm, advisory in pre mode)
875875
if: ${{ !inputs.dry_run }}
876876
env:
@@ -879,7 +879,7 @@ jobs:
879879
if bash scripts/downstream-smoke.sh; then
880880
echo "::notice::hotcrm@${HOTCRM_REF} is still compatible with the pre-release train."
881881
else
882-
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the pre-release train — expected for this window's deliberate removals. Ship a migrated hotcrm release and bump HOTCRM_REF in release.yml before 'changeset pre exit' re-arms that gate."
882+
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the pre-release train — expected for this window's deliberate removals. Ship a migrated hotcrm release, bump it, and set BLOCKING=1 in .github/workflows/release.yml to re-arm this gate."
883883
fi
884884
885885
# ────────────────────────────────────────────────────────────────────────

content/docs/references/api/events.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const result = BulkDataEventSchema.parse(data);
5757
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` || Event type |
5858
| **object** | `string` || Object name |
5959
| **recordId** | `string` || Record ID |
60+
| **organizationId** | `string` | optional | Organization the record belongs to (its organization_id), so a tenant-scoped consumer can discriminate the event's tenant without reading the record body. Absent when the record belongs to no organization: every event on a single-posture deployment (no organization wall, nothing stamps the column), and a row that carries no organization under a walled posture (environment-wide, or an object outside the wall) — read absence as "not behind any organization wall", never as "unknown". Present = exactly that organization; never fabricated, and the empty string is refused. |
6061
| **changes** | `Record<string, any>` | optional | Changed fields |
6162
| **before** | `Record<string, any>` | optional | Before state |
6263
| **after** | `Record<string, any>` | optional | After state |

packages/core/src/security/authz-store-unavailable.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,31 @@ export const AUTHZ_STORE_UNAVAILABLE_MESSAGE =
106106

107107
/**
108108
* The own-property brand {@link isAuthzStoreUnavailableError} tests for.
109-
* A string-keyed own property (not a `Symbol.for` registry key) so it survives
110-
* `structuredClone`, and so a duplicated copy of this module still brands
111-
* identically.
109+
* A string-keyed own property (not a `Symbol.for` registry key), so a
110+
* duplicated copy of this module still brands identically — which is exactly
111+
* what `instanceof` cannot do (module doc above).
112+
*
113+
* ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends
114+
* on it doing so — the same measured behaviour `service-not-registered.ts`
115+
* records for its own brand. Reproduce on Node 22.22.2:
116+
*
117+
* ```js
118+
* const e = new Error('x'); e.__brand = true; e.code = 'C';
119+
* const c = structuredClone(e);
120+
* // c.__brand === undefined c.code === undefined c.message === 'x'
121+
* // control: structuredClone({ __brand: true, code: 'C' }) keeps BOTH keys
122+
* ```
123+
*
124+
* `Error` has a dedicated serialization carrying `message`, `stack` and
125+
* `cause` only, so it DROPS every other own property — this brand, the
126+
* ADR-0112 `code`, `status` and `object` alike (and a subclass's own `name`
127+
* returns as `'Error'`). The plain-object control is the half that proves the
128+
* loss is specific to `Error`, not general to `structuredClone`.
129+
*
130+
* ⛔ So never branch on this brand across a worker or `postMessage` boundary:
131+
* it would answer `false` and fail OPEN. Every call site today is in-process —
132+
* `rethrowAuthzStoreUnavailable` on the rest rethrow paths and
133+
* `isAuthzStoreUnavailableError` inside service `catch` blocks.
112134
*/
113135
const AUTHZ_STORE_UNAVAILABLE_BRAND = '__objectstackAuthzStoreUnavailable' as const;
114136

0 commit comments

Comments
 (0)