Skip to content

Commit f67151c

Browse files
committed
feat(hooks)!: remove deprecated ctx.session.tenantId alias (#3290)
Converge the hook and action-body authoring surface on `organizationId`, removing the `ctx.session.tenantId` / `ctx.user.tenantId` alias that #3280 added as a deprecated shim. - spec: drop `tenantId` from `HookContextSchema.session` (only `organizationId` remains; a stray key is now stripped by the schema). - objectql: engine `buildSession()` no longer emits `session.tenantId`; the audit-stamp plugin sources the `tenant_id` column from `session.organizationId`. - runtime: `buildActionSession()` and the REST action `ctx.user` no longer emit `tenantId`. - trigger-record-change: reads `session.organizationId` when forwarding the writer's org to a runAs:'user' flow (behavior identical). - check:org-identifier guard now also covers `packages/**` (tests and comments excluded); docs/skills reference bodies migrated to `organizationId`. The generic driver-layer tenancy knob (ExecutionContext.tenantId, DriverOptions.tenantId, SqlDriver.applyTenantScope, ExecutionLog.tenantId) is deliberately untouched. Breaking: major bump for spec, objectql, runtime. Closes #3290 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Q7R16SoGBeqKkEd7RCZRL
1 parent a140ff0 commit f67151c

15 files changed

Lines changed: 150 additions & 94 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/objectql": major
4+
"@objectstack/runtime": major
5+
"@objectstack/trigger-record-change": patch
6+
---
7+
8+
**BREAKING: remove the deprecated `ctx.session.tenantId` / `ctx.user.tenantId` alias from the hook & action authoring surface — converge on `organizationId` (#3290).**
9+
10+
#3280 made `organizationId` the blessed developer-facing name for the caller's active org across the JS authoring surface and kept `tenantId` as a `@deprecated` alias carrying the identical value. That alias is now **removed** from the hook `ctx.session`, the action-body `ctx.session`, and the action-body `ctx.user`. Read the caller's active org under the single blessed name:
11+
12+
```diff
13+
- const org = ctx.session.tenantId; // hook or action body
14+
+ const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
15+
```
16+
17+
**FROM → TO migration** (in any `*.hook.ts` / `*.action.ts` body):
18+
19+
- `ctx.session.tenantId``ctx.session.organizationId`
20+
- `ctx.user.tenantId` (action body) → `ctx.user.organizationId`
21+
22+
The value is unchanged — `organizationId` is the same active-org id, matching the `organization_id` column and `current_user.organizationId` in RLS/sharing. `ctx.user` is `undefined` for system / unauthenticated writes, so read `ctx.session?.organizationId` when a hook or action must work regardless of a resolved user.
23+
24+
What changed internally:
25+
26+
- **`@objectstack/spec`**`HookContextSchema.session` drops the `tenantId` field (only `organizationId` remains). A stray `tenantId` on a constructed session is now stripped by the schema.
27+
- **`@objectstack/objectql`** — the engine's `buildSession()` no longer emits `session.tenantId`; the audit-stamp plugin sources the `tenant_id` column from `session.organizationId`.
28+
- **`@objectstack/runtime`**`buildActionSession()` and the REST action `ctx.user` no longer emit `tenantId`.
29+
- **`@objectstack/trigger-record-change`** — reads `session.organizationId` (was `session.tenantId`) when forwarding the writer's org to a `runAs:'user'` flow; behavior is identical.
30+
31+
**Explicit non-goal (unchanged):** the generic **driver-layer** tenancy abstraction is *not* touched — `ExecutionContext.tenantId`, `DriverOptions.tenantId`, `SqlDriver.applyTenantScope` / `TenancyConfig.tenantField`, and `ExecutionLog.tenantId`. That isolation column is configurable and legitimately carries an *environment* id in database-per-tenant kernels; it is a distinct axis from the developer-facing org. The build-time `check:org-identifier` guard now also covers `packages/**` to keep reference bodies off the removed name.

.github/workflows/lint.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ jobs:
7575
- name: Reserved-word ("role") docs ratchet
7676
run: pnpm check:role-word
7777

78-
# #3280 org-identifier guard: `organizationId` is the blessed developer-facing
79-
# name for the caller's active org in hook/action bodies; `session.tenantId`
80-
# is a deprecated alias. Keeps our own reference apps (examples/, apps/) —
81-
# which authors and AIs copy fromoff the deprecated name. Hard-fail
82-
# (authoring surfaces carry zero occurrences today); skills/ and docs/ are
83-
# excluded since they teach the deprecated form, and driver-layer
84-
# `execCtx.tenantId` is never matched.
78+
# #3280/#3290 org-identifier guard: `organizationId` is the blessed
79+
# developer-facing name for the caller's active org in hook/action bodies;
80+
# the `session.tenantId` alias was REMOVED in v11 (#3290). Keeps our own
81+
# reference code (examples/, apps/, AND packages/)which authors and AIs
82+
# copy from — off the removed name. Hard-fail (surfaces carry zero
83+
# occurrences today); tests, comments, skills/ and docs/ are excluded, and
84+
# driver-layer `execCtx.tenantId` is never matched.
8585
- name: Org-identifier authoring guard
8686
run: pnpm check:org-identifier
8787

content/docs/kernel/runtime-services/examples.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export async function run(ctx: any) {
2929
export async function beforeUpdate(ctx: any) {
3030
const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, {
3131
userId: ctx.session?.userId,
32-
tenantId: ctx.session?.tenantId,
32+
// Read the caller's org under `organizationId` (the `session.tenantId` alias
33+
// was removed in v11, #3290); it feeds the sharing context's `tenantId`.
34+
tenantId: ctx.session?.organizationId,
3335
positions: ctx.session?.positions,
3436
});
3537

content/docs/kernel/runtime-services/sharing-service.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ services.sharing.listShares(object: string, recordId: string, context: SharingEx
3434
```ts
3535
const allowed = await services.sharing.canEdit('contract', ctx.input.id, {
3636
userId: ctx.session?.userId,
37-
tenantId: ctx.session?.tenantId,
37+
// The hook exposes the caller's org as `organizationId` (the `session.tenantId`
38+
// alias was removed in v11, #3290); it feeds the sharing context's `tenantId`.
39+
tenantId: ctx.session?.organizationId,
3840
positions: ctx.session?.positions,
3941
});
4042
if (!allowed) throw new Error('PERMISSION_DENIED');

packages/objectql/src/engine.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -496,10 +496,10 @@ describe('ObjectQL Engine', () => {
496496

497497
await engine.insert('task', { title: 'x' }, { context: { userId: 'u1', tenantId: 'org_1' } as any });
498498

499-
// Blessed name and the deprecated alias carry the identical value.
499+
// Blessed name carries the org; the deprecated `tenantId` alias was
500+
// removed in v11 (#3290) and must no longer be emitted.
500501
expect(session.organizationId).toBe('org_1');
501-
expect(session.tenantId).toBe('org_1');
502-
expect(session.organizationId).toBe(session.tenantId);
502+
expect(session.tenantId).toBeUndefined();
503503
// `ctx.user` shortcut carries the same org for zero-relearning filtering.
504504
expect(user).toMatchObject({ id: 'u1', organizationId: 'org_1' });
505505
});

packages/objectql/src/engine.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -745,11 +745,11 @@ export class ObjectQL implements IDataEngine {
745745
userId: execCtx.userId,
746746
// `organizationId` is the blessed developer-facing name for the caller's
747747
// active org (matches the `organization_id` column, `current_user`
748-
// RLS shape, and seed rows). `tenantId` is kept as a deprecated alias
749-
// carrying the IDENTICAL value — both come from `execCtx.tenantId`, which
750-
// the kernel resolves from `session.activeOrganizationId` (#3280).
748+
// RLS shape, and seed rows). It comes from `execCtx.tenantId`, which the
749+
// kernel resolves from `session.activeOrganizationId`. The deprecated
750+
// `session.tenantId` alias (#3280) was removed here in v11 (#3290) — the
751+
// driver-layer `execCtx.tenantId` knob is a separate axis and stays.
751752
organizationId: execCtx.tenantId,
752-
tenantId: execCtx.tenantId,
753753
positions: execCtx.positions,
754754
accessToken: execCtx.accessToken,
755755
// Propagate system-elevated flag so hooks can distinguish engine

packages/objectql/src/plugin.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,8 +731,11 @@ export class ObjectQLPlugin implements Plugin {
731731
record.updated_by = session.userId;
732732
}
733733
}
734-
if (isInsert && session?.tenantId && hasField(objectName, 'tenant_id')) {
735-
record.tenant_id = record.tenant_id ?? session.tenantId;
734+
// Stamp the driver-layer `tenant_id` column from the caller's active org.
735+
// The hook session exposes it as `organizationId` (the `session.tenantId`
736+
// alias was removed in v11, #3290); the column name is a separate axis.
737+
if (isInsert && session?.organizationId && hasField(objectName, 'tenant_id')) {
738+
record.tenant_id = record.tenant_id ?? session.organizationId;
736739
}
737740
};
738741

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,12 +2451,13 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
24512451
expect(user.positions).toEqual(['sales_rep', 'org_member']);
24522452
expect(user.permissions).toEqual(['convert_lead']);
24532453
expect(user.email).toBe('rep@acme.test');
2454-
// #3280 — `organizationId` is the blessed name; `tenantId` is the alias.
2454+
// #3280 made `organizationId` the blessed name; the `tenantId` alias was
2455+
// removed in v11 (#3290) and must no longer be emitted on ctx.user.
24552456
expect(user.organizationId).toBe('org_acme');
2456-
expect(user.tenantId).toBe('org_acme');
2457+
expect(user.tenantId).toBeUndefined();
24572458
});
24582459

2459-
it('exposes ctx.session.organizationId (blessed) + tenantId (deprecated alias) to the action body (#3280)', async () => {
2460+
it('exposes ctx.session.organizationId and no longer emits the removed tenantId alias to the action body (#3290)', async () => {
24602461
const { dispatcher, executeAction, ctx } = captureCtx({
24612462
userId: 'user_42',
24622463
positions: ['sales_rep'],
@@ -2467,8 +2468,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
24672468
expect(session).toBeDefined();
24682469
expect(session.userId).toBe('user_42');
24692470
expect(session.organizationId).toBe('org_acme');
2470-
expect(session.tenantId).toBe('org_acme');
2471-
expect(session.organizationId).toBe(session.tenantId);
2471+
expect(session.tenantId).toBeUndefined();
24722472
});
24732473

24742474
it('falls back to a `system` principal only when the request is anonymous', async () => {

packages/runtime/src/http-dispatcher.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,17 +1033,17 @@ export class HttpDispatcher {
10331033
*
10341034
* `organizationId` is the blessed name for the caller's active org — the
10351035
* same value as the `organization_id` column and `current_user.organizationId`
1036-
* (RLS). `tenantId` is a deprecated alias carrying the identical value.
1037-
* Returns undefined for a genuinely context-less / self-invoked call so a
1038-
* body can distinguish "no session" the same way hooks do.
1036+
* (RLS). The deprecated `session.tenantId` alias (#3280) was removed in v11
1037+
* (#3290); the driver-layer `ExecutionContext.tenantId` it is sourced from is
1038+
* a distinct, configurable axis and stays. Returns undefined for a genuinely
1039+
* context-less / self-invoked call so a body can distinguish "no session" the
1040+
* same way hooks do.
10391041
*/
10401042
private buildActionSession(ec: any): any | undefined {
10411043
if (!ec || (ec.userId == null && ec.tenantId == null)) return undefined;
10421044
return {
10431045
...(ec.userId != null ? { userId: String(ec.userId) } : {}),
1044-
...(ec.tenantId != null
1045-
? { organizationId: String(ec.tenantId), tenantId: String(ec.tenantId) }
1046-
: {}),
1046+
...(ec.tenantId != null ? { organizationId: String(ec.tenantId) } : {}),
10471047
...(Array.isArray(ec.positions) && ec.positions.length ? { roles: ec.positions } : {}),
10481048
};
10491049
}
@@ -3932,11 +3932,9 @@ export class HttpDispatcher {
39323932
positions: Array.isArray(ec.positions) ? ec.positions : [],
39333933
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
39343934
// `organizationId` is the blessed developer-facing name for the
3935-
// caller's active org (matches columns + `current_user.organizationId`);
3936-
// `tenantId` is kept as a deprecated alias with the identical
3937-
// value (#3280).
3935+
// caller's active org (matches columns + `current_user.organizationId`).
3936+
// The deprecated `tenantId` alias (#3280) was removed in v11 (#3290).
39383937
organizationId: ec.tenantId,
3939-
tenantId: ec.tenantId,
39403938
}
39413939
: { id: 'system', name: 'system', roles: [], positions: [], permissions: [] };
39423940

packages/spec/src/data/hook.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,14 +541,14 @@ describe('HookContextSchema', () => {
541541
input: {},
542542
session: {
543543
userId: 'user_123',
544-
tenantId: 'tenant_456',
544+
organizationId: 'org_456',
545545
roles: ['user', 'admin'],
546546
},
547547
ql: {},
548548
});
549549

550550
expect(context.session?.userId).toBe('user_123');
551-
expect(context.session?.tenantId).toBe('tenant_456');
551+
expect(context.session?.organizationId).toBe('org_456');
552552
expect(context.session?.roles).toContain('admin');
553553
});
554554

@@ -567,23 +567,25 @@ describe('HookContextSchema', () => {
567567
expect(context.session?.accessToken).toBe('token_abc123');
568568
});
569569

570-
// #3280 — `organizationId` is the blessed developer-facing name; `tenantId`
571-
// stays as a deprecated alias carrying the identical value.
572-
it('should accept session.organizationId (blessed) alongside tenantId (deprecated alias)', () => {
570+
// #3280 made `organizationId` the blessed developer-facing name; the
571+
// `tenantId` alias was removed from this surface in v11 (#3290). A stray
572+
// `tenantId` key is now stripped by the schema rather than surfaced.
573+
it('exposes session.organizationId and no longer carries the removed tenantId alias (#3290)', () => {
573574
const context = HookContextSchema.parse({
574575
object: 'account',
575576
event: 'beforeInsert',
576577
input: {},
577578
session: {
578579
userId: 'user_123',
579580
organizationId: 'org_456',
581+
// No longer part of the schema — Zod strips this unknown key.
580582
tenantId: 'org_456',
581-
},
583+
} as any,
582584
ql: {},
583585
});
584586

585587
expect(context.session?.organizationId).toBe('org_456');
586-
expect(context.session?.tenantId).toBe('org_456');
588+
expect((context.session as any)?.tenantId).toBeUndefined();
587589
});
588590

589591
it('should accept user.organizationId shortcut', () => {

0 commit comments

Comments
 (0)