Skip to content

Commit 0862db0

Browse files
committed
Merge commit 'faf634850' into claude/issue-22466-refused-attach-tombstone
2 parents 00ed254 + faf6348 commit 0862db0

47 files changed

Lines changed: 2300 additions & 61 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.

‎.changeset/19939-flow-value-slot-template-dialect-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ A string whose tokens include one of these is not refused. Text slots (`notify`
3030
| you wrote | write instead | what changes |
3131
|:--|:--|:--|
3232
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
33-
| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run |
33+
| `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run |
3434
| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` |
3535
| `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` |
3636
| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`)
6+
7+
Clause-②: no
8+
9+
The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`.
10+
11+
The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded as `has(vars.list.tags) ? vars.list.tags : null`.
12+
13+
Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os serve <config>`: a multi-package config now serves its flat `src/docs/` pages, under the package that owns the stack's manifest
6+
7+
Clause-②: no
8+
9+
A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]`, such as one `composeStacks([…], { manifest: 'preserve' })` returns, did not serve the stack's own `src/docs/*.md` pages. The boot put them on the config's top level, and a config boot registers that config's package bodies, never its top level. `GET /api/v1/meta/doc` did not list them, and nothing warned. `os build` of the same project, booted as an artifact, served them.
10+
11+
- **What changes.** The config boot places the flat pages where `os build` places them: on the body of the one `packages[]` entry whose id is the stack's `manifest.id`, after that package's own `src/<pkg>/docs/` pages. `GET /api/v1/meta/doc` now lists them under that package, as an artifact boot of the same project does.
12+
- **What stays the same.** A single-package config (no `packages[]`) serves its flat pages under its one package, as before. Each package's own `src/<pkg>/docs/` pages are served under that package, as before. Artifact boots (`os dev`, `os start`, `os serve` with `dist/objectstack.json`) do not change.
13+
- **When nothing moves.** If no entry has the manifest id, if several do, or if the stack declares no `manifest.id`, the flat pages stay on the top level, as `os build` keeps them. A config boot of such a stack still does not list them.
14+
- **A name shared by an inline doc and a flat page.** `os build` refuses this (`docs/duplicate-name`); a config boot does not lint docs. On a single-package config the flat page still replaces the inline doc of the same name. On a multi-package config the flat page is now served under the owning package, and an inline doc on that package's body with the same name is served with the flat page's content.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/lint": minor
6+
"@objectstack/cli": minor
7+
---
8+
9+
feat(i18n): a refused `end` node's message translates in the run's locale (`flows.<flow>.refusals.<node_id>.message`)
10+
11+
Clause-②: yes (widening)
12+
13+
- **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows.<flow_name>.refusals.<node_id>.message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console.
14+
- **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets.
15+
- **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key.
16+
- **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message.
17+
- **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error).
18+
- **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it.
19+
- **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
`security/explain` answers an update of a `controlled_by_parent` record the way that record's own by-id update is answered: its record verdict now comes from the master-detail write check (ADR-0055), which it never asked.
6+
7+
- **What was wrong.** On such a record the sharing service's per-record gate abstains, because the effective model of `controlled_by_parent` is `public`, and explain read the abstention as "writable". It also kept the platform ownership floor (`owner_only_writes`) on the record, which the write path hands over to the master-detail write check. So `record.visible` for an update was decided by that floor (`decidedBy: 'rls'`) for every principal who did not create the record. That was refused, on the wrong layer and with no word about the master, for a principal who may not edit the master. It was also refused for a principal who may edit the master, beside a `PATCH` that answers `200`.
8+
- **What it does now.** For every update of a record that exists, explain asks the security service's `checkControlledByParentWrite` with the context it is explaining (another user's, when `userId` names one). A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the refusing leg (`object_permission`, `row_level_security`, `record_sharing` or `master_chain`) or the reason the check reached no verdict. A rejection of the check is reported fail-closed, as a dependency that throws already is. `allow` and `not_applicable` leave the report exactly as before. The update's row-level composition carries the write path's own master-gate coverage vouch, so the ownership floor is handed over to that check as the write path hands it, and never on behalf of a delegator.
9+
- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field, which answers whether the principal may update the object at all. `delete` is unchanged too: explain asks no master check for it and keeps its floor.
10+
- **Public surface.** `ExplainEngineDeps` gains the optional `checkControlledByParentWrite` dependency. A deps bag wired without it explains an update as before, and claims nothing about a master.

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ for some input, so the rewrite is yours to judge.
266266
| you wrote | write instead | what changes |
267267
|:---|:---|:---|
268268
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
269-
| `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run |
269+
| `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run |
270270
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
271271
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
272272
| `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1624,7 +1624,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16241624
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
16251625
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
16261626
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
1627-
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
1627+
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
16281628
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
16291629
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
16301630
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |

‎content/docs/references/system/translation.mdx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Platform translation data — the per-app groups plus the platform-only `setting
176176
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
177177
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
178178
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
179-
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
179+
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
180180
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
181181
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
182182
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
@@ -259,6 +259,7 @@ Translation data for a single object
259259
| :--- | :--- | :--- | :--- |
260260
| **label** | `string` | optional | Translated flow label |
261261
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
262+
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
262263

263264
### Nested Shape: `PlatformTranslationData.metadataForms[string]`
264265

@@ -375,7 +376,7 @@ Per-app translation data for objects, apps, and UI messages
375376
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
376377
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
377378
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
378-
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
379+
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
379380
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
380381
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
381382

@@ -457,6 +458,7 @@ Translation data for a single object
457458
| :--- | :--- | :--- | :--- |
458459
| **label** | `string` | optional | Translated flow label |
459460
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
461+
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
460462

461463
### Nested Shape: `TranslationData.metadataForms[string]`
462464

@@ -524,7 +526,7 @@ One locale of translations — the `translation` metadata type
524526
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
525527
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
526528
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
527-
| **flows** | `Record<string, { label?: string; screens?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
529+
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
528530
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
529531
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
530532
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
@@ -616,6 +618,7 @@ Translation data for a single object
616618
| :--- | :--- | :--- | :--- |
617619
| **label** | `string` | optional | Translated flow label |
618620
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
621+
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
619622

620623
### Nested Shape: `TranslationItem.metadataForms[string]`
621624

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 354 |
22+
| `system/` | 355 |

0 commit comments

Comments
 (0)