Skip to content

Commit f73a103

Browse files
committed
Merge origin/main into claude/project-thread-xniw22
Claude-Session: https://claude.ai/code/session_01ADHkmR4MjTzeUv5x6tNkbk Co-authored-by: Claude <noreply@anthropic.com>
2 parents b91e771 + 7f277a8 commit f73a103

98 files changed

Lines changed: 5454 additions & 725 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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an object extension's `fieldPatches` refuses a field the same extension replaces in `fields`, keys option patches by the option-value grammar, refuses `__proto__`, and carries the field's and option's display renames
6+
7+
`ObjectExtensionSchema.fieldPatches` (ADR-0146 D10) is applied by the registry merge, so what it accepts now changes what every read serves. Three things it accepted are refused, each located at its key:
8+
9+
- a field named in both `fields` and `fieldPatches` of one extension (`fieldPatches.industry`): `fields` takes the vendor's field over whole, so the patch landed on the extension's own replacement and the author got neither answer;
10+
- an option-patch key no option `value` can be (`fieldPatches.industry.options.Tech Sector`): the map is now keyed by `SelectOptionSchema.shape.value` itself, so the patch can address only an option that can exist;
11+
- a `__proto__` own key in `fields`, `fieldPatches` or an option-patch map, which zod's record parser dropped while reporting success. Measured at the `/api/v1/meta` save door: the body parsed, the stored row kept the key, and the registered extension did not.
12+
13+
A field patch that spells a key the way the field's own rename table knows (`helpText`, `help`, `hint` or `tooltip` for `inlineHelpText`; `title` or `displayName` for `label`), and an option patch that spells one the option's table knows (`text`, `name` or `title` for `label`), now gets the rename hint instead of a bare unknown-key refusal. The rows are taken by filter from the one table each source shape reads, so a rename onto a whitelisted key reaches the patch the day it lands. The `fieldPatches` description lists its members from `BASE_FIELD_DISPLAY_WHITELIST`.
14+
15+
Clause-②: no (narrowing) — `ObjectExtensionSchema` refuses three inputs it accepted: a field both replaced and patched, an option-patch key outside the option-value grammar, and a `__proto__` own key in its three record slots. Nothing is widened: the renames are hints on keys already refused.
16+
17+
Metadata-protocol: narrowed (an object extension may no longer patch a field it replaces in `fields`, nor key an option patch by anything but a valid option value)
18+
19+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed. A field both replaced and patched has two opposite intents and no rewrite keeps both; an option-patch key outside the value grammar never addressed an option; a __proto__ key never survived the parse. The author decides, and each refusal names the key. -->
20+
21+
**BREAKING** accept-set narrowing on `ObjectExtensionSchema` (`@objectstack/spec`), at every door that parses an object extension: `defineObjectExtension`, `defineStack({ objectExtensions })`, `os validate`, the marketplace install and the `/api/v1/meta` `object_extension` save door.
22+
23+
- **Before.** `{ extend: 'crm_account', fields: { industry: {…} }, fieldPatches: { industry: { label: 'Sector' } } }` parsed, and the patch relabelled the extension's replacement. `fieldPatches: { industry: { options: { 'Tech Sector': { label: 'Tech' } } } }` parsed and could never match an option. A JSON body carrying `"__proto__"` under `fields` or `fieldPatches` parsed with that key silently missing.
24+
- **Now.** Each is refused: `custom` at `fieldPatches.industry`, `invalid_key` at `fieldPatches.industry.options.Tech Sector`, `custom` at `fields.__proto__` / `fieldPatches.__proto__`.
25+
- **Unchanged.** A patch of a field the extension does not replace, keyed by a valid option value, parses as before.
26+
- **Who sees it.** No extension in this repository's examples, skills or docs writes any of the refused forms.
27+
28+
**The remedy.** Choose one answer per field: keep the patch to change only its display (the vendor's type, values and upgrades still apply), or keep the `fields` entry and set the display properties there. Key an option patch by the option's stored `value` (`technology`), not its label. Rename a `__proto__` key.
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+
fix(spec): the owner-stamp build door judges a `packages[]` artifact's top level the way the boot does
6+
7+
`defineStack`, `os validate` and `os build` refuse a collection item whose body `_packageId` names a package other than the one that ships it. For a release artifact (`packages[]`), the top-level item no body declares is the stack's residual, judged against `manifest.id`. Which top-level items are residual is now read the way the boot reads it (`unclaimedTopLevel`, `@objectstack/metadata`), so the build door refuses a residual's stamp exactly when the boot does:
8+
9+
- top-level seed `data`, `datasets` and `jobs` are never judged as residual items: the boot applies, consumes and schedules them straight off the stack, files none of them as residual items and judges none of their stamps. A package body's items of these collections are still judged at both doors;
10+
- a top-level view container is claimed by the object it binds to (its own `object`, else its list's or form's `data.object`), however the binding is spelled, and a body's container also claims every view it expands into. A top-level copy of a body's container written the other way round is no longer judged against the artifact's id, and a top-level container binding another object through its list alone is no longer passed over as a copy;
11+
- a top-level item with no registry slot (no `name`, and for a view no binding) is not judged, since no door registers it.
12+
13+
Clause-②: yes (widening) — the build door stops refusing three shapes the boot accepts: a foreign-stamped top-level seed dataset, `datasets` entry or job of a `packages[]` artifact, and a top-level copy of a body's view container spelled with another binding. The one shape it now refuses that it accepted before, a top-level list-bound container no body declares wearing a foreign stamp while a body carries any list-bound container, is one the boot already refused and the pending build-door refusal for collection item stamps already declares refused ("a top-level item no body declares judged against `manifest.id`").
14+
15+
Metadata-protocol: added (a foreign `_packageId` on a top-level `data`, `datasets` or `jobs` item of a `packages[]` artifact, and on a top-level copy of a body's view container, is accepted at the build doors as it is at boot)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
refactor(i18n)!: the Packaged automation flows keys and Home's Manage Objects keys leave every pack
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->
11+
12+
Setup's Packaged automation flows page and Home's Manage Objects entry are retired: packaged flows are switched on and off, and copied, in Studio's Automations pillar, and objects are managed in Studio. The strings those screens read stayed in `@object-ui/i18n` with no reader. They now leave all ten packs (ar, de, en, es, fr, ja, ko, pt, ru, zh).
13+
14+
**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses 28 keys. A `t()` call that names one still compiles, since its key is a `string`, and reads the key back. Nothing replaces them; the fix is to delete the reference.
15+
16+
| Removed key | Replacement |
17+
|:---|:---|
18+
| `packagedAutomation.title`, `.subtitle`, `.flowsHeading`, `.colFlow`, `.colActions`, `.toggleLabel`, `.notRunning`, `.notRunningDeploymentPolicy`, `.reasonDetails`, `.emptyTitle`, `.emptyBody`, `.loadFailed` | none: the flows list is retired; a packaged flow's switch lives in Studio |
19+
| `packagedAutomation.clone`, `.cloneTitle`, `.cloneBody`, `.cloneName`, `.cloneLabel`, `.cloneCopyLabel`, `.cloneConfirm`, `.cloneCreated`, `.cloneSwitchSourceOff`, `.cloneSourceOff`, `.cloneOpen`, `.cloneFailed`, `.cloneFailedHttp` | none: the clone dialog is retired; Studio's New similar copies a flow |
20+
| `home.quickActions.manageObjects`, `home.quickActions.manageObjectsDesc`, `layout.systemNav.objectManager` | none: Home's Manage Objects entry is retired |
21+
22+
The keys Setup's Packaged actions page reads (`packagedAutomation.actionsHeading`, `.refresh`, `.colActivation`, `.on`, `.off`, `.cancel`, `.toggleFailed`, `.toggleFailedHttp` and the `actions*` / `switched*` / `actionOff*` family) are unchanged.

‎.changeset/28977-sql-dry-run-refuses-date-bucket-over-non-date.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Metadata-protocol: none
1212
**BREAKING** accept-set narrowing on the `/analytics/sql` dry run (`@objectstack/service-analytics`). `/analytics/query` is unchanged.
1313

1414
- **Before.** Measured on real SQLite on `main` `ab085a04`. `/analytics/query` refused a month bucket over a text, select or boolean column, a year bucket over a number and a day bucket over a `time` column with the engine's `INVALID_FIELD` / 400 at `groupBy.N.dateGranularity`. The dry run rendered `strftime('%Y-%m', region)` and its kin, on the direct and the cross-object path, and for a cube dimension's one declared granularity asked raw. At `America/New_York` it answered `NOT_IMPLEMENTED` / 501.
15-
- **Now.** The dry run judges the keys `execute()` hands the engine (its cross-object path's when the selection routes there) with the reading the draft preview already used (#28852), now `dataset-date-bucket-door.ts`. It runs after the dry run's `dateRange` read and its column-asked-twice refusal, as `query()` refuses, and gives the engine's refusal: the same message, `object`, `param`, `path`, `field` and `fields`. A field with no declared type, or a `formula`, is not judged, as in the engine.
15+
- **Now.** The dry run judges the keys `execute()` hands the engine (its cross-object path's when the selection routes there) with the reading the draft preview already used (#28852), now `dataset-date-bucket-door.ts`. It runs after the dry run's `dateRange` read and its column-asked-twice refusal, as `query()` refuses, and gives the engine's refusal: the same message, `object`, `param`, `path`, `field` and `fields`. A field with no declared type is not judged, as in the engine; a `formula` is refused with the engine's virtual-field refusal on every face (#29167).
1616
- **Unchanged.** A date or datetime bucket renders byte for byte, the statement the query echoes.
1717
- **Who sees it.** A caller of the dry run for a selection the query refuses: the query's refusal, instead of SQL for a statement that never runs.
1818

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a start input a flow's definition reads as a record id is loaded in the caller's own scope from the object the flow reads it from, and must be an id, before the run exists (#29050)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**The rule.** Where a flow's own definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from THAT object before the run exists, and refuses a value the caller cannot read there with the action door's own `404 RECORD_NOT_FOUND` (ADR-0112 envelope). A record the caller cannot read and an id that names nothing get the same answer. The input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads it as a list; any other value is refused `400 VALIDATION_FAILED` naming the input. Nothing runs on a refusal: no run record, no node, no side effect. This holds whatever object the start names, or none, and whatever the flow runs as.
11+
12+
**Where a definition reads an input as a record id.** Across every region of the flow (loop bodies, parallel branches, try and catch regions):
13+
14+
- a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input (one id), or whose `filter.id.$in` is (a list of ids);
15+
- a `screen` node with a literal `objectName` whose `recordId` is the input;
16+
- a `subflow` node with a literal `flowName` that hands the input to a callee input the callee reads so, and a `map` node that does the same with the input itself or with each item of it (a `map` iterating the input reads it as a list of ids).
17+
18+
"Is the input" means a CEL value whose whole source is the input's name (`caseId` or `vars.caseId`).
19+
20+
**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; and an action of `type: 'flow'` (the REST action route and the MCP `run_action` tool), for each key of the caller's own `params` and for the routed record id under the row slots, so the routed record is judged against the object the flow reads as well as the action's own object.
21+
22+
**The REST action route's routed record is one id.** A body `recordId` that is not one id (a string or a number) is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared param contract, the record load and dispatch, for every action type. A path segment is always one id; an absent, `null` or empty `recordId` is the record-less call, as before.
23+
24+
**What stays as it was.**
25+
26+
- A start whose caller can read the record in the object the flow reads it from.
27+
- An input the definition does not read as a record id, including a value the flow computes from an input or derives elsewhere.
28+
- A name a `loop` or `map` node binds as its item or index variable, which no longer holds the start's value once that node runs.
29+
- The system principal (an in-process caller, such as a job).
30+
- At the action door, the subject record's own fields, and the row slots of an action whose `recordIdField` names a field other than `id`.
31+
32+
**If a start now answers 404 or 400.** Hand the input the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids).
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/service-sms': patch
4+
'@objectstack/service-automation': patch
5+
'@objectstack/core': patch
6+
---
7+
8+
fix(server): every budget counter holds its limit under concurrent requests, counts in a registered `cache` service, and keeps its in-process fallback bounded
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
The rule `@objectstack/plugin-auth`'s counters already follow now holds for the server's other budget counters (ADR-0069 D2).
14+
15+
- **`@objectstack/runtime`: a token bucket holds its budget under concurrency inside one process.** Updates of one bucket key are serialised in the process, so the inbound limiter (`server.security.rateLimit`) and a declared endpoint `rateLimit` admit no more requests than their budget for one caller however many arrive at once. Other callers keep counting concurrently.
16+
- **The counters count in the registered `cache`.** When a `cache` service is registered, as a value or as a factory, the runtime's inbound and endpoint buckets, the daily SMS send quota (`@objectstack/service-sms`) and the `notify` node's per-address email-field limit (`@objectstack/service-automation`) count in it. The kernel's own in-memory stand-in is not a shared store: with no cache plugin they keep their bounded per-process fallback and say so once.
17+
- **`@objectstack/core`: the in-memory `cache` fallback stays bounded.** Entries whose time-to-live has passed leave it without having to be read again, so it holds at most about twice its live entries. Nothing live is evicted; the `ICacheService` contract is unchanged.
18+
- **Across nodes, unchanged.** `ICacheService` has no atomic increment, so two nodes that share one cache can still over-admit slightly between them, as documented.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@object-ui/types': patch
4+
---
5+
6+
docs(spec): the record-page `timeline` total's published description reads it as an upper bound of the drawn 动态 entries
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
`RecordPageTimelineCountSchema.total`'s `.describe()`, which ships in the published JSON schema and the API reference, and the schema's docblock now say what the `RecordPageTimelineRequestSchema` docblock says: the total counts the rows of the sources the request named, as that schema's table defines them and as the caller may read them. That makes it an upper bound of the entries the 动态 panel draws, not their number. The panel draws fewer where its `showCompleted` / `types` configuration filters entries out, and where a ledger row has no displayable diff. Description text only: no key, type, bound, default or export changes, and no behaviour changes.
12+
13+
The `RecordPageResponse.timeline` docblock in `@object-ui/types`, the UI's mirror of the response, says the same: the total counts the sources' rows, so it is an upper bound of the drawn entries, and the panel draws fewer in the same two cases. Comment only: no type, member or export changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Studio app navigation, object, object extension and flow editors and the metadata item editor no longer edit the published version over a draft the author is not allowed to read
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** A pending draft can carry an audience gate (`requiredPermissions`) its author does not hold, for example one written over the API or MCP, and the draft read answers `403 PERMISSION_DENIED` for it by design. `readDraftForSave` hands that refusal back as `refused`, but the Interfaces pillar's app navigation, the Data pillar's object and its extension at a base's paired package, the Automations pillar's flow and the metadata item editor still read it as "no pending draft": they opened the published body in its place, and their next save sent that body as the draft, dropping the gate and every other unpublished change. The metadata item editor did the same after a publish that left such a draft behind.
11+
- **Now.** Each of them installs no buffer for such an item: nothing is shown to edit and no save is sent. The refusal strip says the item has an unpublished draft this author may not read and that editing the published version would overwrite it; the server's own sentence is behind Details. The metadata item editor withholds every write it offers for that item and shows the same strip in place of the editor. The strip is now its own module, `StudioRefusalStrip`, so the metadata item editor draws the one Studio uses.
12+
- **Unchanged.** An item with no pending draft opens the published body and its first save is still pinned to "no draft row"; a readable draft opens as served. Any other failed draft read is handled as before.

0 commit comments

Comments
 (0)