Skip to content

Commit 3d9eac4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/sys-member-role-assessment-s09jvx
2 parents cf15bad + 4cca74c commit 3d9eac4

98 files changed

Lines changed: 3843 additions & 1070 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(agents): PD #12 now points at the alias retirement path (`readAliasedConfig` shim → ADR-0087 D2 conversion layer) instead of two examples that have since moved — releases nothing.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/service-analytics": patch
4+
---
5+
6+
fix(driver-sql,analytics): stop `aggregate()` / `distinct()` leaking SQLite's raw epoch storage (#3797)
7+
8+
Both returned `await builder` directly, without the `formatOutput` pass every
9+
`find()` row gets. On SQLite — the one dialect where a `Field.datetime` is
10+
stored as INTEGER epoch milliseconds rather than a native timestamp — that raw
11+
storage form went straight to the caller:
12+
13+
| call | before | after |
14+
| --- | --- | --- |
15+
| `find()` | `"2026-01-10T09:00:00.000Z"` | unchanged |
16+
| `distinct('closed_at')` | `[1768035600000]` | `["2026-01-10T09:00:00.000Z"]` |
17+
| `aggregate()` `max(closed_at)` | `1768035600000` | `"2026-01-10T09:00:00.000Z"` |
18+
| `aggregate()` `groupBy: ['closed_at']` | key `1768035600000` | key `"2026-01-10T09:00:00.000Z"` |
19+
20+
Same root cause as #3773, different exit. `Field.date` was never affected — it
21+
is ISO TEXT on every dialect, so its storage form already equals its
22+
presentation.
23+
24+
The visible surfaces were a `_max`/`_min` measure over a datetime (a "last
25+
closed" KPI tile rendered `1768035600000`) and a `groupBy` on a raw datetime
26+
dimension, which also disagreed with the in-memory `applyInMemoryAggregation`
27+
fallback — that one consumes already-formatted `find()` rows, so the same
28+
dataset changed key type depending on which path served it.
29+
30+
Which columns hold an instant is now recorded while the statement is built,
31+
because that is the only point where a column name and its meaning are both
32+
known: a `min()` lands under its alias and never under the field name, while a
33+
date-BUCKETED column lands under the field name but holds a label (`'2026-01'`)
34+
rather than an instant. Matching on names afterwards gets both backwards.
35+
36+
`distinct()` additionally re-deduplicates after presenting: SQL `DISTINCT`
37+
compares STORED values, and one SQLite datetime column holds both INTEGER and
38+
TEXT forms, so two rows recording the same instant survived as two and then
39+
presented identically. It has no in-repo callers today; this keeps it honest
40+
rather than leaving a second convention in the driver.
41+
42+
**`cross-object-rebucket` was fixed alongside it, because presenting min/max
43+
correctly is what exposed it.** `recombine()` coerced every operand with
44+
`Number()`, which silently depended on receiving an epoch: handed the ISO string
45+
the driver now returns it produced `NaN`, and on Postgres/MySQL (where knex
46+
returns a `Date`) it had always flattened the value back to an epoch integer one
47+
layer above the driver. `min`/`max` now order by the instant and return the
48+
winning value in the shape it arrived in; `sum`/`count` stay numeric.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(approvals): an approval action is recorded against the authenticated caller, never a body field (#3800)
7+
8+
Every mutating approvals entrypoint takes an `actorId`, and the REST routes
9+
filled it from `body.actorId ?? body.actor_id ?? context.userId` — so the body
10+
won. The service then authorized *that value*: `pending_approvers.includes(
11+
input.actorId)` for a decision, `submitter_id === actorId` for a recall. It never
12+
checked that the value named the caller.
13+
14+
So any authenticated user could POST `{"actorId": "<someone else>"}` and have
15+
that person's approval recorded, the request finalized, and the owning flow run
16+
resumed down the `approve` edge — or name a request's submitter and recall it.
17+
With `api.requireAuth` unset the anonymous-deny never fires either, so an
18+
unauthenticated request could do the same.
19+
20+
#3783 drew this line for the *data-write* identity and called the audit-row half
21+
"tolerable". It was not: the same unchecked string was the authorization key, so
22+
naming someone else was not a mislabelled audit row, it was how you got through
23+
the door.
24+
25+
The actor is now resolved server-side (`ApprovalService.resolveActor`) on all
26+
nine entrypoints — `decide` / `decideNode`, `recall`, `sendBack`, `resubmit`,
27+
`reassign`, `remind`, `requestInfo`, `comment`.
28+
29+
**The rule is not "`actorId` must equal `context.userId`."** A slot can
30+
legitimately be keyed by something else: the approver resolver stores the
31+
`type:value` literal when a graph lookup finds no holders, and the Console picks
32+
from the caller's own identity list — user id, email, or `role:<r>`. The rule is
33+
**"the actor must be an identity the server can prove belongs to the caller"**:
34+
35+
- A **system** context keeps its explicit actor. The SLA sweep's reserved
36+
`system:sla` sentinel and the ADR-0043 action link — whose single-use hashed
37+
token binds exactly one approver — are unchanged. They are the only callers
38+
holding a trustworthy actor with no session behind them.
39+
- A caller with **no identity at all** is now refused. This is the anonymous case
40+
above.
41+
- **No `actorId`, or one naming the caller**, resolves to the caller. This is the
42+
common path and what the Console already sends.
43+
- **Any other value** is accepted only when the server can prove the caller holds
44+
it — `position:<p>` / `role:<p>` against the positions on the resolved authz
45+
context, or the caller's own email (one lazy `sys_user` read, taken only when
46+
nothing cheaper matched). Otherwise `FORBIDDEN`.
47+
48+
REST still forwards the body value; it is now a *hint* the service validates,
49+
which is what keeps the email and `type:value` slot cases working.
50+
51+
**Upgrade note.** A client that deliberately sent another user's `actorId` now
52+
gets `403 FORBIDDEN` instead of silently succeeding. Send the action as the
53+
acting user's own session — the field can be omitted entirely, and the caller is
54+
used. Server-to-server callers that legitimately act for someone else should
55+
present a system context, as the SLA sweep and the action link already do.
56+
57+
This also makes two existing claims true that were previously aspirational: the
58+
approval object's declared actions say "`actorId` defaults to the caller
59+
server-side… the service remains the authority on who may act", and
60+
`attachViewers` documents `can_act` as mirroring "the exact authorization the
61+
decision methods enforce".
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
fix(approvals): the status mirror names the human who caused the transition (#3783)
7+
8+
When an approval moves, the service writes the new status onto the business
9+
record (`approvalStatusField`). That write is what fires the record-change flows
10+
bound to that object — so it is the seam "when the invoice is approved, do X"
11+
runs through. It presented a bare `{ isSystem: true }` context with **no
12+
`userId`**, at six call sites that each know exactly who acted: a submitter
13+
submitting, an approver approving, rejecting, sending back, recalling.
14+
15+
Combined with #3760 — which stopped letting a `runAs:'user'` run with no trigger
16+
user touch data — that identity gap made the most natural approvals automation
17+
there is unwritable in its obvious form. The cascade inherited no user, so its
18+
data nodes were refused, and the author's only way forward was to declare
19+
`runAs: 'system'` and take blanket elevation for a case where a perfectly good
20+
scoped identity existed at the call site all along.
21+
22+
The mirror now carries the acting user. It stays `isSystem` — the record is
23+
normally locked while its approval is live, so only a platform write can land the
24+
status — because elevation and anonymity are separate choices, and this write
25+
only ever needed the first. Cascades now run as the deciding user with RLS
26+
enforced.
27+
28+
- **The identity is the authenticated principal, never the request body's
29+
`actorId`.** `actorId` arrives from the caller (`body.actorId ?? context.userId`)
30+
and is only checked against the pending approver slate, never against the
31+
caller. That is tolerable on an audit row; promoting it to the identity of an
32+
RLS-scoped write would have turned a mislabelled audit trail into identity
33+
spoofing.
34+
- **Approval-by-email-link is attributed too.** ADR-0043 action links carry no
35+
session, so they used to decide as pure system. The single-use hashed token
36+
binds exactly one approver and is re-checked against the live slate at
37+
redemption — that is an authentication — so the redeemed decision now presents
38+
that approver, and an emailed approval cascades identically to one made in the
39+
UI.
40+
- **The two machine-driven transitions stay user-less on purpose**: the SLA
41+
escalation's auto-decision and the dead-run sweep. `system:sla` and
42+
`system:dead-run` are reserved audit actors, not users, and presenting one as a
43+
user would put a non-user in `updated_by` and in every downstream flow's
44+
identity. A flow that wants to react to those declares `runAs:'system'` — the
45+
honest answer, and now a deliberate one rather than an artefact.
46+
- **Attribution only — the write is not newly org-scoped.** On an
47+
ExecutionContext `tenantId` is a driver-scoping knob, not attribution
48+
(ObjectQL turns it into a tenant predicate), so passing the request's org would
49+
have silently no-op'd the mirror on a record whose org differs. The automation
50+
engine already back-fills a run's `tenantId` from the resolved user's grants.
51+
52+
**Visible change:** the mirrored record's `updated_by` now names the acting user
53+
instead of retaining its previous value — ObjectQL's audit stamping is gated on
54+
the write context's `userId` alone, and `isSystem` buys no exemption. That is the
55+
attribution this fix is for: the approver who set the record to `approved` is now
56+
its last modifier.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
---
3+
4+
test(service-storage): give the attachment read-visibility harness real Filter Protocol semantics
5+
6+
Test-only — releases nothing.
7+
8+
`attachment-read-visibility.test.ts` faked its engine with a matcher that
9+
understood implicit equality and `$in` and nothing else: no `$and`, no `$or`,
10+
no `$not`. Every assertion in the file could therefore only check the *shape*
11+
of the filter `computeParentVisibilityFilter` emits, never the rows that filter
12+
selects — a poor bargain for a predicate whose whole job is narrowing.
13+
14+
Changes:
15+
16+
- The harness matcher now implements the protocol for real, mirroring
17+
`driver-memory/memory-matcher.ts` and `formula/matches-filter.ts`: every key
18+
in a filter object ANDs, `$or` ORs its branches, and a branch's own contents
19+
still AND. Operators it does not implement now **throw** instead of being
20+
ignored — a silently under-implemented test double is the failure mode this
21+
change exists to close.
22+
- A new case asserts the **rows** a multi-parent-type scope returns, not just
23+
its shape: rows whose discriminator matches a branch but whose id is absent
24+
from that branch's id list (including one that borrows the sibling branch's
25+
id) are excluded, so the per-branch pairing itself is pinned.
26+
- A conformance block pins the harness matcher against the same 2x2 fixture and
27+
expectations as `memory-matcher-or-semantics.test.ts`,
28+
`matches-filter-or-semantics.test.ts` and `sql-driver-or-filter.test.ts`, so
29+
the four cannot drift apart, plus a case proving the matcher actually
30+
distinguishes a correctly paired scope from a widened one.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): finish the `--json` truncation fix — every command, and the second document it was hiding (#3780 follow-up)
6+
7+
#3780 routed three commands through `emitJson`. The other ~100 emission sites
8+
still wrote machine output with `console.log`, which on a **pipe** is cut off
9+
at one 64 KiB buffer when the command exits right after: Node buffers pipe
10+
writes asynchronously and the exit tears the process down mid-drain. It is
11+
invisible to whoever writes it — stdout to a TTY is synchronous, so every
12+
interactive run looks perfect while every scripted consumer, the only audience
13+
`--json` has, gets invalid JSON.
14+
15+
The exit does not have to be an explicit `process.exit`. oclif ends failing
16+
commands with `handle()``Exit.exit()``process.exit()` and flushes
17+
nothing on that path (`flush()` runs only on `execute()`'s success path), so a
18+
plain `this.exit(1)` — or any thrown error — truncates identically. 73 of the
19+
104 sites had exactly that shape. Even `lint` was only half fixed: `--eval
20+
--json` writes a whole corpus report and was still on `console.log`.
21+
22+
All 104 sites now go through `emitJson`, `formatOutput`'s `json`/`yaml`
23+
branches through the same drain-aware write (`--format yaml` truncated too),
24+
and an ESLint rule keeps the pattern from growing back one command at a time.
25+
Control flow is untouched — a following `this.exit(1)` stays, it is simply
26+
safe once the buffer has drained. Output bytes are unchanged: roughly half the
27+
sites emitted compact and half indented, and each keeps whichever it had.
28+
29+
**Draining the write exposed a second defect underneath it.** Because
30+
`this.exit(1)` *throws*, a command whose body sits in one `try` unwinds its
31+
inner "report and stop" into the outer `catch`, which reports again — so
32+
`os validate --json` on a failing config printed **two** JSON documents, which
33+
is neither valid JSON nor valid JSONL. Truncation had been hiding the second
34+
one. Nine commands had this shape; their catch clauses now re-throw the exit
35+
signal (`isExitSignal`) instead of describing it as a failure.
36+
37+
Measured on `os validate --json` against a config with 900 schema errors,
38+
piped:
39+
40+
| | bytes | parses |
41+
|---|---:|---|
42+
| before | 131072 (exactly two buffers, cut mid-string) | no |
43+
| truncation fix alone | 1514711 (two documents) | no |
44+
| both fixes | 1514648 (one document, 900 errors) | **yes** |
45+
46+
Pinned end to end: a real command, a real pipe, a payload past several
47+
buffers — plus a control case asserting the `console.log` pattern it replaced
48+
genuinely truncates, so the gate cannot quietly stop testing anything.
49+
50+
Not covered: the ~30 human-facing `console.log` paths, which are unaffected,
51+
and `os serve` / `os dev` logging. This is deliberately not fixed by forcing
52+
stdout into blocking mode process-wide, which would be one line and cover
53+
everything — the same binary runs the dev server, and a blocking write to a
54+
pipe with a slow reader blocks the event loop, trading truncated JSON for a
55+
server that stalls on its own logs.

.changeset/console-1bb77aa24514.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `1bb77aa24514`. Frontend changes in this range:
6+
7+
- fix(flow-runner): honor a screen field's `visibleWhen` — render and validation (framework#3528) (#2899)
8+
- fix(i18n): unconditional Chinese in the chatbot confirm card and field inspector (#2884, #2885) (#2900)
9+
- fix(actions): one precedence for `target`/`execute`, and stop mislabeling server-side `body` (#2896) (#2895)
10+
- fix(i18n): close the last three zh-branch gaps (#2871, part 3) (#2898)
11+
- feat(grid): compute all eleven spec column summary aggregations (#2890)
12+
- feat(console): make `delegated_admin` reachable and narrow both role pickers (framework#3697) (#2891)
13+
- fix(app-shell): localize the two DeclaredActionsBar strings that bypassed i18n (#2762 P0-3) (#2894)
14+
- fix(i18n): delete the four `pick({en,zh})` clones (#2871, part 2) (#2893)
15+
- fix(views): the five per-view-type configs speak the spec vocabulary (#2231 phase 3) (#2892)
16+
- feat(grid): gate list row Edit/Delete and bulk delete on the effective operation set (objectstack#3720) (#2889)
17+
- feat(charts): honor `ChartAxis.stepSize`, `ChartConfig.description` and `.height` (framework#3752) (#2888)
18+
- fix(i18n): retire four hand-rolled zh/en branches (#2871, part 1) (#2887)
19+
- feat(charts): ObjectChart honors the spec `ChartConfig` author shape (#2880) (#2883)
20+
- fix(hooks): stop calling translation hooks inside try/catch (#2879) (#2881)
21+
- fix(charts): a fieldless `count` aggregate keyed its value column `undefined` (framework#3701) (#2878)
22+
- fix(i18n): make `en` the complete source of truth for grid import and set-password (#2872 b/c) (#2877)
23+
- fix(auth): localize the ADR-0069 remediation gate and the auth split-panel (#2870) (#2875)
24+
- fix(metadata-admin): drop the SkillPreview "Required Permissions" panel (framework#3686) (#2874)
25+
- feat(console): scoped-invitation placement — invite straight into a unit and positions (framework ADR-0105 D8) (#2868)
26+
- fix(attachments): read the storage service's new error envelope so gated downloads keep their friendly copy (objectstack#3675) (#2869)
27+
- fix(fls): wire real per-caller FLS into import targets and grid columns, drop dead field.permissions shape (objectstack#3661) (#2866)
28+
- fix(page,field): consume the spec's type/label/maxLength keys (framework#1878 §3 recheck) (#2867)
29+
- fix(cloud-connection): localize the Cloud Connection panel (objectstack#3589 follow-up) (#2865)
30+
- fix(dashboard,charts): send widget query options to the server, order funnel stages by the pipeline (#2864)
31+
- fix(action): honor the spec disabled predicate on every action-rendering surface (#1885 follow-through) (#2863)
32+
33+
objectui range: `09c6a177bb4a...1bb77aa24514`

0 commit comments

Comments
 (0)