Skip to content

Commit 9a26de3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15136-positions-security-axis
2 parents b1e99af + 3f89967 commit 9a26de3

32 files changed

Lines changed: 1371 additions & 127 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`.
6+
7+
Until now every terminal of a flow was "completed". A flow could say *do this* but not *refuse this, and say why, for which record* — the only channel that interpolated per-record text was a `screen` node's `description`, and a message-only screen renders Submit and, on submit, resumes to `end`, whose runner toasts `Flow "…" completed` at a user who was just told "this is refused". Maintainer ruling (2026-09-05, option 2′): the refusal is a first-class outcome of the existing terminal node, not a second node type.
8+
9+
The contract, declared here first (the engine and runner halves follow in their own packages):
10+
11+
- **`end` node config**`EndConfigSchema` (`@objectstack/spec/automation`): `outcome?: 'completed' | 'refused'` (default `completed`) and `message?: string`, a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.). `outcome: 'refused'` without a `message` is refused at parse (a refusal without text is the shape this exists to replace); `message` on a completed end is refused too (nothing would ever render it). The shape is strict: an undeclared key is a parse error naming the intended key. Because `end` is structural (no executor, no descriptor), `FlowNodeSchema` applies the contract itself to every `type: 'end'` node it parses and writes the parsed (defaulted) config back; a node with no `config` is left without one. Every other node type's `config` stays the open, executor-owned slot it was.
12+
- **Run row**`ExecutionStatus` gains `refused` (appended last: a terminal state distinct from `failed` — a refusal is a successful evaluation that says no; never resumed) and `ExecutionLogSchema` gains `refusalMessage`, the rendered per-record text, set only on a refused run.
13+
- **Result / wire**`AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'`, and both carry `refusalMessage`; on a refusal `success` is `true` and `successMessage` is absent, so a runner shows the message with Close only — no Submit, no completion toast.
14+
15+
Additive throughout: nothing renamed or retired, so no ADR-0087 conversion-layer entry (disposition: not-required). Flows that never set `config` on an `end` node parse exactly as before.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711)
6+
7+
`translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf.
8+
9+
Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) A published RUNTIME DEFAULT moves: the label resolvers' chain-less fallback, a literal `['en']` before this change, is now `[]`; and one OPTIONAL key is added to a plain TS options interface (`ResolveOptions.defaultLocale` in `packages/spec/src/system/i18n-resolver.ts`). No Zod schema, no authorable metadata key and no stored row changes shape, so the meta conversion tooling has nothing to touch and a registry entry would be false data in the one ledger this gate keeps true. Which TEXT answers a chain-less non-default request moves; nothing starts failing, and no source edit is prescribed: a host that wants `en` consulted declares `fallbackChain: ['en']`, which it always could. -->
8+
9+
**BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`.
10+
11+
## The ruling (#15711, recorded 2026-09-05)
12+
13+
A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**.
14+
15+
- `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked.
16+
- `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label.
17+
- A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required.
18+
- `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset).
19+
20+
Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default.
21+
22+
Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence.

content/docs/automation/flows.mdx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Each node performs a specific action in the flow.
9999
| Type | Description |
100100
| :--- | :--- |
101101
| `start` | Flow entry point |
102-
| `end` | Flow termination |
102+
| `end` | Flow termination `config.outcome` says how: `completed` (default) or `refused` with an interpolated `message` ([below](#end-node-outcome)) |
103103
| `decision` | Conditional branching (if/else) |
104104
| `assignment` | Set variable values |
105105
| `loop` | Structured iteration **container** — runs a body region once per item (ADR-0031) |
@@ -426,6 +426,60 @@ bound to `config.idVariable` so a later step can reference it.
426426
This is how a single flow walks the user through several full object forms in
427427
sequence (e.g. lead → account → opportunity), each step saving its own record.
428428

429+
### Ending a run — `completed` or `refused` [#end-node-outcome]
430+
431+
Every `end` used to mean "completed". The terminal node now declares its
432+
**outcome**, so a flow can say *refuse this, and here is why, for this record*
433+
instead of dressing a refusal up as a message-only `screen` — an input step that
434+
renders **Submit** and, on submit, resumes to `end`, whose runner then toasts
435+
`Flow "…" completed` at a user who was just told the opposite (maintainer
436+
ruling 2026-09-05, option 2′: a first-class outcome on the existing node, not a
437+
second terminal node type).
438+
439+
```typescript
440+
{
441+
id: 'refuse_duplicate',
442+
type: 'end',
443+
label: 'Refused — duplicate',
444+
config: {
445+
outcome: 'refused', // 'completed' (default) | 'refused'
446+
message: 'Refused: {record.name} is a confirmed duplicate of {duplicate.name}',
447+
},
448+
}
449+
```
450+
451+
- `outcome: 'refused'` is a **terminal state, never resumed**, and it is
452+
**distinct from `failed`** — a refusal is a successful evaluation that says
453+
no; nothing threw. The run row records `status: 'refused'` with the rendered
454+
text as `refusalMessage`, and the trigger / resume response carries the same
455+
(`success: true`, `status: 'refused'`, `refusalMessage` — and **no**
456+
`successMessage`, so there is nothing to toast).
457+
- `message` is a `{token}` template interpolated at run time **exactly like a
458+
`screen` node's `description`**, so the text names the record. It is
459+
**required** when `outcome` is `refused` (a refusal without text is the shape
460+
this replaces) and **refused** on a completed end (nothing would ever render
461+
it — the key would be a silent no-op). The config is strict: an undeclared key
462+
is a parse error naming the intended one (`reason``message`, `status`
463+
`outcome`).
464+
- A runner shows `refusalMessage` with **Close only** — no Submit, no
465+
`Flow "…" completed` toast; the invoking action's own `successMessage` stays
466+
suppressed exactly as it is behind a paused run.
467+
468+
Reach the refusing `end` from a `decision` edge like any other branch, and keep
469+
every write behind the branch the refusal never takes. Because `end` is
470+
structural (no executor, no descriptor), the flow parse itself applies the
471+
contract — `outcome: 'refused'` with no `message` is refused at
472+
`nodes[i].config.message`, at registration and by `objectstack validate` alike.
473+
An `end` node with no `config` parses exactly as before.
474+
475+
<Callout type="warn" title="Declared first — the engine and runner halves follow">
476+
This page states the contract (`@objectstack/spec`). The engine half —
477+
`service-automation` stamping `refused` and persisting the rendered message at
478+
the `end` node (#15788) — and the runner half — the console `FlowRunner`
479+
rendering Close-only (objectui#7707) — land separately. Until both do, a
480+
`refused` end parses and registers but the run still ends as `completed`.
481+
</Callout>
482+
429483
## Structured control flow (ADR-0031)
430484

431485
`loop`, `parallel`, and `try_catch` are **structured control-flow constructs**
@@ -917,7 +971,7 @@ Each run's `steps[]` records every executed node — including loop iterations,
917971
parallel branch bodies, and try/catch region steps — which the Studio flow
918972
designer surfaces, nested by iteration / branch / handler, in its **Runs** side
919973
panel. Recent runs are held in an in-memory ring buffer; terminal runs
920-
(completed / failed) are also mirrored to `sys_automation_run` as durable
974+
(completed / failed / refused — the last with its rendered `refusalMessage`) are also mirrored to `sys_automation_run` as durable
921975
history with a bounded step log, so `listRuns` / `getRun` still report a run's
922976
status, steps, and failure reason after a restart or ring-buffer eviction.
923977

content/docs/permissions/system-context.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5083`, `:6509`, `:6757`, `:7188`, `:7381` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5101`, `:6527`, `:6775`, `:7206`, `:7399` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
@@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs.
193193

194194
| Assumption | Reality | Anchor |
195195
|:---|:---|:---|
196-
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942``1944`, #3760), `flow.zod.ts:702` |
196+
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942``1944`, #3760), `flow.zod.ts:743` |
197197
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
198198
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10306``10323` |
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1581` (#3493 / #6640) |

content/docs/protocol/kernel/i18n-standard.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ Fallback to: pt ✗ (not found)
150150
Fallback to: en (system default) ✓
151151
```
152152

153+
The "system default" step is the stack's **declared** fallback
154+
(`i18n.fallbackLocale`, else `defaultLocale`), never a literal `en`. For
155+
metadata labels one more rule applies (#15711): the inline `label:` authored
156+
on the metadata **is** the default locale's text, so a request for
157+
`defaultLocale` stops at the authored label without consulting any other
158+
locale's bundle — a `zh-CN` workspace with a courtesy `en` bundle serves
159+
Chinese to `zh-CN` and English to `en`. See
160+
[Translations](/docs/ui/translations#how-a-locale-is-chosen).
161+
153162
## Translation Bundles
154163

155164
Translations are stored in **JSON files** organized by locale and namespace.

0 commit comments

Comments
 (0)