Skip to content

Commit be6800e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14325-silent-gate-declarations-s1
2 parents a5d988e + a3d5724 commit be6800e

30 files changed

Lines changed: 1697 additions & 96 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(plugin-auth): auth mail follows the caller's `Accept-Language`, deployment default second (#14319)
6+
7+
Request-triggered auth email — signup verification, password reset, magic link,
8+
and the change-email notice — now picks its `sys_email_template` row from the
9+
requesting caller's `Accept-Language`, falling back to the deployment default
10+
(`localization.locale`, then `i18n.defaultLocale`) and finally to
11+
`EmailService`'s documented `en-US`.
12+
13+
The motivating case is the one no deployment default can answer: at cloud
14+
self-service signup there is no workspace yet, so nothing on the server
15+
represents that person's language — a Chinese browser reached a Chinese signup
16+
screen and received an English verification email.
17+
18+
The header is parsed by the platform's existing `preferredLocaleFromHeader`,
19+
the same function REST uses for metadata translation and the runtime dispatcher
20+
uses for `ExecutionContext.requestLocale`, so the mail cannot disagree with the
21+
screen that triggered it. A requested locale takes effect only when it names one
22+
of `AUTH_EMAIL_TEMPLATE_LOCALES` (`en-US`, `zh-CN`, `ja-JP`, `es-ES`); anything
23+
else falls through rather than naming a row that does not exist.
24+
25+
Two deliberate exclusions. **Invitations keep the deployment default**:
26+
better-auth hands that callback a request too, but it is the *inviter's*, and
27+
stamping their browser language onto the invitee's mail would reproduce this
28+
same defect one seat over. **Per-user language stays deferred**`sys_user`
29+
grows no locale column here.
30+
31+
This ships as `minor` because it changes which template row an existing
32+
deployment sends: a workspace whose users' browsers ask for a different language
33+
than the workspace declares will now send in the browser's language.
34+
35+
**Ruling:** maintainer, 2026-09-02, superseding the 2026-08-13 ruling that had
36+
rejected `Accept-Language` outright. Both are recorded, with the older one
37+
marked superseded, on `AuthManager.setDefaultEmailLocale`.
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): name the terminally-failed-but-repairable run on `AutomationResult.status``'stranded'` (#14384, contract half of #13937)
6+
7+
`AutomationResult.status` (`contracts/automation-service.ts`) gains a fourth
8+
member beside `'completed' | 'paused' | 'failed'`: **`'stranded'`** — the run
9+
whose resume CONSUMED its suspension and then had a downstream node throw, so
10+
the run is recorded as failed and can be re-armed only by an explicit operator
11+
verb (#13909's condition; the #13937 shape-4 ruling, maintainer 2026-09-01
12+
「命名同批定」). The wire mirror `TriggerFlowResponseSchema.data.status`
13+
(`api/automation-api.zod.ts`) carries the same four, and a pin test binds the
14+
two at the type level and the value level.
15+
16+
Additive: no existing literal changes meaning, `'failed'` still says "the run
17+
ran and was rejected", and no engine, route or client behaviour moves in this
18+
change — the engine begins stamping `'stranded'` when #13937's services half
19+
(the operator re-arm verb) lands. A consumer that switches exhaustively over
20+
`status` needs a `'stranded'` arm; the measured count of such switches in this
21+
repo is zero. plugin-approvals' report-only `StrandedRunState`
22+
(`'missing' | 'failed'`) is deliberately not promoted (same ruling).

.changeset/chilled-eels-shave.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
A `state_machine` rule's refusal now carries `constraint` and `value` alongside an **author-written** `message`, not only alongside the built-in one (#14311).
6+
7+
`checkStateMachine` emitted the full field-error envelope — `field`, `code`, `message`, `label`, `constraint`, `value` — when the rule left its message empty, but dropped `constraint` and `value` the moment the rule declared one. Since `ValidationRuleSchema` **requires** `message` on every rule, the machine-readable half was in practice reachable only by declaring `message: ''`: every normally-authored state machine refused writes with no way for a client to learn *which* states are legal.
8+
9+
A create form that wants to offer exactly the declared `initialStates`, or a detail page that wants to grey out illegal transitions, had to parse the author's prose or keep a second copy of the state machine.
10+
11+
Now both paths emit the same envelope:
12+
13+
- insert — `constraint: { allowed: 'planned' }`, `value: 'active'`, `code: 'invalid_initial_state'`
14+
- update — `constraint: { from: 'draft', to: 'approved' }`, `code: 'invalid_transition'`
15+
16+
The author still owns the wording; only the facts beside it are restored. Nothing about which writes are refused changes, and `constraint` / `value` are already declared on `FieldValidationError` (mirroring `FieldErrorSchema`), so no consumer contract widens — REST ships the same `400 VALIDATION_FAILED` envelope with the fields it always declared.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`lintLivenessProperties` no longer crashes on the `live-elsewhere` verdict — and never tells an author to remove a key a sibling repo enforces
6+
7+
`describe()` in `lint-liveness-properties.ts` knew three verdicts
8+
(`experimental`, `planned`, `dead`) and threw, loudly and by design, on any
9+
other. #13483 then shipped the ledger's fifth status — `live-elsewhere`: dead
10+
HERE by measurement, genuinely enforced in a sibling repo — and migrated
11+
`manifest.runtime` onto it (its enforcer is the cloud marketplace publish
12+
gate). Nothing taught `describe()` about it, so the day any `live-elsewhere`
13+
row opts into `authorWarn: true`, `os lint` would raise that
14+
shipped-ledger-integrity error instead of the advisory warning the author
15+
should get. No shipped row carries `authorWarn` today, so this was a fuse
16+
rather than a fire.
17+
18+
`describe()` now has a fourth branch. `live-elsewhere` gets its own rule id —
19+
`liveness-live-elsewhere-property`, exported as `LIVENESS_LIVE_ELSEWHERE_PROPERTY`
20+
beside `LIVENESS_DEAD_PROPERTY` / `LIVENESS_EXPERIMENTAL_PROPERTY` /
21+
`LIVENESS_PLANNED_PROPERTY` and advisory-only like them — plus its own message
22+
(`is enforced in a sibling repo, not here`) and its own default hint, which keeps
23+
the property and points at the ledger row's `evidence` for the enforcer. It
24+
deliberately does **not** reuse the `dead` branch: that is the #11384 lesson,
25+
which is that verdicts imply OPPOSITE author actions, and "Remove it" is the
26+
single most damaging sentence available about a key whose enforcement is real
27+
and remote — deleting it tears out a live gate's input. The sentinel throw
28+
stays for genuinely unknown statuses, with its enumeration of the known ones
29+
updated.
30+
31+
The suite gains a coverage pin derived from the shipped ledgers rather than from
32+
a hand-written list: every distinct `status` those ledgers actually carry must be
33+
answered by `describe()` with a rule id of its own, or (for `live`, which reaches
34+
`describe()` only through a ledger-authoring mistake) must still fail loud. A
35+
sixth status now fails that pin by name instead of waiting for an author to trip
36+
the sentinel.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
chore(spec): govern the four `RestServerConfig` sub-objects in the liveness ledger (#14369)
6+
7+
The `liveness/` ledgers ship inside this package's npm tarball (they are named in
8+
`files`), so this is a published-data change even though no runtime behaviour
9+
moves, no schema key changes spelling, and `packages/spec/src/api/rest-server.zod.ts`
10+
is not edited at all.
11+
12+
Four new ledger files — `crud_endpoints.json`, `metadata_endpoints.json`,
13+
`batch_endpoints.json`, `route_generation.json` — classify all 32 authorable
14+
properties of `CrudEndpointsConfigSchema`, `MetadataEndpointsConfigSchema`,
15+
`BatchEndpointsConfigSchema` and `RouteGenerationConfigSchema`, the four
16+
`RestServerConfig` sub-objects a host writes when it constructs the REST server.
17+
They are enrolled through the gate's `SPEC_ONLY_SCHEMAS` override, the route
18+
`query` / `qa` / `manifest` already take: server configuration is neither a
19+
metadata item nor a request body nor a manifest, so no registry has ever held it
20+
and no ratchet rooted in one could ask who reads it.
21+
22+
Seventeen properties are `live` with a symbol-anchored consumer and a producer
23+
pointer at the normalizer that threads the authored value into `this.config`.
24+
Fifteen are `dead` — the ten keys the census filed with this card measured, with
25+
the two container keys (`crud.patterns`, `routes.overrides`) expanded into a row
26+
per member. `routes` is dead entire: `excludeObjects: ['sys_log']` excludes
27+
nothing and `nameTransform: 'plural'` still mounts every route under the raw
28+
object name. `metadata.endpoints.schema` and `batch.operations.upsertMany` are
29+
switches for routes that were never built — no path ending in `/schema` is
30+
mounted anywhere in `packages/rest/src`, and the protocol has no `upsertManyData`
31+
counterpart to its three sibling batch methods.
32+
33+
What this records, and what it deliberately does not. #11984 made
34+
`RestServer.normalizeConfig` PARSE and CONSUME these four sub-objects instead of
35+
casting them, so an out-of-enum or out-of-range value is now refused at
36+
construction. That settles accept/reject and nothing else: executing a declared
37+
contract does not give a key a consumer. No key is removed, enforced, deprecated
38+
or re-described here. The enforce-or-remove call per dead key (ADR-0049) is a
39+
follow-up on the human floor — the enforce route is a feature per key, and
40+
`routes.excludeObjects` is advertised in `RestServerConfigSchema`'s own
41+
`@example`, which makes its removal a capability retirement rather than a cleanup.
42+
43+
Rooted on the four sub-schemas rather than on `RestServerConfigSchema` itself,
44+
which is measurement rather than taste: the ledger walk drills exactly ONE level,
45+
so with the whole config as the root the sub-objects would BE the drilled level
46+
and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row
47+
of their own — their container's blanket `live` (three of four members gate a real
48+
route mount) silently covering a dead key, which is the #4956 shape in the file
49+
written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is not
50+
enrolled: its consumption seam is still validate-only and is the subject of its
51+
own card, so a census of it would record a half that is about to move.
52+
53+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-described: this change adds ledger rows and a gate enrolment, and every key it classifies keeps the exact spelling, type, default and describe() it had. There is no source for `objectstack migrate meta` to rewrite, because no author's config becomes invalid or becomes valid as a result. -->

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1345` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1353` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |

content/docs/references/api/automation-api.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ const result = AutomationApiErrorCode.parse(data);
607607
| **error** | `string` | optional | Error message if execution failed |
608608
| **durationMs** | `number` | optional | Execution duration in milliseconds |
609609
| **code** | `Enum<'PERMISSION_DENIED' \| 'INVALID_SIGNAL' \| 'RUN_NOT_FOUND' \| 'STORE_UNAVAILABLE' \| …>` | optional | Machine-readable failure classification, set alongside `error` when the caller must distinguish WHY it failed. A closed union - the members and their transport mappings are documented on the contract (`AutomationResult.code`, contracts/automation-service.ts). |
610-
| **status** | `Enum<'completed' \| 'paused' \| 'failed'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed` means the run reached a terminal state. |
610+
| **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded` means the run reached a terminal state. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. |
611611
| **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it |
612612
| **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. |
613613
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |

examples/app-showcase/src/data/objects/project.object.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,17 @@ export const Project = ObjectSchema.create({
135135
// `insert` in `events` is what makes the initialStates check run on create.
136136
events: ['insert', 'update'] as const,
137137
initialStates: ['planned'],
138-
message: 'Invalid project status transition.',
138+
// ONE authored sentence answers BOTH refusals this rule can raise —
139+
// `invalid_initial_state` on insert and `invalid_transition` on update —
140+
// because `authoredRuleMessage` resolves one key per RULE, not per code.
141+
// The old wording ("Invalid project status transition.") described only
142+
// the update half, so a create rejected for being born `active` was told
143+
// about a "transition" it had not attempted. It is translated at
144+
// `objects.showcase_project._validations.project_status_flow.message`
145+
// (#14253) — an authored message is emitted verbatim unless the bundle
146+
// carries that key, which is why this one used to be the single English
147+
// sentence on an otherwise zh-CN form.
148+
message: 'Projects start as Planned, and then move only along the declared status flow.',
139149
transitions: {
140150
planned: ['active', 'cancelled'],
141151
active: ['on_hold', 'completed', 'cancelled'],

examples/app-showcase/src/system/translations/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,33 @@ export const ShowcaseTranslationBundle = {
3434
start_date: { label: 'Start Date' },
3535
end_date: { label: 'End Date' },
3636
},
37+
// An author-written `validations[].message` is emitted VERBATIM unless
38+
// the bundle carries it here (#14253) — the built-in field catalog's
39+
// own sentences have shipped zh-CN since #3957, so a rule that declares
40+
// its own message is the one way a refusal escapes the caller's
41+
// language. `project_status_flow` is the showcase's state machine and
42+
// the only refusal a visitor reliably triggers (the New Project wizard
43+
// used to offer four statuses the machine will not accept on create),
44+
// so it read as the single English sentence on a zh-CN form.
45+
// All FOUR of the object's rules, not just the state machines: the New
46+
// Project wizard can trip `end_after_start` and `spent_within_budget`
47+
// from its budget/schedule step, so translating only the status rule
48+
// would move the single English sentence one step later rather than
49+
// remove it.
50+
_validations: {
51+
project_status_flow: {
52+
message: 'Projects start as Planned, and then move only along the declared status flow.',
53+
},
54+
project_health_progression: {
55+
message: 'Health changed by more than one step — confirm this is intentional.',
56+
},
57+
end_after_start: {
58+
message: 'Target End Date must be on or after the Start Date.',
59+
},
60+
spent_within_budget: {
61+
message: 'Spend exceeds 120% of budget — escalate before continuing.',
62+
},
63+
},
3764
},
3865
showcase_task: {
3966
label: 'Task',
@@ -270,6 +297,16 @@ export const ShowcaseTranslationBundle = {
270297
start_date: { label: '开始日期' },
271298
end_date: { label: '结束日期' },
272299
},
300+
// The zh-CN mirror of the `en` `_validations` block — see the note
301+
// there. Without these two keys the write path's own refusals arrive in
302+
// Chinese (built-in catalog, #3957) while these author-written ones
303+
// arrive in English, inside one error envelope.
304+
_validations: {
305+
project_status_flow: { message: '项目的初始状态为“计划中”,此后只能按既定的状态流转变更。' },
306+
project_health_progression: { message: '健康度一次变更超过一级,请确认这是有意为之。' },
307+
end_after_start: { message: '结束日期不能早于开始日期。' },
308+
spent_within_budget: { message: '已花费超过预算的 120%,请先上报后再继续。' },
309+
},
273310
// `default` — the container's DEFAULT list. `defineView({ list })`
274311
// declares it without a `name`, and the composer therefore registers it
275312
// as `<object>.default`; `_views` keys are that bare runtime key

0 commit comments

Comments
 (0)