Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/32656-user-text-request-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/spec': minor
'@objectstack/core': patch
'@objectstack/metadata-protocol': patch
'@objectstack/rest': patch
'@objectstack/runtime': patch
---

feat(metadata-protocol): a metadata write's advisories and refusals come back in the caller's language

Clause-②: yes (widening) — `SaveMetaItemRequestSchema` and `PublishMetaItemRequestSchema` gain an optional, server-stated `userTextLocales` member (at most two locale codes; the doors build these requests member by member, so a wire body never sets it, like `client`), and `ExecutionContextSchema` gains an optional `workspaceLocale`. The batch publish body (`PublishPackageDraftsRequestSchema`) is unchanged; its door states the locales to the protocol call directly. Nothing that was accepted before is refused now; a request that states no locales composes in `en`, and a finding whose rule has no user sentence carries no `userMessage`, exactly as before.
Metadata-protocol: added
Skills-docs: none — `skills/**` and `content/docs/**` teach what a metadata author writes; `userTextLocales` and `workspaceLocale` are stated by the server from the caller's request and workspace, never authored or sent by a client, so no taught sentence moves. The generated reference pages (`content/docs/references/**`) are regenerated.

Ruling C on #32440: the producer of a finding writes its user-facing sentence in the request's language, else the workspace's default language, else English, and every client shows it as received. The authoring rules already carry a sentence in twelve languages for most findings (#32790); this change composes it on the server:

- Every `/meta` save, single-item publish and package publish now fills `userMessage` on each advisory, and on each issue of a 422 refusal, for a rule that has a user sentence. `rule`, `message`, `hint`, `path` and `where` are unchanged.
- The door states the two languages it resolved: the request's own (its `Accept-Language` header or `locale` query) and the workspace default. The execution context used to fold them into one `locale`, so a request in a language the platform has no text in fell straight to English; it now carries the workspace default on its own as `workspaceLocale`, and the core helper `userTextLocalesOf(ctx)` reads the pair in order.
- The REST server and the runtime dispatcher both state them, for saves, publishes and package publishes.

Not in this change: the refusal sentences written outside the authoring rules (the packaged permission-set lock, the permission middleware's denials) move onto the same ladder in a follow-up PR on #32656.
2 changes: 2 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2538,6 +2538,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
| **message** | `string` | optional | Optional human-readable note recorded with the publish history event. |
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. |
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
| **userTextLocales** | `string[]` | optional | The locales the user-facing sentences of this write's findings (`advisories[].userMessage`, and `issues[].userMessage` on its 422) are composed in, in ladder order: the locale the request names, then the workspace default. Each is read against the platform locale table, whole and then one subtag shorter at a time; a value that names no table language is passed over, and `en` is the last rung. Stated by the door that holds the request (the REST door and the dispatcher state `[ExecutionContext.locale, ExecutionContext.workspaceLocale]`), never read from a wire body. ABSENT ⇒ the sentences are composed in `en`. It selects a language only: what is judged, refused or saved does not depend on it. |


---
Expand Down Expand Up @@ -2908,6 +2909,7 @@ Answer to a device registration: the device id the client keeps
| **writeFace** | `Enum<'package-duplicate' \| 'meta-envelope' \| 'meta-dispatch' \| 'external-import'>` | optional | Which write door a refusal is being rendered FOR — stated by the SERVER, never by a remote caller: every door builds this request field by field and never spreads a request body into it, so there is no path for a client to smuggle a face in, and a face arriving in a wire body is simply never read. Two refusals branch on it, for different questions: the 409 destructive-change remedy names the acknowledgement mechanism that actually exists on the refusing door (`?force=true` on the REST doors; the dispatcher, the duplicate-package door and the external-table import have none), and the 422 invalid-metadata message adapts to whether a structured `issues[]` channel reaches the consumer beside it. Absent = the conservative default wording. |
| **surface** | `Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>` | optional | The authoring surface this save is made through (see `MetadataAuthoringSurfaceSchema`). A draft save (`mode: 'draft'`) stamps it as `by` on every key path it changes, the entries the strict draft read serves as `draftChanges`; an active save records no per-key provenance. ABSENT stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` and refuses every other value `400 VALIDATION_ERROR`, `copilot` and `mcp` included: `copilot` is stated only by an in-process AI tool calling the save and `mcp` only by the MCP build door after it verified the client's token, never by a remote caller, so an HTTP request cannot mark its own write as the copilot's or an MCP agent's. A value outside the set in-process is refused `400 INVALID_REQUEST`, before anything is written. |
| **client** | `{ id: string; name?: string }` | optional | The OAuth client this save is made by (see `MetadataAuthoringClientSchema`), stated only by the MCP build door, in-process, from the token it verified. Only with `surface: 'mcp'`: a `client` beside any other surface, or a malformed one, is refused `400 INVALID_REQUEST` before anything is written. A draft save stamps it as `client` beside `by: 'mcp'` on every key path it changes; an active save records it nowhere. No HTTP door reads it: the REST door and the dispatcher build this request member by member, so a `client` in a wire body or query is never read. |
| **userTextLocales** | `string[]` | optional | The locales the user-facing sentences of this write's findings (`advisories[].userMessage`, and `issues[].userMessage` on its 422) are composed in, in ladder order: the locale the request names, then the workspace default. Each is read against the platform locale table, whole and then one subtag shorter at a time; a value that names no table language is passed over, and `en` is the last rung. Stated by the door that holds the request (the REST door and the dispatcher state `[ExecutionContext.locale, ExecutionContext.workspaceLocale]`), never read from a wire body. ABSENT ⇒ the sentences are composed in `en`. It selects a language only: what is judged, refused or saved does not depend on it. |

### Nested Shape: `SaveMetaItemRequest.client`

Expand Down
12 changes: 12 additions & 0 deletions content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const result = BaseEngineOptionsSchema.parse(data);
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -107,6 +108,7 @@ Options for DataEngine.aggregate operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -162,6 +164,7 @@ Options for DataEngine.count operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -218,6 +221,7 @@ Options for DataEngine.delete operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -296,6 +300,7 @@ Options for DataEngine.insert operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -357,6 +362,7 @@ Query options for IDataEngine.find() operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -444,6 +450,7 @@ Options for DataEngine.update operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -520,6 +527,7 @@ QueryAST-aligned options for DataEngine.aggregate operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -608,6 +616,7 @@ QueryAST-aligned options for DataEngine.count operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -678,6 +687,7 @@ QueryAST-aligned options for DataEngine.delete operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -743,6 +753,7 @@ QueryAST-aligned query options for IDataEngine.find() operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down Expand Up @@ -836,6 +847,7 @@ QueryAST-aligned options for DataEngine.update operations
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const result = AuthGateCodeSchema.parse(data);
| **tenantId** | `string` | optional | |
| **timezone** | `string` | optional | |
| **locale** | `string` | optional | |
| **workspaceLocale** | `string` | optional | |
| **currency** | `string` | optional | |
| **positions** | `string[]` | optional (default: `[]`) | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
Expand Down
50 changes: 48 additions & 2 deletions packages/core/src/security/assemble-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
assembleExecutionContext,
assembleExecutionContextOrGuest,
ENTRY_EXECUTION_CONTEXT_FIELDS,
userTextLocalesOf,
type EntryLocalization,
type OAuthTokenProvenance,
} from './assemble-execution-context.js';
Expand Down Expand Up @@ -252,11 +253,18 @@ describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-#
// principal whose token names a client — absent everywhere else,
// and carrying that client's id. The parity pin below is otherwise
// unweakened: every other key still compares byte-for-byte.
const { performedBy, ...restOfNow } = observable(now);
//
// [#32656] The second argued delta, subtracted and pinned the same
// way: the envelope now also carries the workspace default locale
// on its own (ruling C on #32440 — the user-text ladder needs it
// UNFOLDED from `locale`). Present exactly when an authenticated
// principal's localization names one, and equal to that value.
const { performedBy, workspaceLocale, ...restOfNow } = observable(now);
expect(restOfNow).toEqual(observable(before));
expect(performedBy).toEqual(
authz.userId && oauth?.clientId ? { clientId: oauth.clientId } : undefined,
);
expect(workspaceLocale).toEqual(authz.userId ? localization?.locale : undefined);
});
}
}
Expand Down Expand Up @@ -291,7 +299,11 @@ describe('#6216 — REST face: byte-for-byte parity with the pre-#6216 assembly'
return;
}
expect(now).toBeDefined();
expect(observable(now)).toEqual(observable(before));
// [#32656] The one argued delta, subtracted and pinned positively
// (see the dispatcher face above): the workspace default locale.
const { workspaceLocale, ...restOfNow } = observable(now!);
expect(restOfNow).toEqual(observable(before));
expect(workspaceLocale).toEqual(localization?.locale);
});
}
}
Expand Down Expand Up @@ -724,3 +736,37 @@ describe('[ADR-0151 D3] an AI service account is an agent principal of its own:
expect(person.onBehalfOf).toBeUndefined();
});
});

describe('[#32656] the user-text ladder: the workspace default rides the envelope UNFOLDED', () => {
const assembly = (localization: EntryLocalization | undefined, requestLocale: string | undefined, authz = HUMAN_MINIMAL) => ({
authz, oauth: undefined, localization, requestLocale, accessToken: undefined, authGate: undefined,
});

it('a request locale wins `locale`, while `workspaceLocale` still names the workspace default', () => {
for (const ctx of [
assembleExecutionContext(assembly({ locale: 'zh-CN' }, 'de')),
assembleExecutionContextOrGuest(assembly({ locale: 'zh-CN' }, 'de')),
]) {
expect(ctx?.locale).toBe('de');
expect(ctx?.workspaceLocale).toBe('zh-CN');
expect(ExecutionContextSchema.parse(ctx).workspaceLocale).toBe('zh-CN');
expect(userTextLocalesOf(ctx)).toEqual(['de', 'zh-CN']);
}
});

it('no workspace default ⇒ no workspaceLocale key; a guest envelope never carries one', () => {
expect('workspaceLocale' in assembleExecutionContext(assembly(undefined, 'de'))!).toBe(false);
const guest = assembleExecutionContextOrGuest(assembly({ locale: 'zh-CN' }, 'de', ANONYMOUS));
expect(guest?.principalKind).toBe('guest');
expect('workspaceLocale' in guest!).toBe(false);
});

it('userTextLocalesOf reads the ladder in order and drops what is absent or empty', () => {
expect(userTextLocalesOf(undefined)).toEqual([]);
expect(userTextLocalesOf(null)).toEqual([]);
expect(userTextLocalesOf({})).toEqual([]);
expect(userTextLocalesOf({ locale: 'fr' })).toEqual(['fr']);
expect(userTextLocalesOf({ workspaceLocale: 'ja' })).toEqual(['ja']);
expect(userTextLocalesOf({ locale: '', workspaceLocale: 'ja' })).toEqual(['ja']);
});
});
Loading
Loading