Skip to content

Commit 981cc85

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-2isfgl
2 parents efd41da + 45e9b2f commit 981cc85

54 files changed

Lines changed: 3421 additions & 105 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(service-automation)!: a flow whose region body calls a child that can suspend is registered but not armed, and the region refusal cancels the child run it leaves behind (#28897)
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
**BREAKING** for a record-triggered flow with a `subflow` or `map` node inside a `loop` body, a `parallel` branch or a `try_catch` slot, whose child flow can suspend the run.
12+
13+
- **Before.** A region body cannot carry a durable pause (ruling D on #15646). The flow parse refuses the pausing node types inside a region, but it cannot see into a `subflow` / `map` child. Such a flow armed, and the first time its child suspended, the run failed at the region boundary with the named refusal (`durable pause inside a structured region …`). The child run that node had started was left parked in the suspended-run store: a later resume ran the rest of the child for a caller that had already failed, and then failed to resume that caller.
14+
- **Now, the first door.** The arming gate in `activateFlowTrigger` declines such a flow, after its disabled-subflow and save-notice halves. "Can suspend" is the save-notice half's reading: the child holds a node of an always-suspending type reachable from its start, or calls, from its top-level graph, a child that can suspend. The flow still registers, is not armed, and warns once, naming the region node (the innermost container), the `subflow` / `map` node and the child's suspending node. `GET /automation/_status` reports it `bound: false` with that reason and the new `unboundCause` value `suspending_subflow_in_region` (added to `FLOW_UNBOUND_CAUSES` in `@objectstack/spec/contracts`). Whenever a flow is registered, every flow whose calls reach it is judged again, so the decline does not depend on load order, and a child republished without its suspension arms the caller again.
15+
- **Now, the second door.** A run that still meets the region refusal (a direct execute, or a flow with no trigger) fails exactly as before, with the same refusal. Before throwing it, the engine cancels the child run the `subflow` / `map` node parked, and every run that child is parked on, through `cancelRun`. Each cancelled run records `cancelled`, with the refusal as its reason.
16+
- **Not declined.** A region-nested call whose child cannot suspend, and a top-level call to a child that can (the top-level graph carries the pause). Registration, the save door and `objectstack validate` accept the same flows as before.
17+
- **The fix.** Move the `subflow` / `map` node the reason names onto the top-level graph and route the region's exit to it, or republish the child without the node that suspends. The flow arms on its own once the shape is gone.
18+
- **Who moves.** No shipped flow has the shape. HotCRM's `campaign_enrollment` calls two children from loop bodies, and neither child suspends. The showcase and dogfood calls are all top-level.
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) no metadata key is added, removed, renamed or re-shaped and the save door accepts the same flows: a flow that registered still registers and is only left unarmed by the runtime, with its own reason naming what to move, a run that failed at the region still fails there with the same refusal, and the one contract change is a new value in the runtime status cause list, which no author writes and no migration could rewrite -->
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-calendar': patch
3+
---
4+
5+
fix(plugin-calendar): a calendar view with no `endDateField` draws no resize-end handle, and a drop that changes no date writes nothing
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
On a calendar view that declares only `startDateField`, every month-view card still drew the "Resize event end" handle. Dragging it moved nothing, yet wrote the record's unchanged start date, so `updated_at` moved and every after-update trigger and flow ran for an edit nobody made.
11+
12+
- **No end field, no end handle.** `CalendarView` takes a new optional `endEditable` prop (default `true`). When it is `false`, the month view draws no "Resize event end" handle and the week and day views draw no bottom "Resize end" handle; moving a card and the top "Resize start" handle are unchanged. `ObjectCalendar` passes `false` when the view declares no `endDateField`. A view that declares one keeps both handles and keeps writing the end, including on a record whose end is empty.
13+
- **A drop that changes nothing writes nothing.** `ObjectCalendar`'s default drop handler now skips the write when every date it would write is where the record already has it. A press and release on a week-view event without moving it was such a drop, with or without an end field.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(studio): a report switched to Joined in Properties is held until its first block is added, instead of autosaving a report the draft door refuses
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Setting Report type = Joined clears the report's dataset and measures, because a joined report selects per block. Until a block is added, the report has no `blocks`, and the spec refuses it ("a `joined` report needs `blocks`"). Studio used to autosave that body and get a 422, and every later edit carried the same body and was refused too, then lost on reload. Now the Interfaces pillar holds it unsent, as it already holds an unfinished Order item, block or chart. A neutral line reads "Not saved yet: a Joined report needs at least one block in “Blocks”", its "Show me" focuses the Blocks list, and Studio's leave guard and Publish see the held edit. Adding a block hands the hold to that block until its Name and Dataset are set, and the next autosave then sends every edit kept meanwhile. A joined report with no blocks that also keeps a key the spec refuses is still sent, so that refusal stays on screen.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
feat(mobile): native shell mode — the Console detects `window.ObjectStackNative` and talks to the ObjectStack mobile shell that hosts it
7+
8+
Clause-②: yes (widening) — `@object-ui/mobile` is published and gains new exports; the seat writes the TIER_DEFAULT contract review.
9+
Metadata-protocol: none
10+
11+
The ObjectStack mobile app hosts the web Console in a WebView and injects a bridge, `window.ObjectStackNative`, before the Console's scripts run (on older Android WebViews, possibly after them). The Console now knows when it is hosted.
12+
13+
- **`@object-ui/mobile` gains the bridge type and detection.** `ObjectStackNative` types exactly the members of the shell's bridge contract (`version`, `platform`, `appVersion`, `capabilities`, `safeArea`, `has`, the five calls and the two events). `useNativeHost()` returns it, or `null` outside the shell; it reads the global lazily and re-checks at `DOMContentLoaded`, `load` and a few points after `load`, so a late injection still arrives. `getNativeHost()` is the same read outside React.
14+
- **Typed calls that never throw.** `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and `nativeResetServer` check `has(capability)` and that the method exists, and resolve a `NativeCallResult`: `{ status: 'unavailable' }`, `{ status: 'ok', value }`, or `{ status: 'failed', code, message }`.
15+
- **Router wiring.** `useNativeHostRouting({ route, navigate })` posts `ready()` once per page load after the first route has rendered, posts `routeChanged(route)` once per route change, and answers the shell's cancelable `objectstack-native:navigate` window event by moving the router to `detail.route` (an in-app path only) and cancelling the event, so a notification tap does not reload the Console. Outside the shell it installs nothing.
16+
- **The Console uses it.** In the shell the Console posts those events from its router, marks the root element with `data-os-native-shell="ios|android"`, and drops the PWA install manifest. It adds no safe-area padding: the shell already pads its WebView. Without the global the Console behaves exactly as before.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/auth': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
fix(auth): sign-in, password reset and the authenticator set-up work at phone width
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
Measured in a real Console at 375 and 390 px, against the showcase and CRM examples. No page scrolled sideways, but three things failed on a phone, and all three are fixed. Above the `sm` breakpoint nothing changes.
13+
14+
- Every control on the sign-in, forgot-password and reset-password pages, and in the remediation overlay, was under the 44 px touch-target floor: 40 px fields and buttons, a 28 px show-password toggle, text links 14 to 17 px tall, the 32 px language button. Below `sm` each is now at least 44 px. Links inside a sentence keep their size.
15+
- The sign-in and forgot-password fields used 14 px text, so iOS zoomed the page each time one took focus. They use 16 px on a phone.
16+
- The authenticator set-up step of the remediation overlay (QR code, backup codes, code field) is taller than a phone screen once the keyboard is up, and the overlay did not scroll: the heading went off the top and the Verify button under the keyboard, out of reach. The overlay now scrolls.
17+
18+
What to do: nothing.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(runtime)!: package commit revert and rollback act only on a commit of the package their path names
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata key, schema, accepted value, export or error code is removed or renamed. Two REST doors now refuse a request whose path package does not own the commit it names, with the existing COMMIT_NOT_FOUND code and the package-not-found 404 the sibling package doors already give, so no stored metadata changes and there is nothing for objectstack migrate meta to rewrite. The protocol's two request types gain an OPTIONAL member, which no existing caller has to send. -->
12+
13+
**BREAKING** for one call shape only: a request that named one package in the path and a commit of another. `POST /api/v1/packages/:id/commits/:commitId/revert` and `POST /api/v1/packages/:id/rollback` (body `{ commitId }`) used to ignore `:id`: a commit of package A, sent through package B's path, answered 200, reverted A's items and recorded a revert commit in A's history, even after A was uninstalled. Such a request now answers `404 COMMIT_NOT_FOUND` ("No commit 'ID' in package 'B'.") and writes nothing. An unknown `:id`, which includes a package that has been uninstalled, answers `404 Package 'ID' not found`, as the draft doors do, so an uninstalled package's history no longer takes new revert commits through its own path either. A commit sent through its own package's path reverts and rolls back exactly as before, with the same response body.
14+
15+
- **`@objectstack/runtime`**: both doors hand the path's package to the protocol, after the `manage_metadata`, walled-catalog and query refusals, which keep their order.
16+
- **`@objectstack/metadata-protocol`**: `revertCommit` and `rollbackToPackageCommit` take an optional `packageId`. When it is sent, a commit of any other package is refused `COMMIT_NOT_FOUND` (404) before anything is written. An in-process caller that sends none reads any commit, as before.
17+
18+
A read-only package keeps its commit revert. Its only commits are the publishes of its environment overlays (ADR-0131 D6), and `POST /packages/:id/revert` answers a read-only package's stored rows too.
19+
20+
What to do: nothing, unless a script sends commit ids through another package's path. Send each commit through the package that `GET /packages/:id/commits` lists it under.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): Studio no longer edits the published version over a dashboard draft it is not allowed to read, so the autosave cannot overwrite that draft
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** A pending draft can carry an audience gate (`requiredPermissions`) its author does not hold, for example one written over the API or MCP. The draft read answers `403 PERMISSION_DENIED` for it, by design. Studio's draft reader (`readDraftForSave`) took every failed read for "no pending draft", so the Interfaces pillar opened the published body in its place and the next autosave sent that body as the draft, with no precondition: the draft lost the gate and every other unpublished change, and publishing it served the dashboard to everyone.
12+
- **Now.** `readDraftForSave` hands a read refused `403` back as `refused` (with `isDraftReadRefused` to tell it apart). The Interfaces pillar installs no buffer for such a leaf: nothing is shown to edit and the autosave has nothing to send. The refusal strip says the item has an unpublished draft this author may not read, that editing the published version would overwrite it, and that someone who holds the draft's required permissions can edit or publish it; the server's own sentence is behind Details.
13+
- **Unchanged.** A leaf 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.

‎apps/docs/lib/layout.shared.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export function baseOptions(): BaseLayoutProps {
4747
// `active` is matched against the PUBLIC pathname (`/blog/x`, never the `/en/blog/x`
4848
// a post is prerendered under) — `components/root-provider.tsx` supplies it.
4949
links: [
50+
// This site's own docs come first: the other three lead off-site or to the blog.
51+
{ text: 'Docs', url: '/docs', active: 'nested-url' },
5052
{ text: 'Why', url: ESSAY_URL, active: 'url' },
5153
{ text: 'Blog', url: '/blog', active: 'nested-url' },
5254
// The product site, by role: ObjectOS (editions, pricing, blog) lives there.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27884,8 +27884,15 @@ export class ObjectStackProtocolImplementation implements
2788427884
* used to read it through {@link findEnvironmentRow}, which leaves the
2788527885
* legacy commit out, and answered `COMMIT_NOT_FOUND` for a commit that is
2788627886
* there.
27887+
*
27888+
* [ADR-0067] With `packageId`, a commit of ANOTHER package is not found
27889+
* either: the package a caller names (the REST doors' path id) decides
27890+
* which commits it can act on, so a commit id copied from the wrong
27891+
* package is answered `COMMIT_NOT_FOUND` naming that package, before
27892+
* anything is written. Without it (an in-process caller that names no
27893+
* package) any commit is read, as before.
2788727894
*/
27888-
private async readRevertableCommit(commitId: string): Promise<any> {
27895+
private async readRevertableCommit(commitId: string, packageId?: string): Promise<any> {
2788927896
// [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a
2789027897
// platform store the door already authorized; the protocol scopes the
2789127898
// `where` by organization itself. Principal-less, the engine refuses it.
@@ -27896,6 +27903,12 @@ export class ObjectStackProtocolImplementation implements
2789627903
err.status = 404;
2789727904
throw err;
2789827905
}
27906+
if (packageId !== undefined && String(row.package_id) !== packageId) {
27907+
const err: any = new Error(`No commit '${commitId}' in package '${packageId}'.`);
27908+
err.code = 'COMMIT_NOT_FOUND';
27909+
err.status = 404;
27910+
throw err;
27911+
}
2789927912
const legacyCommits = await this.legacyOrganizationRowsOf('sys_metadata_commit');
2790027913
const legacy = ObjectStackProtocolImplementation.organizationScopedWriteRefusal(
2790127914
`Commit '${commitId}'`,
@@ -27938,6 +27951,12 @@ export class ObjectStackProtocolImplementation implements
2793827951
*/
2793927952
async revertCommit(request: {
2794027953
commitId: string;
27954+
/**
27955+
* The package the commit must belong to (ADR-0067): a commit of any
27956+
* other package is refused `COMMIT_NOT_FOUND` (404) and nothing is
27957+
* written. Optional for in-process callers that name no package.
27958+
*/
27959+
packageId?: string;
2794127960
actor?: string;
2794227961
}): Promise<{
2794327962
success: boolean;
@@ -27952,7 +27971,7 @@ export class ObjectStackProtocolImplementation implements
2795227971
`Commit '${request.commitId}'`, request,
2795327972
);
2795427973
await this.ensureOverlayIndex();
27955-
const row = await this.readRevertableCommit(request.commitId);
27974+
const row = await this.readRevertableCommit(request.commitId, request.packageId);
2795627975
const items = this.parseCommitItems(row.items);
2795727976
// #4556 — threaded into repo.put/delete → `recorded_by`; NULL when the
2795827977
// revert carries no human actor.
@@ -28441,6 +28460,12 @@ export class ObjectStackProtocolImplementation implements
2844128460
*/
2844228461
async rollbackToPackageCommit(request: {
2844328462
commitId: string;
28463+
/**
28464+
* The package being rolled back (ADR-0067): a target commit of any
28465+
* other package is refused `COMMIT_NOT_FOUND` (404) and nothing is
28466+
* written. Optional for in-process callers that name no package.
28467+
*/
28468+
packageId?: string;
2844428469
actor?: string;
2844528470
}): Promise<{
2844628471
success: boolean;
@@ -28451,7 +28476,7 @@ export class ObjectStackProtocolImplementation implements
2845128476
ObjectStackProtocolImplementation.refuseOrganizationScopedWrite(
2845228477
`Commit '${request.commitId}'`, request,
2845328478
);
28454-
const target = await this.readRevertableCommit(request.commitId);
28479+
const target = await this.readRevertableCommit(request.commitId, request.packageId);
2845528480
const all = await this.listCommits({ packageId: target.package_id });
2845628481
// listCommits is newest-first; revert every `apply` commit strictly newer
2845728482
// than the target (by created_at). Revert commits are skipped (their

0 commit comments

Comments
 (0)