Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
04f1182
docs(spec): SystemFieldName says which columns are actually injected …
os-zhuang Aug 1, 2026
ad5fe25
fix(spec,objectql,metadata-protocol): a `user` field carries its targ…
os-zhuang Aug 1, 2026
5293114
fix(automation): enforce isDefault and stop swallowing an unclaimable…
os-zhuang Aug 1, 2026
20bc357
fix(spec,metadata-protocol,runtime): stop advertising routes for the …
os-zhuang Aug 1, 2026
4bee182
fix(cli): every author-time rule that can gate runs on all three comm…
os-zhuang Aug 1, 2026
84b4a3a
docs(client): drop the retired validateOnly batch option from the REA…
os-zhuang Aug 1, 2026
68c02c2
fix(automation): evaluateCondition decides the dialect from the sourc…
os-zhuang Aug 1, 2026
ebb209c
fix(spec,lint): 把 record:* 从 react 档契约里撤回——它 publish 的 props 没有任何渲染器读…
os-zhuang Aug 1, 2026
cdf4d9a
feat(spec,service-datasource): datasource.config 按驱动契约校验 (#4410) (#4465)
os-zhuang Aug 1, 2026
e6b1b69
feat(spec,showcase): aggregate bulk dispatch — allow _selectedIds thr…
os-zhuang Aug 1, 2026
ec975f1
fix(objectql,driver-mongodb)!: findOne must say which record it wants…
os-zhuang Aug 1, 2026
83cf2d3
feat(migrate,metadata-protocol): `os migrate meta --stored` rewrites …
os-zhuang Aug 1, 2026
2826d1e
fix(automation,approvals): 审批决策不能在流程原地不动的情况下"成功" (#4420) (#4460)
os-zhuang Aug 1, 2026
9ca2d85
feat(spec)!: 退休 datasource.readReplicas —— 声明了、strict 了、刚被加了校验,但没有任何东…
os-zhuang Aug 1, 2026
dadb43f
refactor(spec,client,metadata-protocol,runtime)!: 退役 workflow 服务槽位与 g…
os-zhuang Aug 1, 2026
cf2c9b7
refactor(spec)!: remove the kernel metadata-loader envelope family — …
os-zhuang Aug 1, 2026
0f9faa2
feat(spec,cli): the liveness gate governs every registered metadata t…
os-zhuang Aug 1, 2026
de0464a
docs(agents),ci: release notes are release-owned, scoped re-verify, m…
os-zhuang Aug 1, 2026
f3141d8
fix(spec): let a schemaless node own an expression-ledger entry (#443…
os-zhuang Aug 1, 2026
9c93465
fix(spec,docs): check-react-blocks-conformance 比的是两份声明,不是声明↔实现 (#4472…
os-zhuang Aug 1, 2026
d449b0c
fix(cli): gate the two routing shapes that can never work, flag the i…
os-zhuang Aug 1, 2026
304423e
feat(automation,migrate): `os migrate meta --stored` covers flow rows…
os-zhuang Aug 1, 2026
b4487aa
feat(spec)!: remove the per-provider connector "template" cluster (#4…
os-zhuang Aug 1, 2026
0800433
feat(lint): flag an action nobody placed (ADR-0078 Phase 3) (#4501)
os-zhuang Aug 1, 2026
328ccc5
fix(analytics): 记录级权限范围与 measure 字段校验 (#4467, #4437) (#4494)
os-zhuang Aug 1, 2026
ba5ff2f
fix(sharing): 停用规则时撤回已物化授权,并修复 DELETE 500 (#4433, #4434) (#4495)
os-zhuang Aug 1, 2026
2b64e0e
fix(docs,test): 文档失真修正与 ReDoS 断言去负载敏感 (#4485, #4476, #4486, #4452) (#…
os-zhuang Aug 1, 2026
c57f3cf
feat(spec)!: remove the trigger-registry Connector cluster (#4499) (#…
os-zhuang Aug 1, 2026
ea90179
fix(data,runtime,drivers): REST 信封与 $search 字段集 (#4431, #4435, #4436,…
os-zhuang Aug 1, 2026
8aacf94
fix(metadata-protocol,rest): one seam for flow canonicalization — `du…
os-zhuang Aug 1, 2026
d7759f3
ci: Test Core 按包两路分片,verify-CLI 拆为独立并行 job(PR 关键路径 ~13min → ~7min) (#…
os-zhuang Aug 1, 2026
97faca3
feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggrega…
os-zhuang Aug 1, 2026
4f13be2
feat(spec,cli): govern the 9 remaining metadata types — liveness cove…
os-zhuang Aug 1, 2026
7631964
feat(spec): ratchet cross-entry dual-source exports — same name, diff…
os-zhuang Aug 1, 2026
2fa286c
docs(spec,metadata-protocol): the code is the record for platform `ag…
os-zhuang Aug 1, 2026
5a84d41
fix(automation,approvals): 屏幕流 resume 服务端校验 + 审批越权可审计 (#4477, #4466, …
os-zhuang Aug 1, 2026
e5e7ee0
feat(spec): strictObject, the first registered-type conversions, and …
os-zhuang Aug 1, 2026
c2a1134
docs(automation): 屏幕流 resume 契约与审批越权标记的文档 + 两份 changeset(#4502 跟进) (#…
os-zhuang Aug 1, 2026
061406d
fix(spec): the protection-envelope check was hollow — it skipped 24 o…
os-zhuang Aug 1, 2026
fd3013a
feat(spec,automation)!: converge `script` to a function call and pars…
os-zhuang Aug 1, 2026
63b33e6
fix: 四处静默失真 — datasource 映射、meta type 归一化、value-shape 扫描、未接线的 lint 规则…
os-zhuang Aug 1, 2026
20b1a9e
fix(data): 审计锚点归引擎所有,lookup 必须能解析 (#4447, #4441) (#4511)
os-zhuang Aug 1, 2026
3144a11
Merge main into percent-scale-3136, regenerating the api-surface base…
claude Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
45 changes: 45 additions & 0 deletions .changeset/action-no-placement-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/lint": minor
"@objectstack/cli": minor
"@objectstack/metadata-protocol": minor
---

Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`).

New advisory rule `action-no-placement`: an action that declares no
`locations` and that no list view places by name renders on **no** surface —
it parses, publishes, and appears in Setup, while no user can ever click it.
ADR-0078 names this shape in its opening paragraph and Phase 3 asks for
exactly this rule; the shared completeness predicate it envisioned was never
built, so this lands standalone, one verified shape at a time.

What made it verifiable now: objectui#3142 collapsed four disagreeing
renderers onto one placement predicate. Before that, `action:bar` and the
record header rendered an *undeclared* action anyway, so the shape only looked
inert on paper. As of objectui 17.1 it is measurably inert.

Two things are deliberately **not** flagged:

- **`locations: []`** — the documented headless action (callable over REST /
MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a
headless declaration is the only legal way to expose one. The rule therefore
distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key
absent) and only reports the latter.
- **Actions a view places by name** — `bulkActions`, `bulkActionDefs`
(including `execution: 'aggregate'` defs, whose whole point is an action with
no single-record home) and `rowActions`, across all three list-view tiers:
`views[i].list`, `views[i].listViews.<key>` and the object-embedded
`objects[i].listViews.<key>`.

Advisory, never fatal — a view in another installed package may be the one
placing the action, the same reason `validateSemanticRoles` and
`lintLivenessProperties` warn rather than gate.

Also: the action form schema in `@objectstack/metadata-protocol` no longer
declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()`
tombstones in spec 17, and this schema is what the Studio designer renders its
fallback form from — so advertising them handed authors two inputs that could
only ever produce an unsaveable draft (objectui#3145 removed the matching
dedicated controls). And `content/docs/ui/actions.mdx` now says which surface
is the exception to location filtering, instead of a blanket claim its own
showcase contradicted.
33 changes: 33 additions & 0 deletions .changeset/agent-code-is-the-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
---

docs(spec,metadata-protocol): record why platform `agent` definitions have no metadata change log (#4507)

Comment-only — no behaviour changes, nothing to release.

`agent` is the only authorable metadata type with no governed write path
(`allowOrgOverride` and `allowRuntimeCreate` are both `false` per ADR-0063 §2,
which closes `*.agent.ts` to third parties). Its rows are written by the
shipping plugin at boot — `AIStudioPlugin.registerMeta` → `metadataService.register()`
→ `MetadataManager.register` → `DatabaseLoader.save` — which writes
`sys_metadata` directly with a fresh checksum and appends no
`sys_metadata_history` row. So a shipped agent definition that changes between
releases leaves no metadata-side change log.

That is accepted rather than overlooked, and the reasoning now sits beside the
declaration instead of in an issue: the two definitions live in version control
(`@objectstack/service-ai-studio` in the `cloud` repo), so git already holds the
full reviewable history. A second history in `sys_metadata` would be a *worse*
record — it would capture only the boots where a given deployment happened to
see the checksum move, so two deployments on the same release would carry
different "histories" of an identical, code-fixed definition.

Two consequences that read as bugs and are not are named explicitly: the
`skipped` outcome `os migrate meta --stored` reports for `agent` rows is correct
and permanent for this type, and Studio showing no History tab for an agent is
the absence of anything to show. `migrateStoredMetadata`'s TSDoc now points at
the note rather than leaving its skip reason to be read as a to-do.

The note also states its own expiry: if `agent` is ever opened to tenant
authoring, an author-owned definition has no git to fall back on, so opening the
type and giving it a real history path become the same piece of work.
8 changes: 8 additions & 0 deletions .changeset/agents-releases-freeze-merge-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
---

Releases nothing — repo process + CI only. `content/docs/releases/` becomes
RELEASE-OWNED (never edited in code PRs; compiled centrally from changesets +
the ADR-0087 registries), AGENTS.md multi-agent §10 scopes the post-merge
re-verify, and the three required-check workflows gain `merge_group:` triggers
so the merge queue can be enabled. No package ships from this change.
26 changes: 26 additions & 0 deletions .changeset/aggregate-bulk-dispatch-selected-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/spec": minor
---

feat(spec): allow the aggregate bulk dispatch key `_selectedIds` through the action param gate (objectui#3139)

A list view's `bulkActionDefs` entry can now opt into an aggregate single-call
dispatch (`execution: 'aggregate'`, objectui 17.1): the renderer invokes the
named object action ONCE for the whole selection, injecting every selected
record id as `params._selectedIds: string[]`, so a single call can produce one
aggregate artifact (zip of QR codes, merged PDF, batch print job).

`ACTION_PARAM_BUILTIN_KEYS` gains `'_selectedIds'` so the ADR-0104 strict
param gate does not 400 an aggregate dispatch against an action that declares
params — like `recordId`/`objectName`, the key is dispatcher-injected and can
never be authored as a declared param. Pure widening: actions declaring no
params were never validated, and no authored bag legitimately carried this
key. The `bulkActionDefs` describe now documents the aggregate contract
(server reads `params._selectedIds`, results are all-or-nothing, `batchSize`
does not apply, set `maxRecords` for expensive aggregates, and toolbar
url/api actions can interpolate `${ctx.selection.ids}`).

The showcase's Task → Bulk Actions view carries the specimen:
`showcase_recalc_selection` dispatches the recalc endpoint once for the whole
selection via the endpoint's new `_selectedIds` batch branch, next to the
per-record fan-out fixtures.
92 changes: 92 additions & 0 deletions .changeset/analytics-record-scoping-and-measure-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
"@objectstack/plugin-security": minor
"@objectstack/service-analytics": minor
---

fix(security,analytics): scope /analytics/query to the caller's readable records, and refuse a measure over a missing field (#4467, #4437)

Two defects on the analytics query path, both found by the v17 verification run
(#3909 / #4482), both reproduced against a live showcase server before the fix
and re-verified with the same requests after.

## #4467 — `/analytics/query` applied no record-level scoping

`ISecurityService.getReadFilter` documents itself as "the same filter the engine
middleware AND-s into every find", and exists precisely for paths that bypass
that middleware — its own doc comment names the analytics raw-SQL path. But the
chain it mirrors is TWO sibling middlewares: plugin-security's RLS injection and
plugin-sharing's owner/share visibility filter (`buildSharingMiddleware` AND-s
`buildReadFilter` into `ast.where` for `find`/`findOne`/`count`/`aggregate`).
Only the RLS half was ever computed here, and analytics has no other source of
scope, so the OWD/share predicate simply never existed on that path.

Live repro: `showcase_private_note` is `sharingModel: 'private'`; an admin owns
5 notes, a member holds read shares on exactly 2 and no `viewAllRecords`.
`GET /data/showcase_private_note` correctly returned 2 for the member, while
`POST /analytics/query {measures:['count']}` returned 5 — and adding
`dimensions:['title']` returned all five titles, i.e. the VALUES of a column
that caller may not read, not merely a bad count. Any authenticated caller who
could reach `/analytics` could enumerate the field values of every row of any
object exposed as a cube, regardless of OWD, sharing rules, or RLS.

`getReadFilter` now resolves plugin-sharing's `buildReadFilter` through the
late-bound `sharing` service and AND-composes it with the RLS filter — the same
composition the two middlewares reach by both writing into `ast.where`. It also
computes the ADR-0057 D1 `__readScope` depth that the security middleware
normally stashes on the context for plugin-sharing to widen its owner-match
with, using the same `getEffectiveScope` call the middleware makes: no
middleware runs on this path, and without it a caller granted `unit`/`org` read
depth would be silently narrowed to `own`. The sharing predicate is resolved for
every non-system caller AHEAD of the RLS stand-down branches, because those are
the RLS middleware's own early exits and none of them is a reason to drop a
sibling middleware's predicate; a sharing-resolution failure denies outright
rather than falling through to half a scope.

**Why `minor` rather than `patch`.** This is an observable behaviour change on a
public read surface, in the narrowing direction: analytics results that a
principal could previously read they now cannot. Counts drop, `dimensions`
groupings lose rows, and any dashboard, report, or export built on
`/analytics/query` over an owner-private object will show smaller numbers for
non-superuser principals — correctly, but visibly. Deployments that had (however
unknowingly) come to depend on the unscoped totals will see them change on
upgrade, so this warrants more than a patch-level note even though it is a
security fix. No API signature changed: `ISecurityService.getReadFilter`'s
declaration is untouched — the implementation merely started honouring the
contract it already documented.

## #4437 — a measure naming a missing field 500'd with SQLITE_ERROR

`inferMeasure('ghost_sum')` maps a suffix convention onto a field name and has
no way to know the field exists, so it built `SUM(ghost)`, the driver threw
`no such column`, and the caller got
`500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error
class as the `error.code` for what is a plain typo, which ADR-0112 forbids. A
dotted spelling took the same path (`measures:['total.sum']` prefix-strips to
`sum` → `SUM(sum)` → 500). The DATA route has refused the identical mistake with
a `400 INVALID_FIELD` naming the field since #4315/#4254.

`AnalyticsService.ensureCube` now validates each measure's resolved source field
against the backing object's field names before any SQL is built, and rejects
with the same envelope the data route produces (`400 INVALID_FIELD` carrying
`field`, `object`, `param`, `measure`) so one mistake has one shape across
`/data` and `/analytics`. The new `getObjectFieldNames` config hook reads the
same schema registry `isRegisteredObject` already consults and the data path's
own gate reads, so "which fields exist" has a single answer across both routes.

The gate is tiered exactly like the #3867 cube-inference gate, deliberately
narrow: it applies only when the cube's `sql` is a bare object name (an authored
cube whose `sql` is a real SQL expression has no field list to check against),
only when the probe answers (no data engine, or an external datasource whose
columns are not mirrored locally, stands down), and only to measures whose
source is a bare column — `count(*)` has no source field, and a dotted
cross-object reference resolves through a join this layer cannot see, so both
pass through untouched. `id`/`created_at`/`updated_at` are admitted
unconditionally, matching the data path's `resolveQueryFields`: a gate stricter
than the engine it guards would reject queries that used to work. Validation
runs before the cube is registered, so a rejected query leaves no trace in the
registry — otherwise a retry would find a "registered" cube carrying the bogus
measure and sail straight into SQL.

This half is `minor` for the same envelope reason: a request that used to return
500 now returns 400 with a different `code`, which is a visible contract change
for any caller branching on the response.
64 changes: 64 additions & 0 deletions .changeset/approval-decision-survives-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/plugin-approvals": minor
"@objectstack/rest": patch
"@objectstack/runtime": patch
---

fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420)

A flow paused at an `approval` node, a deploy, then an approver clicking
Approve: the request row flipped to `approved`, the UI toasted success — and
the flow never moved. No next-stage request, no error, the record's mirrored
status frozen mid-workflow. Approval flows pause for days by design, so a
restart mid-flight is the normal case: every release could quietly zombify
every in-flight approval, with the approvers none the wiser.

Durable suspended runs (#1518) had shipped and were not the missing piece. Two
other things were.

**The wiring could enable a store over a table nobody had created.** Object
registration and store activation resolve different services in different
phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin
declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`,
warned, and continued; `start()` then attached the DB-backed store anyway. Every
suspend failed with `no such table: sys_automation_run` into a log line nobody
read, pauses silently stayed in memory, and the next restart lost them all.
Now: `AutomationServicePlugin` declares `optionalDependencies:
['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an
engine-less kernel must still boot); a registration missed at `init()` is
retried at `start()`, which still lands before ObjectQL's schema sync; the
store is never attached when registration did not happen, and says so at
**error** level instead of warning; the table is probed once at boot so a
broken setup surfaces there rather than one failed write at a time; and a
failed durable write of a paused run is logged at error — it is data loss in
waiting, not a warning.

**A reported resume failure read as success.** `AutomationEngine.resume()`
answers a lost run by *returning* `{ success: false }`, never by throwing.
`ApprovalService` discarded that return value, and `decide()` counted only a
thrown error as failure — so a decision against a dead run came back
`resumed: true`, HTTP 200. Resume failures are now classified
(`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining
`PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is
distinguishable from a store that is merely unreachable, and the raw resume
route maps them to 404 / 503 / 409.

Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which
reads the suspension store, unlike `getRun()`, and throws rather than answering
`false` when the store is unreadable — pre-flights every flow-advancing
operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the
zombie half-state is never created rather than merely reported: the decision
fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A
resume that fails after the decision is durable can no longer be undone, but it
now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of
reporting success. A concurrent duplicate resume stays benign — the engine's
idempotency guard is doing its job — and reports through the new optional
`resumeError` field. Recall and revise-window cancellation stay non-fatal by
design (they abandon the request), but log at error with the reason instead of
swallowing it. Compositions with no automation engine attached are unaffected.

Existing zombie requests from affected deployments (already `approved`, run
stranded) are not repaired by this change — `releaseDeadRunRequests` only
sweeps requests that are still `pending`.
47 changes: 47 additions & 0 deletions .changeset/approval-override-audit-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": patch
"@objectstack/plugin-approvals": patch
---

fix(approvals): record an admin override of a staffed approver slate AS an override (#4466)

An admin who is not in a request's `pending_approvers` may still act on it — the
`#3424` privileged-override path exists so a request routed to an unstaffed
position, or to approvers who have all left, is not undecidable forever. The
override is defensible; what was not is what the audit trail recorded.

`sys_approval_action` had no override column at all. So an admin overriding a
properly-staffed slate wrote a row **byte-for-byte identical** to the designated
approver approving normally: a reader of the timeline saw `approve` by the admin
and could not tell whether the admin *was* an approver or *overrode* the ones who
were, and the bypassed approver's later `409 INVALID_STATE` was the only trace —
existing only if they happened to try. The platform knows at decision time (it
took the `isOverrideActor` branch to admit the call at all), so this was dropped
information, not unavailable information. The whole point of an approval record
is to answer "who authorized this, and were they entitled to?".

`sys_approval_action` now carries **`via_override`** (boolean, optional), set on
exactly the actions admitted by that branch — `decideNode`'s approve/reject and
`reassign`'s admin rescue. It is surfaced on `ApprovalActionRow.via_override`
(`@objectstack/spec/contracts`), returned by `listActions`, and added to the
object's `highlightFields` and two grid list views so a timeline can say
"overrode the approver slate" instead of rendering it as an ordinary approval.

Three distinctions the column keeps apart deliberately:

- **`true`** — the actor held no slot in the slate and was admitted only by the
override branch.
- **`false`** — checked, and it was not an override. An admin who *is* a
designated approver is approving normally and records `false`: the marker is
about which branch admitted the call, not about whether the actor holds admin
rights.
- **absent** — a row written before this column existed. "Not recorded" is not
the same claim as "not an override", so `rowFromAction` maps `null` to
`undefined` rather than to `false`.

Additive and nullable, so this needs no data migration: existing rows keep
working and simply read as unrecorded. Levelled `patch` rather than `minor`
because nothing an author writes changes — but note it *is* an observable
behaviour change on a read surface: `listActions` responses and the
`sys_approval_action` grid views now carry a field consumers did not see before,
and `sys_approval_action` gains a column on next schema sync.
Loading
Loading