Skip to content

Commit 8b118b6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24519-approvals-context-identity
2 parents 31100d0 + 65a8eeb commit 8b118b6

68 files changed

Lines changed: 3813 additions & 572 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/plugin-approvals': minor
5+
---
6+
7+
feat(automation)!: an approval decision resumes a flow as the person who decided
8+
9+
Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A runtime semantics change of the declared key flow.runAs (both values) after an approval decision, not a metadata shape change. No spec key, enum value, export, option, config field, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is whose rights a runAs 'user' run's nodes after an approval decision carry: the decider's instead of the trigger user's, so a node the decider may not perform is refused where it used to run. A runAs 'system' run keeps its elevation and only its attribution user moves to the decider. The other categories are closed on facts: the three packages publish (not unpublished); no ADR-0087 id covers this behaviour and this diff adds none (not registered / already-registered); and the change is a behaviour change, not only an interface or type change (not runtime-interface-only / type-surface-only). -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants.
16+
17+
`flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider.
18+
19+
- **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it.
20+
- **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter.
21+
- **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too.
22+
- **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors.
23+
24+
`@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`).
25+
26+
What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': minor
4+
'@objectstack/plugin-email': minor
5+
'@objectstack/plugin-auth': minor
6+
'@objectstack/cli': patch
7+
---
8+
9+
feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column
10+
11+
Clause-②: yes (widening)
12+
13+
ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing.
14+
15+
- **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links.
16+
- **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written.
17+
- **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`.
18+
- **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`.
19+
- **What a reader of `sys_email` sees.**
20+
- **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall.
21+
- **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed.
22+
- **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`.
23+
- **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release.

‎.changeset/24537-findfile-memo.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
fix(metadata): `FilesystemLoader` remembers which file a metadata name resolves to, so a repeated read stops probing the filesystem (#24537)
6+
7+
Clause-②: no
8+
9+
`FilesystemLoader` used to make one `fs.access` call per resolvable extension (five under `MetadataPlugin`'s format set) on every `load()`, `exists()` and `stat()`, for a name no file holds as well as for one a file holds. Every authenticated request paid this cost: the security catalog asks the metadata service for each held permission set the engine registry does not serve, and in an artifact-mode runtime no such file exists.
10+
11+
Each loader instance now remembers the answer for each `(type, name)`: the path, or no file. Only something that can change the answer retires it:
12+
13+
- a `save()` through the loader;
14+
- a walk of the type's directory by the loader (`list()`, `loadMany()`, `loadManyKeyed()`), so a name `list()` reports is not answered `null` by `get()`;
15+
- a read with `useCache: false`, which re-resolves from disk. The file watcher's re-read on an `added` or `changed` event is such a read;
16+
- a remembered path that the next read finds deleted. That read resolves the name again from disk and logs no stat failure. This covers an `unlink` event, and a deletion that no event reports.
17+
18+
The memo holds at most 10,000 names and then starts over.
19+
20+
One change is not seen at once. Suppose a name has already been answered "no file", and then another process creates that file under the loader's root while no watcher runs (`watch: false`). The loader does not see the file until the next walk of that type, the next `save()` through the loader, or a restart. Content edits to a file that already resolves are still read on the next request. Nothing in the public API changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: a flow resume body that carries both `inputs` and `variables` is refused with `400 VALIDATION_FAILED`; it no longer answers 200 with the `variables` bag silently dropped
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape, export or error code is added, removed or renamed. The resume route's body is declared by no published schema, and its accepted key set is unchanged: `inputs`, `variables`, `output`, `branchLabel`. The route now refuses one combination it accepted before, a body carrying both `inputs` and `variables`, whose `variables` bag it never read. Nothing stored changes, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING** (an accept-set narrowing on a public HTTP door: `POST /api/v1/automation/:name/runs/:runId/resume`), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`variables` is an accepted alias of `inputs` on the resume route: both name the screen submission. A body that carried both used to answer `200` with `success: true`, the run resumed on the `inputs` bag, and the `variables` bag was never read, even when it held a field the screen does not declare. Sent alone, that same bag is refused `400 INVALID_SCREEN_INPUT`.
14+
15+
- **Now.** A body carrying both keys answers the route's own validation-failure envelope: `400`, `error.code: 'VALIDATION_FAILED'`, and `details.fields` naming `inputs` and `variables` (code `invalid_value`). The message says to send `inputs` alone. The engine is never consulted, so the paused run stays parked and the same call with a corrected body resumes it.
16+
- **Unchanged.** A body carrying `inputs` alone, or `variables` alone, is forwarded exactly as before, and the engine judges it exactly as before. A key whose value is `undefined` counts as absent, as it already did for the value-shape check. The unknown-key and value-shape refusals keep their messages and come first.
17+
18+
What to do. Send the screen submission under `inputs` only. `@objectstack/client`'s `automation.resume()` and the console's flow runner already send `inputs` alone, and the MCP `resume_run` tool builds its signal in process rather than through this body, so none of them is affected.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a seed replay no longer rewrites an unchanged row whose `time`, `datetime` or `date` value the seed spells differently from the stored form (#24562)
6+
7+
Clause-②: no
8+
9+
The replay's no-op check (`isNoOpReplay`) compared a seed value with the stored one as text, so `'09:15'` against the stored `'09:15:00'`, `'2026-06-17T14:30:00Z'` against `'2026-06-17T14:30:00.000Z'`, and a `date` written as an instant against its stored `YYYY-MM-DD` all read as changed. Every boot rewrote those rows: `updated_at` moved, lifecycle validation ran again, history tracking recorded an edit nobody made, and a parent's summary recompute moved the parent's `updated_at` too.
10+
11+
A field the object declares `time`, `datetime` or `date` is now compared in its stored form, by the rule the drivers apply on write (`temporalStorageForm`, `@objectstack/core`). Two spellings of the same wall clock, instant or calendar day skip the row. A different value is still rewritten, and a value that rule cannot read still counts as changed. What a seed may declare, and what any write stores, is unchanged.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): a reminder's notification no longer carries one-tap approve / reject links, so no stored notification row holds a raw action token
6+
7+
Clause-②: no
8+
9+
- **What changed.** `remind()` still sends each concrete pending approver their own `approval.reminder` notification, with the same title, body and deep link to the request in the Approvals Inbox. It no longer mints ADR-0043 action tokens for that notification, and the payload it hands to `messaging` has no `actions` list. Messaging stores that payload, on the `sys_notification` event row and on each `sys_notification_delivery` row, so the links were kept in the database as usable links. ADR-0043's storage rule (`sys_approval_token` keeps SHA-256 digests only) exists so that the database holds none.
10+
- **What a recipient sees.** Nothing changes. A reminder names no channel, so it is delivered to the in-app inbox, and the inbox row never carried the links.
11+
- **Unchanged.** The action page (`GET` and `POST /api/v1/approvals/act`), `issueActionTokens`, `peekActionToken` and `redeemActionToken` behave exactly as before, and a redeemed token still decides as the approver it binds. The approval notification payload type no longer accepts links, and `issueActionTokens` now states that a raw token may travel only in a message that is sent and not kept.
12+
- **Rows written before this fix.** A reminder sent by an earlier build stored its links. Each of those tokens stops working at its 72-hour expiry, or earlier: once it is used, once its request is decided or recalled, or once its slot is reassigned. The rows themselves age out under the notification tables' retention, 90 days at most.

‎.claude/skills/pm-dispatch/references/execution-duties.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
- 整车道一次读全,本地求交:`list_issues` 带 `labels: [domain:X]` + 最小字段拿回全车道 open 集。
1010
- 候选 = open、未 assign、无 `needs-user-decision`、无 `pm:retriage`;⛔ 排除三门皆无的 `tooling` 卡。
11-
- 自贴 `domain:*` 卡:车道合域表且正文齐 `filing-gate.md` 三件 ⇒ 候选;缺一 ⇒ 挂 `pm:retriage`。
11+
- 自贴 `domain:*` 卡:合域表且齐立卡门三件 ⇒ 候选;缺一 ⇒ 挂 `pm:retriage`,⛔ 只评论不算。
1212
- 已排队父单的 open sub-issue 自动是候选,`pm:epic` 父单的子树除外。
1313
- 每张候选读全文 + 全部评论,写派发词前做决策复读:评论读到最后一页。
1414
- 维护者裁决逐字引入派发词裁决分区作约束,无则写明无裁决。

‎.claude/skills/pm-dispatch/references/filing-gate.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
- ③ 维护者直派的任务,正文引其原话。
1919
- ④ 协调节点:跨仓/跨层父单与它的逐层子单。
2020
- ⛔ 不是卡:席位自己的失误、协议观察与「记住这条」,它们进座位贴或收班简报。
21-
- ⛔ 不是卡:三类外或无 `reach:` 的 `finding`,它们进 Acceptance notes 带 `carrier:`。
2221
- 「搭下个碰 X 的 PR」不算 `carrier:`;已落地工作欠的后续照立卡,⛔ 不按「不是卡」压下。
2322
- ⛔ 不是卡:丢卡重建(除非自身即 ①–④)、sweep 每 fire 重导的 pin/bump/卫生提醒。
2423
- ⛔ 不是卡:既有卡一条评论能承载的指针/知会/请求;那条评论即其记录。
2524
- UI 卡写明所需通用块或 spec 契约门;要产品命名 widget/dialog/hook/命名空间的卡退回重立。
2625
- POST 前正文答三件:类别 ①–④、动手的读者(哪席哪步)、查重查询与命中数(含 closed)。
27-
- 缺任一件的卡由分诊首触即关、不定级,关单一行点名缺的是哪一件。
26+
- 缺件卡由分诊首触补齐能从卡与一次检索推导的三件,写进定级评论并定级。
27+
- 补不齐(四类皆非,或 `finding` 无 `reach:`)者才因缺件首触即关 not_planned,一行点名所缺。
2828
- 「动手的读者」与 Acceptance notes 同一问:说得出具体 PR 或人 ⇒ 写进去,说不出 ⇒ 不立卡。
2929
- 说不出的形态:`.changeset/*`、无人在改的文档页、生成物、已扫完的批次,兜底不成立。
3030
- 配额:一席每 fire 至多立三张;sweep 超出的折进一张带清单的卡,或折进座位贴。

‎.claude/skills/pm-dispatch/references/triage-duties.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- 析取 ②:有 `pm:queue` 无 `domain:*`(仅多车道仓);析取 ③:有 `domain:*` 无 pm-state。
1515
- 析取 ④:有 `pm:queue` 与 `domain:*` 而无 `priority:*`;②③④ 只取 `updated_at` 早于 ~2 分钟。
1616
- 排除 `tracking`、`status:parked`、全部 `pm:seat` 贴、`qa-run` 记录。
17-
- 自贴卡抽查:窗内新派发且 `domain:*` 事件与建卡同分钟者,逐张核车道、三件;⛔ 不凭 actor。
17+
- 自贴卡抽查:窗内新建且 `domain:*` 事件与建卡同分钟,逐张核车道、补齐三件;⛔ 不凭 actor。
1818
- 半状态治愈积压 ②③④ 最老优先、P0 嫌疑先行,⛔ 优先最新不适用。
1919
- 首触定级与三析取 sweep 每 fire 跑到清空:⛔ 无每轮总量预算,清得动就多清。
2020
- 窗口耗尽未跑完的,下一 fire 从标签状态断点续跑。

0 commit comments

Comments
 (0)