Skip to content

Commit bb7d350

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/sync-12718-local
2 parents 5eb237f + dc75ba8 commit bb7d350

272 files changed

Lines changed: 26441 additions & 1640 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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `ObjectSchema.create()` rejects unknown keys on nested action-param literals at compile time (#12615)
6+
7+
`create()` infers its generic from the argument, so TypeScript's
8+
excess-property (freshness) checking never fires at any depth; the existing
9+
`NoExcessObjectKeys` map compensated only at the top level. A typo'd key on an
10+
`actions[].params[]` literal (measured: `carryOverX` on
11+
`sys-permission-set.object.ts`) therefore passed `tsc` clean and was caught
12+
only by `ActionParamSchema`'s strict parse at module load.
13+
14+
The same `Record<excess-key, never>` map is now mirrored over each element of
15+
each action's `params` array, so the typo becomes a located `tsc` error at the
16+
authoring site (`error TS2322 … 'true' is not assignable to 'never'` pointing
17+
at the unknown key).
18+
19+
Compile-layer signal only — shipped as `patch` because no working code
20+
changes meaning: the strict parse at module load stays the enforcement of
21+
record, nothing changes in what parses or when, and every literal the new
22+
constraint refuses was already refused (later, at import) by that parse.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
refactor(service-analytics): derive the analytics auto-bridge's engine view from the declared contracts (#11833)
6+
7+
`plugin.ts` named the data engine through a consumer-local structural
8+
`DataEngineLike` — the second of the two sites #11833 records, after the
9+
datasource half that landed as PR #12011. It is now derived from the declared
10+
contracts: `IDataEngine.aggregate` / `execute?` /
11+
`resolveEffectiveDatasource?` / `getDriverForObject?` and
12+
`IObjectQLEngine.getObject`. Optionality is preserved exactly — `aggregate`
13+
required, everything else `Partial<>` — because these probes are the plugin's
14+
graceful-degradation seam.
15+
16+
**Why this is `patch` and not a type-only no-op.** Four of the five members
17+
substitute with no behaviour change. The fifth does not: the deleted structural
18+
type declared `aggregations[].function` as `string`, while the contract
19+
declares the six-value `AggregationFunction`. The bridge therefore forwarded
20+
whatever method string reached it. That forward is now parsed with the spec's
21+
own enum, so a method the engine contract does not declare is refused at the
22+
bridge — loudly, naming the aggregation and the legal vocabulary — instead of
23+
reaching the engine, where `driver-sql` blamed a `function` key the author
24+
never wrote and the in-memory evaluator answered `null` for every bucket under
25+
the author's own measure name.
26+
27+
No authored analytics can trigger the new refusal: the one reachable producer
28+
of a non-aggregate method — a custom-SQL measure (`AggregationMetricType`
29+
`number` / `string` / `boolean`) — is already refused earlier, caller-facing,
30+
by `ObjectQLStrategy.resolveMeasureAggregation` (#12209). What is left is host
31+
drift (a cube object registered without meeting `CubeSchema`), which is why
32+
the new refusal is a bare `Error` in the undeclared-500 tier rather than an
33+
ADR-0112 400 that would blame the caller for something they did not write.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `/analytics/dataset/query` carries a producer-marked `userMessage` on its three hand-built terminals (#12710)
6+
7+
`POST /api/v1/analytics/dataset/query` (and its environment-scoped twin) builds
8+
its error envelopes by hand and shares no exit with the `/data` door, so #9934's
9+
producer-marked `userMessage` — a channel that door applies once at its exit,
10+
branch-agnostically, through `withDeclaredUserMessage` — was applied at none of
11+
them. A producer's caller-facing sentence reached the client on
12+
`POST /data/:object` and vanished here for the identical throw.
13+
14+
**Scope is by ARM.** Four terminals live in that route's catch; three dropped
15+
the mark and one did not:
16+
17+
| arm | envelope | before |
18+
| :-- | :--- | :--- |
19+
| ① declared 4xx ADR-0112 passthrough | hand-built `{ code, message }` | ⛔ no mark |
20+
| ①b `classifiedRefusalAnswer` re-dress | `{ ...refusalFields, message }` | ✅ carried it |
21+
| ③a declared 5xx relay | `declaredServerFaultAnswer`'s body, sent verbatim | ⛔ no mark |
22+
| ③b generic `500 ANALYTICS_QUERY_FAILED` | hand-built `{ code, error }` | ⛔ no mark |
23+
24+
①b already carried it because its body comes from `resolveErrorResponse`, whose
25+
arms ride the mark already. The other three hold no classification to ride on.
26+
27+
Measured on `4af6c4419` before the repair, one marked producer per arm, driven
28+
through the real route against the flat `/data` door for the identical throw:
29+
30+
```text
31+
throw { code: 'INVALID_FILTER', status: 400, userMessage: 'Check the filter…' }
32+
① analytics : 400 {"code":"INVALID_FILTER","message":"…"} — no mark
33+
/data door : 400 {"error":"…","code":"INVALID_FILTER",
34+
"userMessage":"Check the filter…"} — mark carried
35+
36+
throw { code: 'READ_SCOPE_COMPILE_FAILED', status: 500, userMessage: '…' }
37+
③a analytics: 500 {"error":"Internal server error",
38+
"code":"READ_SCOPE_COMPILE_FAILED"} — no mark
39+
/data door : 500 {…, "userMessage":"…"} — mark carried
40+
41+
throw Error('[Analytics] no strategy can handle query …') + userMessage
42+
③b analytics: 500 {"code":"ANALYTICS_QUERY_FAILED","error":"…"} — no mark
43+
/data door : 500 {"code":"INTERNAL_ERROR","userMessage":"…"} — mark carried
44+
```
45+
46+
Nothing invalid shipped — every body parsed as `ApiErrorSchema`, which already
47+
declares the optional field — and that is what made the loss silent and
48+
one-directional: a console told by ADR-0112 to render `userMessage` verbatim
49+
found nothing at these three arms and fell back to its generic substitution, for
50+
the same throw the twin door rendered.
51+
52+
**What callers see change:** exactly one optional key is ADDED, and only when
53+
the producer marked one. No existing key moves or changes value, at any of the
54+
four arms — pinned as an explicit key-order assertion per arm for an unmarked
55+
producer.
56+
57+
The value comes from `boundedDeclaredUserMessage` (exported by #12693) —
58+
`declaredUserMessage`'s presence answer with #5423's bound applied — resolved
59+
once for the whole catch rather than at each terminal, so this door has one
60+
answer to "is there a mark, and how long may it be" and shares it with `/data`
61+
rather than copying it. ①b is deliberately untouched: a second application there
62+
would be one rule applied twice.
63+
64+
**Unchanged:** the prose withhold (#5367/#5437/#5811) — a declared server fault's
65+
message is still replaced by the generic sentence and still reaches the operator
66+
in full through the `logError` line that runs before every arm; the statuses and
67+
`code`s all four arms answer; and #5667's tiering, which leaves a self-authored
68+
undeclared fault readable.
69+
70+
**Not reachable from in-repo producers today.** Censused at claim: no package
71+
under `packages/services/**` sets a `userMessage` of any kind, and
72+
`service-analytics` dispatches no sandbox hook, so the QuickJS side-channel — the
73+
other in-repo carrier — does not reach this door either. This wires up a declared
74+
channel the published contract already promises on this route's envelope; the
75+
intended producer is an app author's analytics datasource or strategy.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": minor
4+
---
5+
6+
feat(spec,plugin-approvals): `escalation.enabled` defaults to `true` and the SLA sweep finally reads it (#12278)
7+
8+
**BREAKING** semantic default flip on a published authorable key, shipped as
9+
`minor` under the repo's launch-window convention for breaking changes.
10+
Maintainer ruling 2026-08-27 (Option C), explicitly reversing the 2026-08-26
11+
"spec stays as declared" ruling with fresh analysis.
12+
13+
`ApprovalEscalationSchema.enabled` declared `default(false)` while the
14+
plugin-approvals escalation sweep never read the key: any escalation block
15+
with a positive `timeoutHours` escalated, and with `action: 'auto_approve'`
16+
that silently approved requests their author had declared off the clock —
17+
the ADR-0049 declared-but-unenforced shape, failing open. Worse, the
18+
approval-node executor parses node config through the schema before
19+
snapshotting it onto the request row, so the old default **materialized**
20+
`enabled: false` into storage for every author who omitted the key, making
21+
"authored off" and "defaulted off" byte-identical at the sweep site.
22+
23+
One change, both halves:
24+
25+
- **spec**: `enabled` now defaults to `true` (stays `z.boolean()`; no
26+
tri-state). The feature-level switch is whether an `escalation` block
27+
exists at all; within a block carrying `timeoutHours`, escalation is on
28+
unless explicitly turned off — which is what the runtime, its eleven
29+
behaviour tests, and every teaching surface have always meant. Declared in
30+
`DEFAULT_CHANGES_BY_MAJOR` (17) and registered as the
31+
`approval-escalation-enabled-default-flip` semantic migration entry.
32+
- **runtime**: `runEscalations` skips a request whose snapshot carries an
33+
explicit `escalation.enabled === false` — the declared switch is enforced.
34+
Request snapshots created **before** the flip cutoff
35+
(`ESCALATION_ENABLED_FLIP_CUTOFF_MS`, 2026-08-28T00:00:00Z) ride a
36+
read-side legacy window and keep escalating exactly as they do today: their
37+
stored `false` is overwhelmingly the old schema default materialized onto an
38+
author who never wrote the key, every such stored row is escalating today,
39+
and the window retires itself as pending requests drain — zero tenant rows
40+
rewritten.
41+
42+
Deployed metadata that omits `enabled` does not change behaviour (it
43+
escalated before, it escalates after). What changes is that writing
44+
`enabled: false` finally binds for newly opened requests — the declared
45+
intent being honoured. To keep an SLA off, write `enabled: false`; to
46+
escalate on timeout, an `escalation` block with `timeoutHours` is enough.
47+
48+
<!-- adr-0087: registered approval-escalation-enabled-default-flip -->
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(plugin-approvals): the `approval_recall` action shows for the #3424 admin override (#12716)
6+
7+
`ApprovalService.recall` has admitted two callers since #3424 — the submitter,
8+
and a platform/tenant admin releasing a stuck request — and `isOverrideActor`'s
9+
own doc block names recall as one of the four override levers in so many words.
10+
The declared action that reaches that endpoint did not agree: `approval_recall`'s
11+
`visible` predicate was submitter-only, while its three siblings
12+
(`approval_approve` / `approval_reject` / `approval_reassign`) each OR in
13+
`record.viewer.can_override`.
14+
15+
So recall was the one lever the override covers whose button never appeared. An
16+
admin rescuing an approval routed to an unstaffed position could approve or
17+
reject their way out — writing a decision nobody made — or reassign it, but
18+
could not simply withdraw it. This is declared-vs-enforced drift in the less
19+
usual direction: a capability the server grants that no UI entry exposed.
20+
21+
`approval_recall`'s `visible` now ORs in `record.viewer.can_override`, spelled
22+
byte-identically to the three siblings.
23+
24+
Not a permission change: the service's authorisation set is untouched, and
25+
`can_override` was already computed server-side for every viewer.
26+
27+
**Pending-only, and enforced rather than asserted.** The new arm carries no
28+
status test of its own — neither do the siblings — because the flag is already
29+
status-scoped where it is computed: `attachViewers` sets
30+
`can_override: row.status === 'pending' && isOverrideActor(...)`, ANDed, so the
31+
flag can never be true off `pending` and the arm is pending-only in effect
32+
however CEL groups the expression. The submitter's own `returned` (revise
33+
window) arm is unchanged. Pinned in both directions, with the flag's own scoping
34+
pinned against the real service on a genuinely `returned` row.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
---
4+
5+
fix(plugin-approvals): the non-submitter recall refusal renders through the
6+
Operation Message Catalog instead of a hardcoded English sentence (#11993, the
7+
services-side half of the shape-A ruling)
8+
9+
A user who opened a record someone else had submitted for approval, clicked
10+
Recall and was correctly refused read the reason in English regardless of their
11+
own locale. `@objectstack/rest`'s `handleApprovalError` ships this service's
12+
thrown reason as the 403 body's human-readable `error`, and Console splices it
13+
under its own localized label — so an operator in a fully Chinese deployment
14+
read a Chinese prefix glued onto an English sentence they could not act on
15+
(`撤回审批失败: <English>`).
16+
17+
The refusal now renders through the shared Operation Message Catalog in
18+
`@objectstack/spec/system` under the key `approval_recall_not_submitter` that
19+
#12493 landed for it — the same mechanism `plugin-security`'s denial gates
20+
already use, with the same resolution ladder (deployment override → the
21+
caller's locale → `en` → the key) and the same guarantee that a misbehaving
22+
i18n service cannot turn a 403 into a 500. All four platform locales (`en`,
23+
`zh-CN`, `ja-JP`, `es-ES`) ship copy that names who *can* recall, rather than
24+
dead-ending the reader.
25+
26+
`ApprovalServiceOptions` gains an optional `messageTranslator` — a lazily
27+
resolved, `II18nService.t`-compatible lookup, wired by `ApprovalsServicePlugin`
28+
the same way `tenancyPosture` and the field-visibility source are, because the
29+
i18n service is contributed by another plugin and may start later. It is what
30+
makes the override address the catalog documents,
31+
`errors.approval_recall_not_submitter`, actually take effect for this emitter;
32+
a stack without an i18n service still renders the built-in catalog in the
33+
caller's locale.
34+
35+
**Not changed: who may recall an approval.** The gate is byte-identical — the
36+
submitter, or a privileged admin releasing a stuck record (#3424). Only the
37+
sentence the refusal carries is different, and the `FORBIDDEN:` code prefix
38+
that the REST layer maps to 403 is untouched.
39+
40+
The button-visibility half of #11993 — a non-submitter seeing a live recall
41+
button at all — is not addressed here; see the issue for the measurement.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): the documented broken-sweep predicate is a first FILTER, not the detector (#12685)
6+
7+
`patch`, and not empty: `sys_automation_run`'s field descriptions are shipped,
8+
translated, operator-facing text — they are what an admin reads in Setup while
9+
wiring an alert they will then trust for months. No counter, no schema and no
10+
engine behaviour changes here; the run summary measured by #4354 is correct and
11+
untouched.
12+
13+
## The wrong claim
14+
15+
`acted_count` advertised `selected_count > 0 AND acted_count = 0 AND
16+
unmeasured_count = 0` as *the* broken-sweep signal, unqualified. Measured A/B on
17+
one graph pair through the real engine — a healthy idempotent sweep (re-select
18+
the same records, gate each one on "was this already handled") and a dead gate
19+
(#4347's shape, the gate sitting in front of the lookup) — **both** report
20+
`selected > 0, acted 0, unmeasured 0`. The predicate cannot make the one
21+
distinction it was advertised to make.
22+
23+
"Over N consecutive runs" does not rescue it either: the healthy steady state
24+
trips it on *every* run for as long as the outstanding work stands, so it is
25+
persistent rather than transient. Consecutiveness filters flapping, which is a
26+
different failure.
27+
28+
Why a wrong sentence here is worse than a wrong sentence elsewhere: a detector
29+
that fires during normal operation gets muted, and a muted broken-sweep detector
30+
is the same silence #4347 produced — with the added cost that it now *looks*
31+
monitored.
32+
33+
## What the descriptions say now
34+
35+
- `acted_count` states the predicate as the **first filter** and names the
36+
discriminator: a healthy skip is accounted for by a read the run performed
37+
(the lookup the gate depends on shows `runs > 0` and `selected > 0` in
38+
`summary_json.nodes[]`), while a dead gate skips just as often with nothing
39+
behind it (`runs: 0`, or `selected: 0`).
40+
- `skipped_count` points at the same fold — `gates[]` names which edge closed
41+
and how often, `nodes[]` says whether the lookup behind it found anything.
42+
- `unmeasured_count` keeps its own point (why the third clause exists) and now
43+
calls the query a filter rather than an alert.
44+
45+
The discriminating data was already shipped by #4354; nothing new is measured
46+
and no detector is implemented in the platform. `run-summary.test.ts` pins the
47+
pair as executable evidence: both shapes match the filter, and the per-node fold
48+
separates them. `content/docs/automation/flows.mdx` carries the same correction
49+
with the measured table and the two authoring shapes that make a sweep's signal
50+
quiet in its healthy steady state.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec,repo): address the changesets `repo` option and the spec README's StackBlitz badge to `objectstack-ai/objectstack` (#12488)
6+
7+
Two places still name `objectstack-ai/spec`, which is not the repository this
8+
monorepo lives in:
9+
10+
- `.changeset/config.json``changelog[1].repo`
11+
- `packages/spec/README.md` — the "Try Online" StackBlitz badge target
12+
13+
The README badge is the user-visible half. `README.md` is listed in this
14+
package's `files`, so it ships in the npm tarball and every reader of
15+
`@objectstack/spec` on npm gets a "Try Online" button addressed to a repository
16+
that is not this one. The path the badge names —
17+
`examples/app-todo/objectstack.config.ts` — exists here, so correcting the
18+
owner/repo segment is the whole fix.
19+
20+
The `.changeset/config.json` half is hygiene with a measured expiry date rather
21+
than a bug that is firing. The configured generator `@changesets/cli/changelog`
22+
re-exports `@changesets/changelog-git`, whose `getReleaseLine(changeset)` and
23+
`getDependencyReleaseLine(changesets, dependenciesUpdated)` do not read the
24+
third argument — the options object that `@changesets/apply-release-plan`
25+
passes as `config.changelog[1]`. So `repo` has **no reader today**, and the
26+
wrong value has never produced a wrong link in any CHANGELOG this repo has
27+
generated. It acquires a reader the moment anyone swaps in
28+
`@changesets/changelog-github` — which is the usual reason to touch that block
29+
— and from then on every generated release line would link into the wrong
30+
repository, with nothing in the diff looking wrong.
31+
32+
No runtime behaviour changes.

0 commit comments

Comments
 (0)