Skip to content

Commit 0c38fbc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-7823-session-token-internal
2 parents 3b1cb33 + ebb14ec commit 0c38fbc

864 files changed

Lines changed: 81057 additions & 6379 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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/core": patch
4+
"@objectstack/metadata-protocol": patch
5+
"@objectstack/runtime": patch
6+
"@objectstack/plugin-audit": patch
7+
"@objectstack/plugin-auth": patch
8+
---
9+
10+
fix(objectql): a by-id `update()`/`delete()` against a nonexistent record answers 404 `RECORD_NOT_FOUND` instead of a 400 from further down the pipeline (#7867)
11+
12+
Nothing on the action-body write path ever asked whether the target row existed.
13+
`ctx.api.object(name).update({ id, … })` reached `ObjectQL.update()`'s by-id
14+
branch through `buildSandboxApi``ObjectRepository`, and that branch had **no
15+
existence gate at all**: `engine.update()` on a ghost id was a silent no-op that
16+
resolved `null`, so the write ran on into validation, the driver and the hook
17+
chain and died on whichever complained first.
18+
19+
**Which one it died on varied with the object's declarations**, which is why the
20+
defect read as several unrelated bugs:
21+
22+
- a **hooked** object → `400` `HookConditionError`, from an `afterUpdate`
23+
condition reading `previous` on a row nobody read;
24+
- an **unhooked** object → `400` `VALIDATION_FAILED` "X is required", because
25+
with no prior row a PATCH is validated as if it were a whole record.
26+
27+
The 400 class varied; the missing 404 was the constant. Measured on one showcase
28+
stack, same id, same object, same second: `POST /actions/showcase_task/
29+
showcase_mark_done/<ghost>` answered 400 while `PATCH /data/showcase_task/
30+
<ghost>` answered 404. Both answer **404 `RECORD_NOT_FOUND`** now.
31+
32+
`delete()` had the same shape and was the worse of the two: with no gate it
33+
reported success for a row that was never there, so a typo'd id, an
34+
already-deleted row and a real deletion were indistinguishable.
35+
36+
**This is not a `previous`-binding bug.** `if (priorRecord) hookContext.previous
37+
= …` is correct and is untouched — ADR-0058 Addendum II / #4649 require that an
38+
absent row leave `previous` UNBOUND rather than fabricated. It was behaving
39+
correctly on a path that should never have been entered, so the fix removes the
40+
producer rather than specializing what it produced.
41+
42+
**Where the gate went, and why there.** At the engine, in the by-id branches of
43+
`update()` and `delete()` — the one point all three action-body write faces
44+
funnel through (`ctx.api.object()`, its context-less repo-facade fallback, and
45+
`ctx.engine.update()`). A repository-level gate would have closed one of the
46+
three and made `ql.update(o, { id })` and `ctx.api.object(o).update({ id })`
47+
answer one ghost id two different ways. Two sibling paths already gated
48+
correctly — `protocol.updateData`/`deleteData` (#4435) and `callData`'s ObjectQL
49+
fallback (#5138) — and all three now throw the **same** `recordNotFoundError`,
50+
which moved to `@objectstack/core` so the engine can reach it without importing
51+
`@objectstack/metadata-protocol` (forbidden in the `/core` closure by ADR-0076
52+
D2's boundary ratchet). `@objectstack/metadata-protocol` re-exports it unchanged.
53+
54+
Existence is asked with a pre-write read, never off the write's own result:
55+
`IDataDriver.update` declares no not-found signal, and the engine's post-write
56+
readback is `null` for a second reason (a write that moves the row out of the
57+
caller's row scope), so reading either would answer 404 to a write that landed.
58+
59+
**Behaviour change worth knowing about — the by-id prior-row read is now
60+
unconditional.** #5284 (update) and #5929 (delete) had narrowed it to "does
61+
anything CONSUME the prior row?", skipping the read for objects with no hook, no
62+
prior-reading validation rule and no roll-up. Existence is a consumer that
63+
demand list never enumerated and the one consumer every by-id write has, and no
64+
cheaper question answers it — so the skip and the gate are mutually exclusive.
65+
The measured cost is small: #5929's own record enumerates the global hook
66+
registrants (plugin-sharing, service-storage, plugin-auth, plugin-audit), so on
67+
any kernel that loads them the demand was already true for every object and the
68+
narrowing skipped nothing. The read is genuinely new only for a bare
69+
`@objectstack/objectql/core` embedder — which is buying a 404 it did not have.
70+
71+
Three read-count pins measured the old skip and now measure the read, each
72+
recording what changed and why at its own site: #5284's and #5929's in
73+
`packages/objectql`, and #5860's `sys_job_queue` case in `@objectstack/plugin-audit`.
74+
The DISPATCH half all three are actually about — the per-object `hasHooksFor`
75+
question, the `excludeObjects` subtraction, and the retired
76+
`sys_fetch_previous_*` builtins — is untouched and still pinned.
77+
78+
One further case encoded the old silent no-op as correct: `@objectstack/plugin-auth`'s
79+
#5941 last-admin-guard test deleted a `sys_account` id that was never seeded and
80+
asserted it RESOLVED, to show the guard does not write-guard that object. It now
81+
deletes a REAL row — which states the same thing more strongly — and separately
82+
pins that a ghost id there is refused by the ENGINE rather than by the guard.
83+
84+
**Scope.** By-id only. A `multi: true` predicate write matching zero rows still
85+
resolves "0 rows affected" — the same line both sibling paths draw.
86+
87+
`@objectstack/runtime`: the sandbox error passthrough now also carries `status`
88+
alongside `code` and `fields`, so an error that names its own HTTP status keeps
89+
it across the QuickJS boundary. Without it the action surface answered the right
90+
diagnosis at the wrong status (`{ code: 'RECORD_NOT_FOUND', httpStatus: 400 }`);
91+
`domains/actions.ts` already honoured `.status` first — the number simply never
92+
arrived. A permission refusal thrown inside a body likewise keeps its 403 now
93+
instead of flattening to 400.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): an action may no longer pair `confirmText` with a non-empty `params` (#7428)
6+
7+
**Acceptance narrowing — this refuses metadata that parsed before.** An action
8+
declaring `confirmText` beside a non-empty `params` array shows the user **two
9+
sequential dialogs for one decision**: the console action runner awaits the
10+
confirm, *then* the param prompt, so the first dialog already reads as "the
11+
action ran" while nothing has been sent yet.
12+
13+
The maintainer's 2026-08-10 ruling on #7278 settled the shape: carry the confirm
14+
question in the action's top-level `description` — which the param dialog renders
15+
under its title — and drop `confirmText`. One condition, one wording, one dialog.
16+
17+
Two PRs repaired the sites that shipped this (#7592 for `plugin-approvals`,
18+
#7827 for the fourteen in `platform-objects`). Repairing instances does not stop
19+
the next one being written, which is what this refusal is for. It ships as a
20+
**refusal rather than a warning** because the in-repo `ActionSchema` census is
21+
now **0** — nothing legal breaks — and a warning that fires on every build of an
22+
untouched project is a check nobody reads.
23+
24+
**Migrating.** Move the sentence, do not delete it:
25+
26+
```diff
27+
defineAction({
28+
name: 'ban_user',
29+
label: 'Ban User',
30+
- confirmText: 'Ban this user? They will be signed out until unbanned.',
31+
+ description: 'Ban this user? They will be signed out until unbanned.',
32+
params: [{ name: 'reason', label: 'Reason', type: 'textarea' }],
33+
})
34+
```
35+
36+
Not `ai.description` — that is the LLM-facing tool contract (≥40 chars, required
37+
when `ai.exposed`), and putting the question there arms a tool description while
38+
the dialog falls back to its generic line.
39+
40+
**What is deliberately NOT refused:**
41+
42+
- **`confirmText` on a param-LESS action** stays correct and untouched — there is
43+
no second dialog to fold the question into, and stripping it would delete the
44+
only warning the user ever sees.
45+
- **`confirmText` beside an empty `params: []`** — nothing is collected, so no
46+
second dialog opens.
47+
- **A view's `bulkActionDefs`.** `BulkActionDefSchema` is a separate schema on
48+
which the pair is *intended*: its params are inputs collected once before the
49+
run, `confirmText` sits above the affected-record summary, and a `required`
50+
param blocks that same dialog's Confirm button — one dialog, so there is
51+
nothing to collapse. The guard lives on `ActionSchema`'s refinement chain and
52+
is structurally incapable of reaching it; a pinning test asserts the bulk
53+
pairing still parses, so a future widening of the guard goes red rather than
54+
landing on correct declarations.
55+
- **Requiring `description` whenever `params` is present.** Forbidding the pair
56+
is the narrowest guard with measured pull behind it; the wider demand has no
57+
measured failure behind it and would be its own decision.
58+
59+
`InlineActionSchema` is likewise unaffected — it picks fields from the shared
60+
factory rather than deriving from this refinement chain, and it does not pick
61+
`description`, so the remedy has no slot on that surface yet.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): `actionLooksDestructive` classifies on declared semantics only (#7828)
6+
7+
`actionLooksDestructive` (the classifier behind the MCP `list_actions` tool's
8+
`requiresConfirmation` field) treated the mere presence of `confirmText` — UI
9+
dialog copy — as an AI-facing destructiveness signal. #7278/#7309 are actively
10+
migrating authors away from pairing `confirmText` with `params`-bearing actions
11+
(the confirm question now rides `description` instead), so the heuristic's
12+
input was being withdrawn by design: measured on #7309's branch, 6 of its 14
13+
migrated identity actions flipped from destructive to not-destructive the
14+
moment their `confirmText` was dropped, because none of them declares
15+
`mode: 'delete'` or `variant: 'danger'` to fall back on.
16+
17+
Maintainer ruling (issue #7828, Option A): drop the `confirmText` leg.
18+
`mode === 'delete' || variant === 'danger'` remain the signal — closed,
19+
declared enumerations an author sets on purpose, not UI copy a heuristic
20+
was never meant to read as a safety property.
21+
22+
This path is gated dead for every action shipped today (all 14 identity
23+
actions are `sys_*`, `type: 'api'`, and none declares `ai.exposed: true`, so
24+
none reaches the MCP `listActions` bridge that calls this classifier) — so
25+
the change has no observable effect on any request a caller can make right
26+
now. It closes the gap before a future `ai.exposed`, non-`sys_*` action
27+
carrying only `confirmText` would have had its classification silently flip.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/types": patch
4+
"@objectstack/rest": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
The ADR-0114 D3 mapper (Zod issue codes → the closed `FieldErrorCode` catalog) is now
9+
`zodIssuesToFields`, exported from `@objectstack/spec` (`@objectstack/spec/api`), and it is
10+
the ONE implementation of D3's table in the repo (#8124).
11+
12+
Why: `fields[].code` is declared as a closed catalog (`FieldErrorCode`, ADR-0114 D2), but
13+
`@objectstack/types`' `fieldsFromZodIssues` — the helper the runtime `/analytics`,
14+
`/notifications` and `/automation` entry refusals emit through — passed Zod's own issue
15+
codes through verbatim. A refusal carrying `unrecognized_keys` / `too_small` did not parse
16+
against the schema the protocol declares for it, and the same wire slot spoke two
17+
vocabularies depending on which route served it.
18+
19+
What changed on the wire (all three runtime domain routes):
20+
21+
- `fields[].code` values are now catalog members: `unrecognized_keys``unknown_field`,
22+
`too_small``min_length`/`min_value`/`min_items` (by origin), `too_big` → the `max_*`
23+
mirrors, enum misses → `invalid_option`, `custom` and any unmapped Zod code →
24+
`invalid_value`.
25+
- A rejection behind a `z.union` is expanded per #5014: the union's own entry is followed
26+
by the branch entries that explain it, so entry count is no longer issue count.
27+
- Two hand-spelled `unrecognized_keys` literals (the analytics `filters` hint and the
28+
automation toggle unknown-key refusal) now say `unknown_field`, the catalog member.
29+
30+
`@objectstack/rest` re-exports the shared implementation from `rest-server.ts` and its
31+
behavior is unchanged (its own mapper tests pin that); `fieldsFromZodIssues` keeps its
32+
signature (plus an optional trailing `input` that upgrades a missing required property from
33+
`invalid_type` to `required`, per the D3 table) and keeps the `'(body)'` spelling for
34+
root-level failures.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): gate the `/analytics/query` SQL echo on debug, as the contract has always declared (#8286)
6+
7+
`POST /api/v1/analytics/query` returned the executed statement to the caller in
8+
`data.sql` on every deployment, `NODE_ENV=production` included, with no debug
9+
flag requested and none available to request. The contract had declared the
10+
field debug-only since it was introduced — `AnalyticsResultResponseSchema`
11+
(`spec/api/analytics.zod.ts`) types it `optional()` and describes it as
12+
"Executed SQL (if debug enabled)" — but no implementation ever read a debug
13+
switch. This restores declared = enforced. **The contract is unchanged; the
14+
response now matches it.**
15+
16+
**What was disclosed.** More than table and column names. The echoed statement
17+
carries the compiled read scope, so it describes the SHAPE of the tenant
18+
isolation predicate: on the reported deployment it showed that `sys_user` is
19+
walled by an enumerated `"sys_user"."id" IN ($2, $3, …)` member list rather than
20+
by an `organization_id` comparison — that is, which column the wall is built on
21+
and how — plus the bound-parameter arity, which counts the caller's own
22+
organization's membership and hands a prober the exact query surface to work
23+
against.
24+
25+
**No wall was breached.** This is information disclosure and nothing more. The
26+
reporter ran the isolation probes on the same deployment and every one held:
27+
cross-tenant read answered 404, cross-tenant update and delete answered 403 at
28+
row-level security, a `filter`/`where` naming another organization came back
29+
empty, a batch write by foreign id answered per-row `PERMISSION_DENIED`, and the
30+
audit log and activity stream were partitioned cleanly. The wall works; it
31+
simply should not have been describing itself to callers.
32+
33+
**The gate is one gate.** It lives at the response-assembly seam —
34+
`AnalyticsService.query`, the single point every strategy's result leaves
35+
through — not on any one strategy. `NativeSQLStrategy` returns the statement it
36+
ran, `ObjectQLStrategy` renders a representative one, and the fallback delegate
37+
passes through whatever the service it delegates to minted (the in-memory
38+
analytics service always echoes); gating one of the three would have left the
39+
others serving. `queryDataset` reaches the same seam through `DatasetExecutor`,
40+
so dataset-backed dashboard and report responses inherit the verdict without a
41+
second gate to keep in step.
42+
43+
**The switch, and its default.** New `debugSql` option on
44+
`AnalyticsServicePlugin` (forwarded to `AnalyticsServiceConfig`). Unset means no
45+
host choice, which resolves to `NODE_ENV === 'development'` — and only that: an
46+
**unset** `NODE_ENV` counts as production and the echo stays off, matching how
47+
`os start`, `os serve` and `os doctor` already read that absence. Of the two ways
48+
to be wrong, disclosing on a production deployment whose operator forgot the
49+
variable is the dangerous one.
50+
51+
It is deliberately a HOST switch with no request field behind it: a
52+
caller-settable debug flag would let any tenant reopen the disclosure on demand,
53+
which is the shape of the defect rather than a fix for it. It is also
54+
deliberately separate from the plugin's existing `debug` option, which stays
55+
server-side log verbosity only — raising log level on a live deployment must not
56+
widen what travels to a tenant.
57+
58+
**Unaffected.** `POST /api/v1/analytics/sql` — the dedicated dry-run route that
59+
exists to hand back a statement — is not gated and behaves exactly as before; it
60+
is where an author debugging a widget should look. Rows, `fields`, `totals`,
61+
drill-through metadata, error envelopes and every gate on the query path are
62+
untouched, and no shipped consumer read the echo (the Studio console does not
63+
render it).
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/service-analytics": patch
5+
---
6+
7+
refactor(plugin-audit)!: retire `export` and `permission_change` from the `sys_audit_log` action enum — two declared actions nothing has ever written (#8147, #7675, ADR-0049/ADR-0087)
8+
9+
<!-- adr-0087: registered audit-log-action-enum-retired -->
10+
11+
**BREAKING** (shipped as `minor` under the launch-window lockstep convention).
12+
13+
`sys_audit_log.action` declared ten actions. Two of them named events this
14+
platform does not record, and has never recorded. Enumerating every
15+
`sys_audit_log` writer in the repo finds exactly two:
16+
17+
- `plugin-audit/src/audit-writers.ts` — the generic hook writer, whose
18+
`actionFor()` maps `afterInsert`/`afterUpdate`/`afterDelete` to
19+
`create`/`update`/`delete` and **nothing else**;
20+
- `plugin-auth/src/admin-import-users.ts` — the admin user-import run-level row.
21+
22+
Neither has ever emitted `export` or `permission_change`. The cost was not a
23+
dormant string: `sys_audit_log` ships **list views** filtered on those values and
24+
the platform dashboard ships **metric widgets** counting them, so an operator got
25+
a permanently empty "Permission Changes" tile and an Auth view whose filter could
26+
never match, while an auditor reading the enum believed the platform captured
27+
permission changes and data exports. That is false compliance on a compliance
28+
surface — the sharpest form of ADR-0049 declared-≠-enforced.
29+
30+
Maintainer ruling 2026-08-12 (#7675) split the finding in two: build the cheap
31+
writers (`login`/`logout` in #8144, `config_change` in #8145) and retire the enum
32+
values with no feature behind them. 原则记录:空 widget + 永远查不到东西的过滤器
33+
是可见产品缺陷;审计面宁窄勿谎。
34+
35+
### Migration: FROM → TO
36+
37+
| Wrote | Write instead |
38+
|:--|:--|
39+
| a filter, saved query or dashboard on `action = 'permission_change'` | filter the permission objects' own `create` / `update` rows by `object_name` — a grant or binding write is an ordinary record write and the generic writer already ledgers it |
40+
| a filter, saved query or dashboard on `action = 'export'` | delete it — no export feature ever wrote an audit row, so it returned nothing on every deployment |
41+
| a `switch` / badge map with arms for either value | delete those arms; an exhaustive `switch` over the action type now fails to compile if they stay |
42+
43+
Every such query returned an empty result set before this change and returns the
44+
same empty result set after it. What changed is that the contract stops promising
45+
otherwise.
46+
47+
⚠️ **Existing rows are untouched and must stay untouched.** The enum is not
48+
enforced on this object — `validateRecord` skips `readonly` fields and every
49+
`sys_audit_log` field is `readonly: true` — so stored history parses and reads
50+
back exactly as written. Audit history is append-only; do not migrate or delete
51+
rows to satisfy a schema narrowing.
52+
53+
### Also in this change
54+
55+
- `auth_events` list view: filter narrowed to `['login', 'logout']`.
56+
- `config_changes` list view: `export` dropped from the filter.
57+
- `plugin-audit`'s generated translation bundles regenerated for all four locales.
58+
- ADR-0087 registration as the semantic migration `audit-log-action-enum-retired`
59+
(D3 step 17). An enum-VALUE retirement, so nothing lands in
60+
`RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are byte-identical by
61+
construction — no authorable key and no def changed.
62+
63+
### `import` is deliberately NOT retired
64+
65+
The 2026-08-12 ruling named `import` alongside the other two on the stated
66+
premise 无此 feature. That premise is measurably false and the value stays:
67+
`plugin-auth`'s admin user-import writes a real run-level row on every run
68+
(`action: 'import'`, `record_id: null`), pinned by case W4 of
69+
`packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts`. Retiring
70+
it would make the enum deny a value the platform writes — and silently, since
71+
the enum is unenforced here. Referred back for a maintainer ruling on #8147.

0 commit comments

Comments
 (0)