Skip to content

Commit 335aaf9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23001-prerelease-artifact-window
2 parents 18b2fe2 + 4e6b270 commit 335aaf9

40 files changed

Lines changed: 2704 additions & 139 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/metadata-core': patch
4+
---
5+
6+
fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877)
7+
8+
Clause-②: no
9+
10+
**What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it.
11+
12+
**What changed in the store.**
13+
14+
- `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before.
15+
- The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member.
16+
- The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index.
17+
18+
**What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member.
19+
20+
**What happens on the first boot after upgrading.** Nothing to configure.
21+
22+
1. The schema sync adds the `bundle_member` column.
23+
2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written.
24+
3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`.
25+
26+
**On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration.
27+
28+
`os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): two pure row → definition mappers for the ADR-0131 upgrade ceremony (`permissionSetDefinitionFromRow`, `positionDefinitionFromRow`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `permissionSetDefinitionFromRow(row)` turns one `sys_permission_set` row into the permission-set definition it carries, and `positionDefinitionFromRow(row)` turns one `sys_position` row into the position definition it carries. The row types `PermissionSetRowForDefinition` and `PositionRowForDefinition` are exported with them. Both sit beside `convertPositionBindingRows`. The upgrade ceremony (`os migrate`) is to apply them, to save Setup-authored rows through the metadata door. ⛔ Neither runs at boot.
10+
- **The permission-set mapping.** The JSON-text facet columns become the camelCase body keys: `object_permissions` → `objects`, `field_permissions` → `fields`, `system_permissions` → `systemPermissions`, `row_level_security` → `rowLevelSecurity`, `tab_permissions` → `tabPermissions`, and `admin_scope` → `adminScope` only when the column is truthy and reads to a truthy value. `label` falls back to the name only when the column is `null` or absent; an empty-string label is kept (unlike the position rule below). A facet column that is `null`, absent, an empty string, JSON `null` or unparseable text answers its default; a facet a driver already parsed is kept as it is. Only the definition columns are read, so `active`, `managed_by`, `customized` and the timestamps never enter the body. The body is then filtered to the keys `PermissionSetSchema` declares.
11+
- **The position mapping.** `{ name, label, description, delegatable }`: `label` falls back to the name unless it is a non-empty string (so `''` is replaced by the name). `description` travels only when it is a non-empty string: `null`, `''`, an absent column and a non-string are absent, and a whitespace-only string travels as stored. `delegatable` is `true` only for a stored `true`, `1`, `'1'` or `'true'`. `active` and `is_default` are row state and never travel.
12+
- **No behaviour changes.** Both functions are pure (one row in, one body out). They produce the same output as the security plugin's own copies, `permissionSetBodyFromRow` and `positionBodyFromRow`, and nothing calls them yet. Neither validates: the metadata door still refuses a body or a name it does not accept.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): a user's own write to their own `sys_user_preference` row no longer adds a `sys_activity` row. The `sys_audit_log` row is written as before
6+
7+
Clause-②: no
8+
9+
The console saves the user's "recently visited" list (`ui.recent`), favourites and palette recents as `sys_user_preference` rows as they navigate. The audit writer mirrored each of those writes into `sys_activity`, and the bell's Activity tab lists the newest activity rows the caller may read. So every navigation came back to the user as `Updated User Preference "ui.recent"`, and a fresh user's bell showed nothing else.
10+
11+
- **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`.
12+
- **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged.
13+
- **Existing rows.** Activity rows written before this release are not deleted. They leave the feed when the `sys_activity` retention window (14 days) removes them.
14+
- **Nothing to migrate.** No configuration is needed.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
8989

9090
- 状态只经 GitHub 标签、assignee、正文行与 `pm:seat` 座位贴读写;循环须能从全新会话恢复。
9191
- 用户账号仅三用:assignee、授权批准、维护者亲手;批准账号永不跑席位或作其关联用户。
92-
- 内容写只走 REST 代理,⛔ 无 MCP 写;`user.login` 记令牌不记席位,归属 = 文本里的 session ID。
93-
- 写侧恒为 `objectstack-fleet[bot]`(AGENTS.md);节奏 ≥3 秒/笔、每账号每小时 ≤40 笔,429 即停写。
92+
- 内容写只走 REST 代理,⛔ 无 MCP 写,唯一例外 `rerun_failed_jobs`(限 CI 红单次重跑,先核 head)。
93+
- 写侧恒为 `objectstack-fleet[bot]`;`user.login` 记令牌不记席位,归属 = 文本里的 session ID。
9494
- GitHub 上一切新内容用英文;中文只留四通道(维护者速读、轮报、裁决引文、四维分析)。
9595
- 裁决引文照抄不译;四维中文只管新记录,存量英文块 ⛔ 不迁移;存量中文 ⛔ 不追溯改写。
9696
- 一 PM 恰管一车道,一车道由一组座位管,一席一贴;PM Y 管什么恰一个答案。

‎docs/NORTH-STAR.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
- records · 写入规则在服务端兑现,撞车响亮、后悔得了、改过什么看得见 · records-forms · validation-rule-type-matrix, object-hook-lifecycle, delete-behavior-matrix, concurrent-edit-conflict, record-edit-undo, field-history-tracking
4141
- records · 记录上的动作与协作:按钮在声明的位置、参数契约在派发时兑现、讨论与 @ 提醒 · records-forms · action-location-matrix, action-param-widgets, upload-guard-blocks-confirm, record-discussion-mentions
4242
- records · 搜得到:跨字段、字段限定、权限一致、拼音、即时新鲜;全局搜索与命令面板同一条路 · search · cross-field-object-search, field-scoped-narrowing, rls-both-personas, pinyin-flag-both-sides, freshness-and-empty, console-global-search, command-palette-navigation
43-
- records · 共享选项集:多个对象复用同一份选项,客户按组织追加值而不改应用源码 · records-forms · picklist-shared-across-objects, picklist-org-append
43+
- records · 共享选项集:多个对象复用同一份选项 · records-forms · picklist-shared-across-objects
4444
- access · 行级与字段级权限两边都对:受限成员只看自己的,该只读的只读、该看不见的不回给前端 · access-security · rls-both-sides, scope-depth-asymmetry, fls-mask-and-strip
4545
- access · 增删改查逐格兑现,改完权限立刻换脸,自查接口与服务端一致 · access-security · crud-permission-matrix, permission-matrix-edit-loop, me-permissions-aggregation-parity
4646
- access · 写入路径挡得住:只读剥离、伪造与转移 owner 被拒、默认可见度只能收紧 · access-security · write-path-guards, owd-save-gate

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4315,7 +4315,7 @@
43154315
"title": "One shared picklist on two objects: both are served the same resolved options, a package extension adds to both, a value outside the set is refused, and a locale switch relabels",
43164316
"since": null,
43174317
"status": "planned",
4318-
"revision": 1,
4318+
"revision": 2,
43194319
"priority": "P2",
43204320
"surface": "mixed",
43214321
"personas": [
@@ -4354,7 +4354,7 @@
43544354
"an extension that duplicates, replaces or removes a base value is a FAIL: extensions are additive only, and a duplicate is refused loudly rather than last-wins"
43554355
],
43564356
"source": [
4357-
"#18164 design of record (comment 5715762696), items 1 to 6: the kind, the field reference, server resolution, the additive package extension, the translation face, and the verification sentence that this item and records-forms.picklist-org-append split between them",
4357+
"#18164 design of record (comment 5715762696), items 1 to 6: the kind, the field reference, server resolution, the additive package extension, the translation face, and the verification sentence, which is now this item's alone: its per-organization half left with records-forms.picklist-org-append, retired by #22869 as ruled out",
43584358
"#19518, the spec layer (landed as addbbf02ab): packages/spec/src/data/picklist.zod.ts#PicklistSchema",
43594359
"#19518, the extension shape: packages/spec/src/data/picklist.zod.ts#PicklistExtensionSchema",
43604360
"#19519, the runtime layer this item waits on (load before `object`, additive merge, resolve at serve time, write validation against the resolved set); its acceptance sentence is this item's three clauses"
@@ -4365,15 +4365,21 @@
43654365
"date": "2026-09-30",
43664366
"change": "initial — planned. The spec layer landed (#19518), but no runtime reads a picklist yet (packages/spec/liveness/picklist.json grades every key `planned`), so there is nothing to drive. The acceptance clauses are drafted from the design of record and #19519's acceptance; steps arrive with #19519, in the edit that promotes this item after a run in which it passes. The locale relabel sits on this item rather than on the organization item because it is phase-1 behaviour, and a promotion needs the whole item to pass",
43674367
"ref": "#19518"
4368+
},
4369+
{
4370+
"revision": 2,
4371+
"date": "2026-10-12",
4372+
"change": "source citation only — no clause, negative or fixture moved. The design-of-record citation said this item split the verification sentence with records-forms.picklist-org-append; that item is retired (the per-organization append is ruled out, see its retiredReason), so the citation now says the remaining sentence is this item's alone",
4373+
"ref": "#22869"
43684374
}
43694375
]
43704376
},
43714377
{
43724378
"id": "records-forms.picklist-org-append",
43734379
"title": "An organization appends a value to a shared picklist without changing the app source: that organization can write it, and another organization is refused",
43744380
"since": null,
4375-
"status": "planned",
4376-
"revision": 1,
4381+
"status": "retired",
4382+
"revision": 2,
43774383
"priority": "P2",
43784384
"surface": "mixed",
43794385
"personas": [
@@ -4405,8 +4411,15 @@
44054411
"date": "2026-09-30",
44064412
"change": "initial — planned. The docs/NORTH-STAR.md feature-map line names per-organization append as half of the shared-picklist feature, and the design of record's verification sentence names this behaviour, so the ledger holds it as a planned requirement rather than leaving it off. It carries no acceptance clauses: neither the overlay's schema nor its write door is designed yet, and a clause would be written against a design nobody has settled",
44074413
"ref": "#19518"
4414+
},
4415+
{
4416+
"revision": 2,
4417+
"date": "2026-10-12",
4418+
"change": "retired — the per-organization append is ruled out rather than deferred, so there is no requirement left to hold as planned; see retiredReason. Kept as a row, with its fields, so the history stays readable",
4419+
"ref": "#22869"
44084420
}
4409-
]
4421+
],
4422+
"retiredReason": "Capability ruled out, with no successor: the maintainer's direct order of 2026-10-11 (filed as #22869) reads 「按组织追加 picklist 值,不考虑」, and ADR-0131 D6 had already retired ADR-0005's per-organization overlay axis this item depended on (docs/adr/0131-total-organization-ownership-no-null-organization-id.md), so no organization will append picklist values, the append is not moved to environment level either, and the shared picklist itself stays on records-forms.picklist-shared-across-objects"
44104423
},
44114424
{
44124425
"id": "records-forms.script-action-hook-refusal-toast",

‎docs/qa/platform-checklist/coverage.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@
230230
]
231231
},
232232
"picklist": {
233-
"waived": "WAIVED 2026-09-30 — there is no runtime behaviour to drive yet, measured rather than asserted. The spec layer landed (#19518, addbbf02ab): PicklistSchema, `Field.select({ picklist })`, PicklistServedFieldSchema, `picklistExtensions` and the `picklists.NAME` translation face. No runtime reads a picklist: packages/spec/liveness/picklist.json grades every key `planned`, and scripts/check-stack-collection-maps.mjs carries `picklists` and `picklistExtensions` as rows PENDING the runtime layer (#19519) in the METADATA_ARRAY_KEYS and ARTIFACT_FIELD_TO_TYPE registration maps. No code resolves a picklist onto a field or judges a write against one, so an item written to RUN today could only assert absence. The two items that will carry this kind are authored as `planned`: records-forms.picklist-shared-across-objects (phase 1: two objects, the package extension, the write door, the locale relabel) and records-forms.picklist-org-append (phase 2: the per-organization overlay, deferred by ruling 5904864936 on #18164). They are not listed under `items`, because a kind whose only items are planned is UNMAPPED and an entry cannot hold a waiver and items together. WHAT RETIRES THIS WAIVER — #19519 lands resolve-at-serve, the additive merge and write validation; records-forms.picklist-shared-across-objects gains its steps, runs, passes and is promoted to `active`, and the same edit replaces this waiver with `items` naming it, the planned organization item beside it. ⚠️ Re-audit it every sweep, as SWEEP.md asks of every waiver."
233+
"waived": "WAIVED 2026-09-30 — there is no runtime behaviour to drive yet, measured rather than asserted. The spec layer landed (#19518, addbbf02ab): PicklistSchema, `Field.select({ picklist })`, PicklistServedFieldSchema, `picklistExtensions` and the `picklists.NAME` translation face. No runtime reads a picklist: packages/spec/liveness/picklist.json grades every key `planned`, and scripts/check-stack-collection-maps.mjs carries `picklists` and `picklistExtensions` as rows PENDING the runtime layer (#19519) in the METADATA_ARRAY_KEYS and ARTIFACT_FIELD_TO_TYPE registration maps. No code resolves a picklist onto a field or judges a write against one, so an item written to RUN today could only assert absence. The item that will carry this kind is authored as `planned`: records-forms.picklist-shared-across-objects (phase 1: two objects, the package extension, the write door, the locale relabel; the phase-2 per-organization overlay is ruled out, #22869, and no item carries it). It is not listed under `items`, because a kind whose only items are planned is UNMAPPED and an entry cannot hold a waiver and items together. WHAT RETIRES THIS WAIVER — #19519 lands resolve-at-serve, the additive merge and write validation; records-forms.picklist-shared-across-objects gains its steps, runs, passes and is promoted to `active`, and the same edit replaces this waiver with `items` naming it. ⚠️ Re-audit it every sweep, as SWEEP.md asks of every waiver."
234234
},
235235
"position": {
236236
"items": [

‎packages/core/src/security/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,18 @@ export {
279279
type PositionBindingConversion,
280280
} from './position-binding-conversion.js';
281281

282+
// ADR-0131 D3/D10 — a Setup-authored catalog row turned into the definition it
283+
// carries, so the upgrade ceremony (`os migrate`) can save it through the
284+
// metadata door. Pure; applied by the ceremony, never at boot.
285+
export {
286+
permissionSetDefinitionFromRow,
287+
type PermissionSetRowForDefinition,
288+
} from './permission-set-row-definition.js';
289+
export {
290+
positionDefinitionFromRow,
291+
type PositionRowForDefinition,
292+
} from './position-row-definition.js';
293+
282294
// [commit f8eb73601] The measured read surface of the administrator derivation — the
283295
// single source `plugin-auth`'s break-glass standing-key lists correspond to.
284296
export {

0 commit comments

Comments
 (0)