Skip to content

Commit db371bb

Browse files
committed
Merge origin/main — adopt #3664's unconditional cross-object guard
#3664 replaced `assertJoinedScopesEnforceable` with `assertNoCrossObjectReferences`, which rejects any cross-object reference regardless of read scope. That is strictly better than what this branch had: `engine.aggregate()` has no join and the SQL driver's aggregate emits none, so a dotted member was never merely "unscopeable" — it was silently wrong (one `(null)` bucket) or a hard error. Adopted as-is; this branch's narrower scope-conditional guard is gone. Made it derive its field set from `referencedFieldNames(cube, query)` instead of `execute()`'s built `groupBy`/`filter`, so `generateSql()` runs the IDENTICAL guard — the rendered SQL must not describe a query `execute()` would reject. Same field set either way, so `execute()`'s behaviour is unchanged. Also kept from this branch: the read scope rendered into `generateSql`'s WHERE (#3664 left rendering alone), and the `context` forwarded to the aggregate bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013st2KArjmLQSuVzpDS91jj
2 parents 6793097 + b04b458 commit db371bb

21 files changed

Lines changed: 729 additions & 94 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
CI-only: turbo cache for the Release job, console-dist cache keyed on the objectui pin, restore-only turbo caches on PRs (main seeds them), CodeQL off pull_request (main push + weekly schedule), spec coverage moved to a nightly workflow. Releases nothing.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654)
6+
7+
`engine.aggregate()` has no join — it never expands a lookup and the SQL driver's
8+
aggregate emits no `JOIN`. So a dotted dimension/measure like `account.region`
9+
reaching `ObjectQLStrategy` (the fallback NativeSQL declines: date-granularity
10+
bucketing, in-memory driver, federated objects) failed SILENTLY: the in-memory
11+
path bucketed every row under one `(null)` group and summed the whole table into
12+
it (a plausible number that is actually a mislabelled full-table total), and the
13+
native path errored on the unresolved column.
14+
15+
`ObjectQLStrategy` now rejects any cross-object reference outright, with a clear
16+
message, before the query reaches the engine. This generalizes the #3597 guard
17+
(which only rejected when the joined object carried a read scope, and skipped the
18+
check entirely when no read-scope provider was configured — so the silent
19+
`(null)` bucket still shipped on unsecured/in-memory setups) into an
20+
unconditional one, and subsumes it: a rejected query never loads the joined
21+
object, so there is nothing left unscoped.
22+
23+
Cross-object datasets are unaffected on `NativeSQLStrategy`, which hand-compiles
24+
the LEFT JOINs (and scopes each). This only changes the fallback path, turning a
25+
silent wrong answer into a loud, actionable error. Full lookup-traversal support
26+
in the aggregate path is left as follow-up (see #3654).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
feat(spec): publish `ISecurityService` — the `security` service surface becomes an enforced contract
8+
9+
The `security` service registers seven cross-package methods (`getReadFilter`,
10+
`getReadableFields`, `resolvePermissionSetNames`, `explain`, and the three
11+
audience-binding suggestion calls) but had no contract in
12+
`@objectstack/spec/contracts`. Consumers duck-typed it, and each one invented its
13+
own fallback for a missing method or an "empty" answer — with more consumers
14+
arriving, that is a drift surface.
15+
16+
`ISecurityService` now documents the surface, and both ends are typed against it
17+
so it is **enforced rather than declared**: `plugin-security` assigns its
18+
registration to `ISecurityService` (a renamed, dropped, or re-typed method fails
19+
that build), and the REST layer resolves the service as a `Partial<ISecurityService>`
20+
(so call sites must keep feature-detecting instead of assuming the full surface).
21+
22+
The contract makes explicit the one thing consumers cannot guess — that the
23+
methods do **not** share a failure convention:
24+
25+
- `getReadFilter` fails **CLOSED**: a resolution failure yields a deny filter
26+
matching zero rows, never `undefined`. `undefined` means "no row restriction",
27+
and nothing else.
28+
- `getReadableFields` fails **SOFT**: `undefined` means "no answer, use your own
29+
projection", while `[]` is authoritative and means "no field is readable" —
30+
opposite instructions that a consumer must not conflate.
31+
32+
Typing the producer immediately caught one real discrepancy, fixed here:
33+
`getReadFilter` declared `Promise<Record<string, unknown> | null | undefined>`
34+
while every return path yields a filter or `undefined` (`filter ?? undefined`
35+
normalizes the null away). The dead `| null` is removed, so "no restriction" has
36+
exactly one representation. Type-level only — no 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-only: extends the 2FA lockout dogfood cover (#3624 follow-up). No package changes, nothing to release.

.github/workflows/ci.yml

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,13 @@ jobs:
111111
restore-keys: |
112112
${{ runner.os }}-pnpm-store-v3-
113113
114-
- name: Setup Turbo cache
115-
uses: actions/cache@v6
114+
# Restore-only on PRs: PR-side saves (~5 turbo entries per push) churned
115+
# the repo's 10 GB Actions cache pool and evicted the main-branch seeds —
116+
# observed as sudden cold-cache spikes (Build Core 51s → 4m30s). PRs fall
117+
# back to main's entries via the prefix restore-keys; only main pushes
118+
# save (the "Save Turbo cache" step at the end of the job).
119+
- name: Restore Turbo cache
120+
uses: actions/cache/restore@v6
116121
with:
117122
path: .turbo/cache
118123
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
@@ -142,25 +147,25 @@ jobs:
142147
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
143148
run: pnpm turbo run test --affected --filter=!@objectstack/dogfood --concurrency=4
144149

145-
# Push to main: full run, but exclude spec's plain test task — the
146-
# coverage step below executes the exact same 250-file spec suite once,
147-
# with coverage. Previously the spec suite ran twice per push. Dogfood is
148-
# excluded for the same reason as the PR step: the Dogfood job runs it.
150+
# Push to main: full run. Spec's suite runs here plain (uninstrumented);
151+
# the coverage-instrumented pass moved to the nightly Spec Coverage
152+
# workflow (coverage-nightly.yml) — instrumentation added minutes to
153+
# every main push for a trend artifact that is consulted occasionally at
154+
# best. Dogfood is excluded for the same reason as the PR step: the
155+
# Dogfood job runs it.
149156
- name: Run all tests (push)
150157
if: github.event_name == 'push'
151-
run: pnpm turbo run test --filter=!@objectstack/spec --filter=!@objectstack/dogfood --concurrency=4
152-
153-
- name: Generate coverage report
154-
if: github.event_name == 'push'
155-
run: pnpm --filter @objectstack/spec test:coverage
156-
157-
- name: Upload coverage reports
158-
if: github.event_name == 'push'
159-
uses: actions/upload-artifact@v7
158+
run: pnpm turbo run test --filter=!@objectstack/dogfood --concurrency=4
159+
160+
# Seed the shared Turbo cache from main only (see the restore step
161+
# above). always(): keep the seed fresh even when a test fails, matching
162+
# the old actions/cache post-step behavior.
163+
- name: Save Turbo cache (main only)
164+
if: always() && github.event_name == 'push'
165+
uses: actions/cache/save@v6
160166
with:
161-
name: coverage-report
162-
path: packages/spec/coverage/
163-
retention-days: 30
167+
path: .turbo/cache
168+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
164169

165170
dogfood:
166171
# Sharded 2-way: the suite is ~60 independent test files, each booting its
@@ -209,10 +214,11 @@ jobs:
209214
${{ runner.os }}-pnpm-store-v3-
210215
211216
# Shard-scoped key: the turbo test hash differs per shard (pass-through
212-
# args are part of the task hash), and two same-named matrix jobs would
213-
# otherwise race to save one cache entry per sha.
214-
- name: Setup Turbo cache
215-
uses: actions/cache@v6
217+
# args are part of the task hash). Restore-only on PRs — see the Restore
218+
# Turbo cache comment in the test job; the save step at the end of this
219+
# job seeds from main only.
220+
- name: Restore Turbo cache
221+
uses: actions/cache/restore@v6
216222
with:
217223
path: .turbo/cache
218224
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }}
@@ -247,6 +253,15 @@ jobs:
247253
echo "::endgroup::"
248254
done
249255
256+
# Seed the shared Turbo cache from main only (see the restore step
257+
# above); shard-scoped key so the two matrix jobs don't collide.
258+
- name: Save Turbo cache (main only)
259+
if: always() && github.event_name == 'push'
260+
uses: actions/cache/save@v6
261+
with:
262+
path: .turbo/cache
263+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }}
264+
250265
dogfood-gate:
251266
# Stable required-check name for a SHARDED job (#3622 follow-up).
252267
#
@@ -312,8 +327,13 @@ jobs:
312327
restore-keys: |
313328
${{ runner.os }}-pnpm-store-v3-
314329
315-
- name: Setup Turbo cache
316-
uses: actions/cache@v6
330+
# Restore-only on PRs: PR-side saves (~5 turbo entries per push) churned
331+
# the repo's 10 GB Actions cache pool and evicted the main-branch seeds —
332+
# observed as sudden cold-cache spikes (Build Core 51s → 4m30s). PRs fall
333+
# back to main's entries via the prefix restore-keys; only main pushes
334+
# save (the "Save Turbo cache" step at the end of the job).
335+
- name: Restore Turbo cache
336+
uses: actions/cache/restore@v6
317337
with:
318338
path: .turbo/cache
319339
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
@@ -377,6 +397,14 @@ jobs:
377397
packages/spec/json-schema/
378398
retention-days: 30
379399

400+
# Seed the shared Turbo cache from main only (see the restore step above).
401+
- name: Save Turbo cache (main only)
402+
if: always() && github.event_name == 'push'
403+
uses: actions/cache/save@v6
404+
with:
405+
path: .turbo/cache
406+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
407+
380408
build-docs:
381409
name: Build Docs
382410
needs: filter

.github/workflows/codeql.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
name: CodeQL Security Analysis
22

3+
# Deliberately NOT on pull_request: the analysis took ~4½ runner-minutes per
4+
# PR sync, and everything a PR introduces is analyzed on main within minutes
5+
# of the merge anyway (multi-agent merge cadence). Alerts surface on main's
6+
# runs + the weekly deep pass instead of blocking/occupying PR runners.
7+
# If branch protection ever required the "Analyze (javascript)" check, that
8+
# requirement must be dropped along with this trigger.
39
on:
410
push:
511
branches:
612
- main
7-
pull_request:
8-
branches:
9-
- main
1013
schedule:
1114
# Run at 02:00 UTC every Monday
1215
- cron: '0 2 * * 1'
1316

14-
# A superseded analysis of an outdated commit has no value — cancel it. PR runs
15-
# group by PR number; push/schedule runs group by ref, so a newer main push
16-
# cancels only an in-flight main analysis (the newer commit gets analyzed).
17+
# A superseded analysis of an outdated commit has no value — cancel it. A newer
18+
# main push cancels only an in-flight main analysis (the newer commit still
19+
# gets analyzed).
1720
concurrency:
18-
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
21+
group: codeql-${{ github.workflow }}-${{ github.ref }}
1922
cancel-in-progress: true
2023

2124
jobs:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Spec coverage, nightly. Moved off the per-push path: the coverage-
2+
# instrumented spec suite added minutes of instrumentation overhead to every
3+
# main push (ci.yml previously excluded spec's plain test task and ran this
4+
# instead), for a trend artifact that is consulted occasionally at best. The
5+
# plain (uninstrumented) spec suite still runs on every main push in ci.yml's
6+
# Test Core; this workflow owns the coverage report.
7+
name: Spec Coverage
8+
9+
on:
10+
workflow_dispatch:
11+
schedule:
12+
- cron: '0 5 * * *' # 05:00 UTC nightly
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
coverage:
19+
name: Spec coverage report
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 30
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v7
25+
26+
- name: Setup Node.js
27+
uses: actions/setup-node@v7
28+
with:
29+
node-version: '20'
30+
31+
- name: Enable Corepack
32+
run: corepack enable
33+
34+
- name: Get pnpm store directory
35+
shell: bash
36+
run: |
37+
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
38+
39+
# Restore-only: scheduled runs read main's store cache; the per-push
40+
# workflows own saving it.
41+
- name: Restore pnpm cache
42+
uses: actions/cache/restore@v6
43+
with:
44+
path: ${{ env.STORE_PATH }}
45+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
46+
restore-keys: |
47+
${{ runner.os }}-pnpm-store-v3-
48+
49+
- name: Install dependencies
50+
run: pnpm install --frozen-lockfile
51+
52+
# The spec package sits at the root of the workspace graph (no workspace
53+
# deps) and its suite reads src/ directly, so no build step is needed —
54+
# same reason lint.yml's typecheck gates run pre-build.
55+
- name: Generate coverage report
56+
run: pnpm --filter @objectstack/spec test:coverage
57+
58+
- name: Upload coverage report
59+
uses: actions/upload-artifact@v7
60+
with:
61+
name: coverage-report
62+
path: packages/spec/coverage/
63+
retention-days: 30

.github/workflows/lint.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,11 @@ jobs:
145145
# package from scratch on every run (~4½ min) while ci.yml's jobs — which
146146
# do carry this cache — finished the same build in under a minute. Same
147147
# key scheme as ci.yml so the fallback prefix can also hit main's caches.
148-
- name: Setup Turbo cache
149-
uses: actions/cache@v6
148+
# Restore-only on PRs (same policy as ci.yml): PR-side saves churned the
149+
# 10 GB Actions cache pool and evicted the main seeds; only main pushes
150+
# save (the "Save Turbo cache" step at the end of the job).
151+
- name: Restore Turbo cache
152+
uses: actions/cache/restore@v6
150153
with:
151154
path: .turbo/cache
152155
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
@@ -243,3 +246,11 @@ jobs:
243246
# so it runs after the build step alongside the other consumer gates.
244247
- name: Check skills TypeScript examples compile
245248
run: pnpm --filter @objectstack/spec run check:skill-examples
249+
250+
# Seed the shared Turbo cache from main only (see the restore step above).
251+
- name: Save Turbo cache (main only)
252+
if: always() && github.event_name == 'push'
253+
uses: actions/cache/save@v6
254+
with:
255+
path: .turbo/cache
256+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}

.github/workflows/release.yml

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ jobs:
4848
restore-keys: |
4949
${{ runner.os }}-pnpm-store-v3-
5050
51+
# This job ran the full workspace build cold on every main push (~4½
52+
# min) — the same omission lint.yml's typecheck job had. Same key scheme
53+
# as ci.yml. Runs only on main pushes, so plain save+restore is right
54+
# here (this job is one of the cache seeders).
55+
- name: Setup Turbo cache
56+
uses: actions/cache@v6
57+
with:
58+
path: .turbo/cache
59+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
60+
restore-keys: |
61+
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
62+
${{ runner.os }}-turbo-${{ github.job }}-
63+
5164
- name: Install dependencies
5265
run: pnpm install --frozen-lockfile
5366

@@ -57,13 +70,34 @@ jobs:
5770
- name: Build
5871
run: pnpm run build
5972

73+
# The vendored Console dist is a pure function of the objectui pin (and
74+
# of the build script itself) — yet it was cloned + vite-built from
75+
# scratch on every main push (~3 min). Cache the finished dist keyed on
76+
# exactly those inputs; the stamp check below still verifies whatever
77+
# dist ends up in place, restored or fresh.
78+
- name: Cache vendored Console dist (keyed on the objectui pin)
79+
id: console-dist-cache
80+
uses: actions/cache@v6
81+
with:
82+
path: packages/console/dist
83+
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
84+
6085
- name: Build vendored @objectstack/console SPA
6186
# Clones objectstack-ai/objectui at the SHA pinned in .objectui-sha,
6287
# builds @object-ui/console, and copies dist/ into
6388
# packages/console/dist/. Must run before publish so the
64-
# prepublishOnly guard in @objectstack/console passes.
89+
# prepublishOnly guard in @objectstack/console passes. Skipped when the
90+
# cache above restored a dist built from the same pin.
91+
if: steps.console-dist-cache.outputs.cache-hit != 'true'
6592
run: bash scripts/build-console.sh
6693

94+
# Belt-and-suspenders for the cache path: build-console.sh stamps the
95+
# objectui SHA it built from into dist/.objectui-sha; check:console-sha
96+
# fails loudly if the dist in place (restored or fresh) drifts from the
97+
# pin.
98+
- name: Verify Console dist stamp matches pin
99+
run: pnpm check:console-sha
100+
67101
- name: Downstream backward-compat smoke (live hotcrm)
68102
# Pre-publish gate (#2035): the about-to-publish @objectstack/spec must
69103
# not break a real third-party consumer pinned to a published release.

content/docs/kernel/contracts/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Service contracts are **TypeScript interfaces** that define the boundaries betwe
4141
| Contract | Interface | Description |
4242
|:---|:---|:---|
4343
| Auth Service | `IAuthService` | Authentication — login, verify, logout, session management |
44+
| Security Service | `ISecurityService` | Query surface for access decisions — row-level read scope, readable-field projection, effective permission sets, access explanation |
4445
| Sharing Service | `ISharingService` | Record sharing rules and access grants |
4546

4647
### Storage & Caching Contracts

0 commit comments

Comments
 (0)