Skip to content

Commit 7e4c22a

Browse files
committed
Merge origin/main into claude/global-actions-unreachable-rw7hpk
Integrates #3987 (ADR-0110 D3 revised): the undeclared-action refusal has no opt-out — OS_ALLOW_UNDECLARED_ACTIONS is retired. The conflict was this branch's #3962 single-wrap adjustment inside the old valve test, which #3987 rewrote into "refuses regardless of the retired flag"; main's side taken verbatim, as the valve semantics the adjustment belonged to no longer exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
2 parents c4beecb + 347f460 commit 7e4c22a

55 files changed

Lines changed: 1526 additions & 369 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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
"@objectstack/plugin-security": minor
5+
---
6+
7+
fix(sharing)!: an edit-level share no longer grants delete (ADR-0111 D3, the verb boundary)
8+
9+
`update` and `delete` shared one `canEdit` gate, and `canEdit` accepts an
10+
`edit`-level share — so one "edit" grant silently conferred delete, the
11+
opposite error from the retired `full` level. A share widens *which rows* a
12+
principal reaches, never *which verbs* they may use (Salesforce Read/Write
13+
cannot delete; Dataverse `Delete` is a distinct privilege; Odoo splits
14+
`write`/`unlink`).
15+
16+
- `ISharingService.canDelete(object, recordId, context)` — ownership (widened
17+
by write DEPTH) or the `modifyAllRecords` super-user bypass ONLY; an `edit`
18+
or legacy `full` share does not confer it. `canEdit` is unchanged (the
19+
update gate, share included).
20+
- `SharingService.buildWriteFilter` takes a `verb` parameter: a bulk
21+
`delete({multi:true})` scopes to the owner/DEPTH set alone (no share
22+
widening), while a bulk `update` keeps it.
23+
- The sharing middleware routes `delete` through `canDelete` and logs a
24+
specific fail-closed reason on denial (ADR-0111 D10).
25+
- `/security/explain` consults `canDelete` for a `delete` operation, so the
26+
record-level explanation matches enforcement.
27+
28+
**Breaking**: a caller who could delete a record *only* through an edit-level
29+
share (and holds object-level delete CRUD) can no longer delete it — delete now
30+
requires ownership, write depth, or Modify All Data. No new delete access level
31+
is introduced; a future per-record delete grant would be a capability mask
32+
AND-ed with object CRUD, not a fourth share level.
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 records the #3796 endgame — all seven flow-node config aliases graduated into the protocol-17 conversion layer and the `readAliasedConfig` shim is deleted; new aliases go straight to conversion entries, never executor shims — releases nothing.

‎.changeset/console-a136322f8723.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `a136322f8723`. Frontend changes in this range:
6+
7+
- fix(app-shell)!: a modal action is client-side only — drop the server fallthrough (objectstack#3959) (#2973)
8+
- fix(app-shell)!: the server-action URL identifies an action by `name`, not `target` (ADR-0110 D1) (#2970)
9+
- fix(form): a server rejection that names fields now marks those fields (#2966)
10+
- fix(actions): one source for the /actions envelope rule, and redirectUrl finally works (#2967)
11+
- fix(actions): apply the ADR-0066 D4 capability gate on every action surface (framework#3923) (#2965)
12+
- fix(detail): multi-value lookup is selectable in inline edit (#2957)
13+
- fix(actions): a failed server action no longer reports as success (green toast) (#2963)
14+
- fix(fields): the criteria builder stops calling an empty criteria "All records" (#2962)
15+
- feat(report): carry a report's `order` into the dataset selection (framework#3916) (#2964)
16+
- feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890) (#2948)
17+
18+
objectui range: `4a4829d0ef39...a136322f8723`
19+
20+
**Release-critical for v17.** The previous pin (`4a4829d0ef39`) predates the
21+
ADR-0110 D1 client fix, so the console it builds still posts `action.target`
22+
to `/api/v1/actions/:object/:action`. Against a v17 server — which resolves
23+
the declaration by `name` and refuses an unresolvable one (D3) — every
24+
target-bound script action would return 404 from the shipped console. The
25+
lockstep the ADR called for is enforced by THIS pin, not by merging the
26+
objectui PR, so v17 must not ship without this bump.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/runtime': major
3+
---
4+
5+
**[ADR-0110 D3, revised] The undeclared-action refusal has no opt-out —
6+
`OS_ALLOW_UNDECLARED_ACTIONS` is removed before 17 ships.**
7+
8+
D3 as accepted refused an undeclared handler but shipped
9+
`OS_ALLOW_UNDECLARED_ACTIONS=1` as a migration valve that ran it anyway,
10+
"slated for removal in 18". Removed now, for two reasons:
11+
12+
- **It contradicts the ruling it accompanies.** A flag that executes an
13+
ungoverned, system-elevated handler *is* the fail-open D3 closes. ADR-0049's
14+
trichotomy has no "enforced unless a flag says otherwise" state.
15+
- **It had no observed users.** A reconciliation sweep across the platform
16+
packages, every example and every plugin found the only `engine.registerAction`
17+
call sites are `app-todo`'s eight, all declared. The valve would have shipped
18+
a documented way to reopen the gate for a population nobody has ever seen.
19+
20+
What it was buying is covered without it: the app still boots, every declared
21+
action still works, D5's boot inventory names each offender at startup, and the
22+
404 names the `defineAction` to add. Migration costs a code change rather than
23+
an env var — the correct price for reopening an authorization gate.
24+
25+
Setting the retired variable has no effect; a regression test pins that, so a
26+
stale deployment script fails loudly rather than silently re-opening the gate.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(spec,automation): graduate the seven flow-node config key aliases into the conversion layer — the `readAliasedConfig` shim retires with them (#3796)
7+
8+
`FlowNodeSchema.config` is an unconstrained record, so the executors were the
9+
only statement of which config key is canonical — and seven deprecated aliases
10+
lived there as tolerance the spec never declared: one behind the
11+
`readAliasedConfig` deprecation shim (warned, ledgered), six as open-coded
12+
`??` fallbacks (no warning, no ledger, no retirement path). All seven now
13+
graduate into the ADR-0087 D2 conversion layer as protocol-17 **live-window**
14+
entries: a stored flow authored with an alias is rewritten to the canonical
15+
key at load — `defineStack` / `validate` / `lint` and the
16+
`AutomationEngine.registerFlow` rehydration seam alike — with a structured
17+
`ConversionNotice` per rewrite, and the executors read the canonical keys
18+
only. The shim (`service-automation/src/builtin/config-aliases.ts`) is empty
19+
and deleted.
20+
21+
FROM → TO (per node type; conversion entry in parentheses):
22+
23+
- `get_record`/`create_record`/`update_record`/`delete_record`:
24+
`config.object` → `config.objectName` (`flow-node-crud-object-alias`)
25+
- `notify`: `config.to` → `config.recipients`, `config.subject` →
26+
`config.title`, `config.body` → `config.message`, `config.url` →
27+
`config.actionUrl` (`flow-node-notify-config-aliases`)
28+
- `script`: `config.functionName` → `config.function`, `config.input` →
29+
`config.inputs` (`flow-node-script-config-aliases`)
30+
31+
One-line fix: rename the key in your flow source — values are unchanged; `os
32+
migrate meta --from 16` rewrites all seven mechanically. Until then nothing
33+
breaks: the protocol-17 loader accepts and converts the old shape (window
34+
retires in 18).
35+
36+
`actionUrl` (not `url`) is the deliberate canonical of its pair, resolving a
37+
contradiction where the notify descriptor documented `url` as canonical while
38+
the executor, tests, and examples preferred `actionUrl`: the whole downstream
39+
chain already uses that name (`sys_notification.action_url`, the
40+
channel-dispatch contract, the REST notification read model), and `url`
41+
elsewhere in the platform means "HTTP endpoint to call" (`http` node,
42+
webhooks) — a different concept from this in-app click-through target. The
43+
executor precedence already put `actionUrl` first, so the choice is
44+
behaviour-preserving; the `notify` descriptor's `configSchema` now documents
45+
`actionUrl`.
46+
47+
Callers that hand a node config **directly** to an executor (bypassing
48+
`registerFlow`) no longer get alias resolution — build the config with the
49+
canonical keys.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(security)!: a disabled RLS policy no longer grants — found by re-verifying the ledger's security subset (#3896 follow-up)
7+
8+
**The fix.** `RowLevelSecurityPolicySchema.enabled` promises, verbatim: *"Disabled
9+
policies are not evaluated."* Nothing read it — not the collection site, not the
10+
projection round-trip, not the compiler. Because applicable policies OR-combine
11+
(any match allows access), a policy an admin switched off **kept contributing its
12+
grant**: disabling a too-permissive policy silently changed nothing. That is the
13+
#3896 shape — a documented security control whose real behaviour is wider than
14+
its contract — one layer up, on RLS instead of sharing rules.
15+
16+
`getApplicablePolicies` now excludes `enabled === false` before any matching, at
17+
the single choke point both the find path and the analytics path flow through —
18+
the same place, and the same ADR-0049 enforce-or-remove resolution, as the
19+
formerly-unenforced `positions` domain. Exact `=== false` on purpose: the schema
20+
defaults `enabled` to true and projection rows may omit the key, so absent stays
21+
active. Four tests pin both directions. Access-narrowing only: no policy grants
22+
MORE after this change, and nothing in-repo authors `enabled: false`.
23+
24+
**The audit that found it.** All 44 entries of the liveness ledger's security
25+
subset (`permission` 33, `position` 4, `object` sharing/access 7) were
26+
call-graph-closed by hand and stamped `verifiedAt: 2026-07-30` — the subset's
27+
first-ever re-verification (previously 4 dated entries repo-wide, and the last
28+
sweep that cited preview renderers went 10-for-13 wrong). Beyond `enabled`:
29+
30+
- `rowLevelSecurity.priority` → **dead + authorWarn**. Not merely unimplemented:
31+
policies OR-combine (the schema's own describe says most-permissive-wins), so
32+
the promised "conflict resolution" semantics cannot exist. A REMOVE candidate
33+
per the #3715/#3950 precedent while the v17 breaking window is open.
34+
- `rowLevelSecurity.label` / `description` / `tags` → dead (benign display —
35+
no consumer in either repo; deliberately not authorWarn'd).
36+
- `tabPermissions` was UNDERSTATED: the note said only `'hidden'` is read, but
37+
hono's rank merge reads all four visibility values across resolved sets, and
38+
the `me-apps-and-everyone-baseline` dogfood test exercises it. Evidence
39+
upgraded; noted as a proof-binding candidate.
40+
- `allowExport` re-verified TRUE against the suspicion that it was
41+
projection-only: the export route carries its own caller-level 403 gate
42+
(`enforceExportPermission`), fail-closed when the security service cannot
43+
answer, separate from the object-level 405.
44+
- `allowTransfer/Restore/Purge` notes re-confirmed accurate (M2 operations still
45+
unshipped; the RBAC gates are pre-mapped fail-closed).
46+
- `object.ownership` evidence had rotted (line drift) — refreshed; six other
47+
object-level security entries re-cited and stamped.
48+
49+
No other runtime behaviour changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
test(showcase): dogfood specimens for the inline record picker (#3405) and the record-backed approver kinds (#3508) — releases nothing. Both live in `@objectstack/example-showcase`, which is private.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-analytics": patch
4+
---
5+
6+
feat(spec): promote the temporal storage hooks onto the IDataDriver contract (ADR-0053 D-A2)
7+
8+
`temporalFilterValue` and `temporalFilterColumnSql` — the pair that closed
9+
#3912's storage-form drift — were duck-typed: analytics probed
10+
`typeof driver.x === 'function'` against a locally-invented interface, and
11+
nothing at the type level said a driver must implement both or neither. The
12+
lesson of #3912 is precisely that coercing the comparand without normalising
13+
the column reintroduces half the bug, so a driver implementing one hook alone
14+
would silently regress.
15+
16+
Both are now optional members of `IDataDriver`
17+
(`@objectstack/spec/contracts`), documented as a pair with "absent = identity"
18+
semantics for drivers whose storage form is the wire form (memory, mongo).
19+
`SqlDriver implements IDataDriver`, so its signatures are compile-checked from
20+
here on; analytics derives its driver seam by `Pick`-ing the contract instead
21+
of a local duck type. Runtime `typeof` guards remain — that is the correct way
22+
to consume an optional contract member — but the shape they guard now has one
23+
authoritative definition.
24+
25+
No runtime behaviour change. ADR-0053 D-A2 is recorded as resolved.

‎.github/workflows/ci.yml‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,126 @@ jobs:
196196
path: .turbo/cache
197197
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
198198

199+
200+
# ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ─────
201+
#
202+
# The datetime storage work (#3912/#3942) was verified against real servers
203+
# because every one of its bugs was invisible on all-UTC infrastructure: a
204+
# zone-naive write resolved in the SERVER's zone on Postgres, mysql2 rendered
205+
# a Date in the HOST's zone, and a bare YYYY-MM-DD comparand meant a
206+
# different midnight per dialect. The committed suites are opt-in
207+
# (OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL) and skip without a server, so
208+
# without this job they would never run in CI and the seam could regress
209+
# silently — D-A3's exact concern.
210+
#
211+
# Every timezone here is deliberately DIFFERENT: servers at +08:00, the Node
212+
# process at America/New_York, assertions in UTC. Both suites assert they
213+
# are pointed at a non-UTC server, so a mis-provisioned service fails loudly
214+
# instead of letting the job pass vacuously.
215+
temporal-conformance:
216+
name: Temporal Conformance (live PG + MySQL)
217+
needs: filter
218+
if: needs.filter.outputs.core == 'true'
219+
runs-on: ubuntu-latest
220+
permissions:
221+
contents: read
222+
223+
services:
224+
postgres:
225+
image: postgres:16
226+
env:
227+
POSTGRES_PASSWORD: postgres
228+
ports:
229+
- 5432:5432
230+
options: >-
231+
--health-cmd="pg_isready -U postgres"
232+
--health-interval=5s
233+
--health-timeout=5s
234+
--health-retries=12
235+
mysql:
236+
# Real MySQL 8.0. The hands-on verification of #3942 ran on MariaDB
237+
# 10.11 — the stricter dialect for datetime literals — so this job is
238+
# the other half of the compatibility claim. `-h 127.0.0.1` forces the
239+
# ping over TCP: the image's init phase runs mysqld with networking
240+
# disabled, so a socket ping would report healthy before init finishes.
241+
image: mysql:8.0
242+
env:
243+
MYSQL_ROOT_PASSWORD: root
244+
MYSQL_DATABASE: conformance
245+
ports:
246+
- 3306:3306
247+
options: >-
248+
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
249+
--health-interval=5s
250+
--health-timeout=5s
251+
--health-retries=24
252+
253+
steps:
254+
- name: Checkout repository
255+
uses: actions/checkout@v7
256+
257+
# Service containers cannot override the image command, so the non-UTC
258+
# zones are set post-start through each server's own mechanism. Echoed
259+
# back so a provisioning failure is visible in the log — though the
260+
# suites' own non-UTC guards are the real gate.
261+
- name: Point both servers at a non-UTC timezone
262+
run: |
263+
docker exec ${{ job.services.postgres.id }} psql -U postgres -c "ALTER SYSTEM SET timezone='Asia/Shanghai'"
264+
docker exec ${{ job.services.postgres.id }} psql -U postgres -c "SELECT pg_reload_conf()"
265+
docker exec ${{ job.services.postgres.id }} psql -U postgres -tAc "SHOW timezone"
266+
docker exec ${{ job.services.mysql.id }} mysql -uroot -proot -e "SET GLOBAL time_zone = '+08:00'"
267+
docker exec ${{ job.services.mysql.id }} mysql -uroot -proot -N -e "SELECT @@global.time_zone"
268+
269+
- name: Setup Node.js
270+
uses: actions/setup-node@v7
271+
with:
272+
node-version: '22'
273+
274+
- name: Enable Corepack
275+
run: corepack enable
276+
277+
- name: Get pnpm store directory
278+
shell: bash
279+
run: |
280+
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
281+
282+
- name: Setup pnpm cache
283+
uses: actions/cache@v6
284+
with:
285+
path: ${{ env.STORE_PATH }}
286+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
287+
restore-keys: |
288+
${{ runner.os }}-pnpm-store-v3-
289+
290+
# Restore-only (same policy as every other job); falls back to the Test
291+
# Core namespace because that job builds a superset of what this one
292+
# needs and its cache is seeded from main.
293+
- name: Restore Turbo cache
294+
uses: actions/cache/restore@v6
295+
with:
296+
path: .turbo/cache
297+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
298+
restore-keys: |
299+
${{ runner.os }}-turbo-${{ github.job }}-
300+
${{ runner.os }}-turbo-test-${{ github.ref_name }}-
301+
${{ runner.os }}-turbo-test-
302+
303+
- name: Install dependencies
304+
run: pnpm install --frozen-lockfile
305+
306+
- name: Build driver-sql and its dependencies
307+
run: pnpm exec turbo run build --filter=@objectstack/driver-sql... --concurrency=4
308+
309+
# The whole driver-sql suite runs under the skewed process zone — not
310+
# just the live-server files — so a TZ-sensitive assumption anywhere in
311+
# the driver's tests fails here before it can ship.
312+
- name: Run driver-sql suite against both live servers
313+
env:
314+
TZ: America/New_York
315+
OS_TEST_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres
316+
OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance
317+
run: pnpm --filter @objectstack/driver-sql test
318+
199319
dogfood:
200320
# Sharded 2-way: the suite is ~60 independent test files, each booting its
201321
# own in-process app, and a single 4-vCPU runner needed ~7½ minutes for the

‎.objectui-sha‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4a4829d0ef3926e9b55757bfb1e369d6dca61ac2
1+
a136322f872314a74462ac7e7142f424e1124400

0 commit comments

Comments
 (0)