@cosmicdrift/kumiko-framework 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # @cosmicdrift/kumiko-framework
2
+
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 7a7da3e: Re-publish 0.2.1 → 0.2.2 mit korrekt aufgelösten cross-package-Versionen.
8
+ 0.2.1 hatte `workspace:*` als Wert in den dependencies (npm publish ohne
9
+ yarn-pack rewrite), Konsumenten bekamen "Workspace not found".
10
+
11
+ publish-with-oidc.sh nutzt jetzt `yarn pack` (rewrited workspace:\*) +
12
+ `npm publish <tarball>` (OIDC + provenance).
13
+
14
+ ## 0.2.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 48b7f6a: CI: switch publish to npm-CLI with OIDC Trusted Publishing + provenance.
19
+ No source changes — verifies the new publish path produces a verified-
20
+ provenance attestation on npmjs.com instead of token-based publish.
21
+
22
+ ## 0.2.0
23
+
24
+ ### Minor Changes
25
+
26
+ - 6c70b6f: fix(tenant): seedTenant idempotent gegen Event-Store-Projection-Drift.
27
+
28
+ Verhindert version_conflict beim App-Boot wenn Aggregat existiert aber
29
+ Projection-Row fehlt (rebuild-drift, async-lag, manueller DB-Eingriff).
30
+
31
+ ## 0.1.0
32
+
33
+ ### Minor Changes
34
+
35
+ - 59ba6d7: Initial public release of Kumiko — AI-native backend builder.
36
+
37
+ What ships in 0.1.0:
38
+
39
+ - **Engine** (`@cosmicdrift/kumiko-framework`): `defineFeature`, `r.entity`, `r.writeHandler`, `r.queryHandler`, `r.projection`, `r.multiStreamProjection`, `r.hook`, `r.translations`, `r.crud`, `r.referenceData`, `r.screen`, `r.nav`, `r.authClaims`, full lifecycle pipeline with field-level access checks
40
+ - **Pipeline** (`@cosmicdrift/kumiko-framework`): `createDispatcher`, JWT auth via jose, Zod schema validation, role-based access checks, command/write/query split
41
+ - **DB** (`@cosmicdrift/kumiko-framework`): Drizzle helpers (`buildDrizzleTable`, `applyCursorQuery`), CRUD executor, Postgres dialect, optimistic locking, soft delete, multi-tenant scoping
42
+ - **Event sourcing** (`@cosmicdrift/kumiko-framework`): aggregate streams, single + multi-stream projections, event upcasters, asOf queries, archive support, AsyncDaemon-pattern dispatcher
43
+ - **Bundled features** (`@cosmicdrift/kumiko-bundled-features`): auth-email-password, sessions, tenants, users, jobs, secrets, file-provider-s3, mail-transport-smtp/inmemory, billing-foundation, cap-counter, channel-in-app, delivery, feature-toggles, legal-pages
44
+ - **Renderer** (`@cosmicdrift/kumiko-renderer`, `@cosmicdrift/kumiko-renderer-web`): schema-driven CRUD UI for React + Expo Web, override paths, list debounce, theme tokens
45
+ - **Headless** (`@cosmicdrift/kumiko-headless`): view-models for list/edit screens, locale-aware
46
+ - **Dev server** (`@cosmicdrift/kumiko-dev-server`): `runDevApp`, `runProdApp`, `kumiko-build` for production bundles (client + server), Docker-ready
47
+ - **Realtime** (`@cosmicdrift/kumiko-dispatcher-live`): SSE broadcast across tenants, Redis Pub/Sub backend
48
+ - **CLI** (`bin/kumiko.ts`): interactive dev menu, test runners, check pipeline (Biome + TypeScript + 18 guards + Vitest)
49
+
50
+ This is a pre-1.0 release — APIs may change between minor versions. Breaking changes will be documented per release.
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
9
+ "url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
10
10
  "directory": "packages/framework"
11
11
  },
12
12
  "bugs": {
13
- "url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
13
+ "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
14
14
  },
15
15
  "homepage": "https://kumiko.so",
16
16
  "keywords": [
@@ -73,7 +73,7 @@
73
73
  "zod": "^4.3.6"
74
74
  },
75
75
  "devDependencies": {
76
- "@cosmicdrift/kumiko-dispatcher-live": "workspace:*",
76
+ "@cosmicdrift/kumiko-dispatcher-live": "0.2.2",
77
77
  "@types/uuid": "^11.0.0",
78
78
  "bun-types": "^1.2.9",
79
79
  "drizzle-kit": "^0.31.0",
@@ -88,4 +88,4 @@
88
88
  "README.md",
89
89
  "LICENSE"
90
90
  ]
91
- }
91
+ }
@@ -126,6 +126,12 @@ export {
126
126
  SYSTEM_ROLE,
127
127
  SYSTEM_USER_ID,
128
128
  } from "./system-user";
129
+ export {
130
+ type EffectiveFeaturesResolver,
131
+ findTierResolverUsage,
132
+ TENANT_TIER_RESOLVER_EXT,
133
+ type TierResolverPlugin,
134
+ } from "./tier-resolver-extension";
129
135
  // Types
130
136
  export type {
131
137
  AccessRule,
@@ -0,0 +1,78 @@
1
+ // Sprint-8a Tier-Composition: framework-extension-point für per-tenant
2
+ // effective-feature resolution.
3
+ //
4
+ // **Pattern (analog mail-foundation / file-foundation):** ein Feature
5
+ // declares `r.extendsRegistrar("tenantTierResolver")` und plugins
6
+ // implementieren via `r.useExtension("tenantTierResolver", "id", { build })`.
7
+ //
8
+ // **Auto-wiring im Framework:** runDevApp + runProdApp scannen das registry
9
+ // nach genau diesem extension-name. Wenn ein plugin gefunden ist UND der
10
+ // App-Author nicht selbst eine `effectiveFeatures`-callback gesetzt hat,
11
+ // wird `plugin.build({ db, registry })` einmalig in onAfterSetup aufgerufen.
12
+ // Der returned callback landet als `effectiveFeatures` im dispatcher.
13
+ //
14
+ // **Warum dieser Pattern:** App-Author mountet `createTierEngineFeature(opts)`
15
+ // und das war's — kein Late-Bound-Holder im run-config, kein
16
+ // effectiveFeatures-callback wiren. Das Framework macht den Late-Bound-Trick
17
+ // intern. Memory `feedback_alles_ist_ein_feature`: Tier-Composition ist
18
+ // ein Feature, nicht ein Subsystem mit App-spezifischem Wiring.
19
+ //
20
+ // **Apps ohne tier-engine:** wenn keine plugin registriert ist, framework
21
+ // macht nichts — `effectiveFeatures` bleibt undefined, alle features sind on.
22
+
23
+ import type { DbConnection } from "../db/connection";
24
+ import type { RegistrarExtensionRegistration } from "./types/config";
25
+ import type { FeatureDefinition, Registry } from "./types/feature";
26
+ import type { TenantId } from "./types/identifiers";
27
+
28
+ /**
29
+ * Extension-name unter dem ein tier-resolver-plugin im registry registriert
30
+ * werden muss. Konstante damit `r.useExtension(TENANT_TIER_RESOLVER_EXT, ...)`
31
+ * + framework's `getExtensionUsages(TENANT_TIER_RESOLVER_EXT)` typo-resistent
32
+ * gegen den selben Wert prüfen.
33
+ */
34
+ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
35
+
36
+ /**
37
+ * Resolver-callback shape: synchron (dispatcher hot-path), per-tenant.
38
+ * Returnt das effective feature-Set für den tenant. Implementations sollten
39
+ * einen in-memory cache pflegen + per `r.entityHook` invalidieren.
40
+ *
41
+ * **System-context convention:** call mit SYSTEM_TENANT_ID erwartet die union
42
+ * aller tier-features (siehe DispatcherOptions.effectiveFeatures doc-block).
43
+ */
44
+ export type EffectiveFeaturesResolver = (tenantId: TenantId) => ReadonlySet<string>;
45
+
46
+ /**
47
+ * Plugin-shape für tier-resolver-extension. Plugins implementieren `build`
48
+ * als boot-time factory: kriegen `db` + `registry` (post-stack-setup),
49
+ * laden initial cache aus DB, returnen den synchronen resolver-callback.
50
+ */
51
+ export type TierResolverPlugin = {
52
+ readonly build: (deps: {
53
+ readonly db: DbConnection;
54
+ readonly registry: Registry;
55
+ }) => Promise<EffectiveFeaturesResolver>;
56
+ };
57
+
58
+ /**
59
+ * Scan a composed feature-list for a `tenantTierResolver`-extension usage.
60
+ * Single plugin assumption — multiple wären ambiguous (welcher resolver
61
+ * gewinnt). Memory `feedback_no_options_without_need`: kein multi-merge-
62
+ * pattern bis es echten Use-Case gibt.
63
+ *
64
+ * Geteilter helper für runDevApp + runProdApp damit der Pickup-Pfad
65
+ * bit-identisch ist (drift-resistent).
66
+ */
67
+ export function findTierResolverUsage(
68
+ features: readonly FeatureDefinition[],
69
+ ): RegistrarExtensionRegistration | undefined {
70
+ for (const feature of features) {
71
+ for (const usage of feature.extensionUsages) {
72
+ if (usage.extensionName === TENANT_TIER_RESOLVER_EXT) {
73
+ return usage;
74
+ }
75
+ }
76
+ }
77
+ return undefined;
78
+ }
@@ -261,11 +261,13 @@ type SharedContextFields = {
261
261
  // rebuildProjection) honour it automatically.
262
262
  readonly signal?: AbortSignal;
263
263
  // Effective feature-toggle resolver. Wired by the dispatcher when the
264
- // feature-toggles feature is loaded — the lifecycle pipeline, MSP runner,
265
- // and ctx.hasFeature all read from this single source. Returns the Set
266
- // of feature names that are currently effectively enabled (after global
267
- // overrides and r.requires() cascade). Absent = all features on.
268
- readonly effectiveFeatures?: () => ReadonlySet<string>;
264
+ // feature-toggles or tier-engine feature is loaded — the lifecycle
265
+ // pipeline, MSP runner, and ctx.hasFeature all read from this single
266
+ // source. Per-tenant: tenantId argument enables tier-cuts (Sprint 8a)
267
+ // where Tenant-A sees Pro features and Tenant-B sees Free features in
268
+ // the same process. Returns the Set of feature names effectively
269
+ // enabled for that tenant. Absent = all features on (back-compat).
270
+ readonly effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
269
271
  };
270
272
 
271
273
  // All optional — used at pipeline/system boundaries.
@@ -286,6 +288,12 @@ export type AppContext = SharedContextFields & {
286
288
  readonly triggerName?: string;
287
289
  readonly _userId?: string | undefined;
288
290
  readonly _handlerType?: string | undefined;
291
+ /** Tenant des aktuellen Pipeline-Calls. Wird vom Dispatcher beim Bauen
292
+ * des HandlerContext aus `user.tenantId` gespiegelt, damit lifecycle-
293
+ * pipeline + system-hooks den Wert ohne Zugriff auf user-Object haben.
294
+ * Sprint-8a Tier-Composition: `effectiveFeatures(ctx._tenantId)`-call
295
+ * in den hook-filter-Stellen. */
296
+ readonly _tenantId?: TenantId;
289
297
  };
290
298
 
291
299
  // Handler execution: db (tenant-scoped) + registry guaranteed.
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
+ import type { TenantId } from "../../engine/types/identifiers";
4
5
  import { createTestUser } from "../../stack";
5
6
  import { createDispatcher } from "../dispatcher";
6
7
 
@@ -321,6 +322,78 @@ describe("dispatcher feature-gate", () => {
321
322
  items: [],
322
323
  });
323
324
  });
325
+
326
+ test("Sprint 8a: per-tenant gating — Tenant A passes, Tenant B gets feature_disabled", async () => {
327
+ // Beweist die Phase-1-Architektur: dispatcher ruft effectiveFeatures
328
+ // mit user.tenantId, resolver kann pro Tenant unterschiedliche Sets
329
+ // returnen → Tier-A sieht feature, Tier-B nicht.
330
+ const registry = createRegistry([toggled()]);
331
+ const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
332
+ const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
333
+
334
+ const dispatcher = createDispatcher(
335
+ registry,
336
+ {},
337
+ {
338
+ effectiveFeatures: (tenantId) => (tenantId === tenantA ? new Set(["toggled"]) : new Set()),
339
+ },
340
+ );
341
+
342
+ const userA = createTestUser({ id: "u-a", tenantId: tenantA, roles: ["Admin"] });
343
+ const userB = createTestUser({ id: "u-b", tenantId: tenantB, roles: ["Admin"] });
344
+
345
+ await expect(dispatcher.query("toggled:query:widget:list", {}, userA)).resolves.toEqual({
346
+ items: [],
347
+ });
348
+ await expect(dispatcher.query("toggled:query:widget:list", {}, userB)).rejects.toThrow(
349
+ /feature toggled is disabled/,
350
+ );
351
+
352
+ const writeA = await dispatcher.write("toggled:write:widget:create", { name: "from-a" }, userA);
353
+ expect(writeA.isSuccess).toBe(true);
354
+
355
+ const writeB = await dispatcher.write("toggled:write:widget:create", { name: "from-b" }, userB);
356
+ expect(writeB.isSuccess).toBe(false);
357
+ if (!writeB.isSuccess) {
358
+ expect(writeB.error.code).toBe("feature_disabled");
359
+ }
360
+ });
361
+
362
+ test("Sprint 8a: ctx.hasFeature is current-user-scoped", async () => {
363
+ // Pin: hasFeature() in handler-bodies resolves against ctx.user.tenantId,
364
+ // NICHT gegen einen globalen Set. Two tenants call same handler,
365
+ // beide rufen hasFeature("toggled") — A bekommt true, B false.
366
+ const tenantA = "00000000-0000-4000-8000-0000000000a3" as TenantId;
367
+ const tenantB = "00000000-0000-4000-8000-0000000000b4" as TenantId;
368
+
369
+ const probe = defineFeature("probe", (r) => {
370
+ r.queryHandler(
371
+ "check",
372
+ z.object({}).passthrough(),
373
+ async (_event, ctx) => ({ enabled: ctx.hasFeature("toggled") }),
374
+ { access: { openToAll: true } },
375
+ );
376
+ });
377
+ const registry = createRegistry([toggled(), probe]);
378
+ const dispatcher = createDispatcher(
379
+ registry,
380
+ {},
381
+ {
382
+ effectiveFeatures: (tenantId) =>
383
+ tenantId === tenantA ? new Set(["toggled", "probe"]) : new Set(["probe"]),
384
+ },
385
+ );
386
+
387
+ const userA = createTestUser({ id: "u-a2", tenantId: tenantA, roles: ["Admin"] });
388
+ const userB = createTestUser({ id: "u-b2", tenantId: tenantB, roles: ["Admin"] });
389
+
390
+ await expect(dispatcher.query("probe:query:check", {}, userA)).resolves.toEqual({
391
+ enabled: true,
392
+ });
393
+ await expect(dispatcher.query("probe:query:check", {}, userB)).resolves.toEqual({
394
+ enabled: false,
395
+ });
396
+ });
324
397
  });
325
398
 
326
399
  describe("write-handler shape guard", () => {
@@ -9,6 +9,7 @@ import {
9
9
  type PreSaveHookFn,
10
10
  type SaveContext,
11
11
  } from "../../engine";
12
+ import type { TenantId } from "../../engine/types/identifiers";
12
13
  import { buildEventId, createLifecycleHooks, type SystemHooks } from "../lifecycle-pipeline";
13
14
 
14
15
  function makeRegistry(hooks?: { preSave?: PreSaveHookFn[]; postSave?: PostSaveHookFn[] }) {
@@ -400,6 +401,105 @@ describe("runPostSave phase routing", () => {
400
401
  });
401
402
  });
402
403
 
404
+ // =============================================================================
405
+ // Sprint 8a: per-tenant entity-hook filter
406
+ // =============================================================================
407
+ //
408
+ // Setup: Feature A owns the entity. Feature B registers an entity-hook
409
+ // on A's entity (cross-feature pattern). lifecycle-pipeline must filter
410
+ // B's hook based on the active tenant's effectiveFeatures-set.
411
+
412
+ describe("Sprint 8a: per-tenant entity-hook filter", () => {
413
+ function setupTwoFeatures() {
414
+ const calls: Array<{ tenant: string }> = [];
415
+
416
+ const featureA = defineFeature("feat-a", (r) => {
417
+ r.entity("widget", createEntity({ table: "Widgets", fields: { name: createTextField() } }));
418
+ r.writeHandler(
419
+ "widget:create",
420
+ z.object({ name: z.string() }),
421
+ async () => ({ isSuccess: true as const, data: null }),
422
+ { access: { openToAll: true } },
423
+ );
424
+ });
425
+
426
+ const featureB = defineFeature("feat-b", (r) => {
427
+ r.entityHook("postSave", "widget", async (_result, ctx) => {
428
+ calls.push({ tenant: ctx._tenantId ?? "no-tenant" });
429
+ });
430
+ });
431
+
432
+ return { registry: createRegistry([featureA, featureB]), calls };
433
+ }
434
+
435
+ const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
436
+ const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
437
+
438
+ const baseSaveCtx: SaveContext = {
439
+ kind: "save",
440
+ id: 1,
441
+ data: { name: "x", tenantId: tenantA },
442
+ changes: { name: "x" },
443
+ previous: {},
444
+ isNew: true,
445
+ entityName: "widget",
446
+ };
447
+
448
+ test("Tenant A (feat-b enabled) → hook fires; Tenant B (feat-b disabled) → hook skipped", async () => {
449
+ const { registry, calls } = setupTwoFeatures();
450
+ const pipeline = createLifecycleHooks(registry);
451
+ const effectiveFeatures = (tenantId: TenantId) =>
452
+ tenantId === tenantA ? new Set(["feat-a", "feat-b"]) : new Set(["feat-a"]);
453
+
454
+ await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
455
+ _tenantId: tenantA,
456
+ effectiveFeatures,
457
+ });
458
+ await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
459
+ _tenantId: tenantB,
460
+ effectiveFeatures,
461
+ });
462
+
463
+ expect(calls).toEqual([{ tenant: tenantA }]);
464
+ });
465
+
466
+ test("ctx without _tenantId → hook fires (legacy back-compat: undefined = skip filter)", async () => {
467
+ // System-jobs / boot-time pipeline-calls have no user → no _tenantId.
468
+ // currentEffectiveFeatures returns undefined; registry filterByPhase
469
+ // treats undefined as "skip filter" → all hooks fire (back-compat).
470
+ const { registry, calls } = setupTwoFeatures();
471
+ const pipeline = createLifecycleHooks(registry);
472
+
473
+ await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {});
474
+
475
+ expect(calls).toHaveLength(1);
476
+ expect(calls[0]?.tenant).toBe("no-tenant");
477
+ });
478
+
479
+ test("effectiveFeatures wird PRO call konsultiert (kein staler Cache zwischen runPostSave-aufrufen)", async () => {
480
+ // Pin: currentEffectiveFeatures-helper ruft effectiveFeatures jedes
481
+ // mal neu — keine pipeline-internal Memoization. Toggle-flips müssen
482
+ // sofort greifen, nicht erst nach pipeline-restart.
483
+ const { registry, calls } = setupTwoFeatures();
484
+ const pipeline = createLifecycleHooks(registry);
485
+ const enabled = new Set<string>(["feat-a", "feat-b"]);
486
+ const effectiveFeatures = (_tenantId: TenantId) => enabled;
487
+
488
+ await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
489
+ _tenantId: tenantA,
490
+ effectiveFeatures,
491
+ });
492
+ enabled.delete("feat-b");
493
+ await pipeline.runPostSave("feat-a:write:widget:create", baseSaveCtx, {
494
+ _tenantId: tenantA,
495
+ effectiveFeatures,
496
+ });
497
+
498
+ expect(calls).toHaveLength(1);
499
+ expect(calls[0]?.tenant).toBe(tenantA);
500
+ });
501
+ });
502
+
403
503
  describe("buildEventId — dedup key construction", () => {
404
504
  test("includes handler, id, version and phase when payload is complete", () => {
405
505
  const payload = { id: 42, data: { version: 3 } };
@@ -7,6 +7,7 @@ import { hasAccess } from "../engine/access";
7
7
  import { checkWriteFieldRoles, filterReadFields } from "../engine/field-access";
8
8
  import { parseQn, qn } from "../engine/qualified-name";
9
9
  import { defineTransitions, guardTransition } from "../engine/state-machine";
10
+ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
10
11
  import type {
11
12
  AggregateStreamHandle,
12
13
  AppContext,
@@ -25,6 +26,7 @@ import type {
25
26
  WriteResult,
26
27
  } from "../engine/types";
27
28
  import { HookPhases } from "../engine/types";
29
+ import type { TenantId } from "../engine/types/identifiers";
28
30
 
29
31
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
30
32
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
@@ -318,13 +320,26 @@ export type DispatcherOptions = {
318
320
  idempotency?: IdempotencyGuard;
319
321
  lifecycle?: LifecycleHooks;
320
322
  jobRunner?: JobRunnerRef;
321
- // Resolves the current effective-feature set — the dispatcher uses it
322
- // to gate calls to handlers of disabled features (403 feature_disabled)
323
+ // Resolves the effective-feature set per tenant — the dispatcher uses
324
+ // it to gate calls to handlers of disabled features (403 feature_disabled)
323
325
  // and to populate ctx.hasFeature. Absent = all features treated as
324
- // always-on (no feature-toggles feature loaded). The resolver must be
325
- // fast and synchronous per call; implementations cache a DB snapshot
326
- // under the hood and refresh on toggle events.
327
- effectiveFeatures?: () => ReadonlySet<string>;
326
+ // always-on (no feature-toggles or tier-engine feature loaded). The
327
+ // resolver must be fast and synchronous per call; implementations cache
328
+ // tenant-keyed sets and refresh on tier-assignment / toggle events.
329
+ //
330
+ // **System-context convention:** when called with SYSTEM_TENANT_ID, the
331
+ // resolver should return the union/superset of all tier-features. Two
332
+ // contexts call with this sentinel:
333
+ // 1. event-dispatcher async-pass (consumers tagged with feature X
334
+ // should not silently skip events from a tenant where X is off —
335
+ // events are immutable, async work runs through).
336
+ // 2. operator-tooling queries (e.g. feature-toggles:registered) where
337
+ // a SystemAdmin needs to see platform-truth, not their own
338
+ // tier-cut.
339
+ // Returning a non-superset for SYSTEM_TENANT_ID will cause silent
340
+ // event-skips and a confusing operator-UI — the framework cannot
341
+ // enforce this contract, but the recipe-test pins the convention.
342
+ effectiveFeatures?: EffectiveFeaturesResolver;
328
343
  };
329
344
 
330
345
  type HandlerType = string | HandlerRef;
@@ -729,11 +744,14 @@ export function createDispatcher(
729
744
  // dispatcher.resolveAuthClaims) cannot drift.
730
745
  resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(claimsUser),
731
746
 
732
- // Feature-effective check for in-handler opt-in logic. When the
733
- // feature-toggles feature isn't wired (no effectiveFeatures callback),
734
- // always returns true apps without toggles treat all features on.
747
+ // Feature-effective check for in-handler opt-in logic. Scope:
748
+ // **current user's tenant** for cross-tenant lookups (rare,
749
+ // SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
750
+ // When the feature-toggles or tier-engine feature isn't wired (no
751
+ // effectiveFeatures callback), always returns true — apps without
752
+ // tier-cuts treat all features on.
735
753
  hasFeature: (featureName: string): boolean =>
736
- effectiveFeatures ? effectiveFeatures().has(featureName) : true,
754
+ effectiveFeatures ? effectiveFeatures(user.tenantId).has(featureName) : true,
737
755
  };
738
756
 
739
757
  // Registry is always the dispatcher's registry — injecting it here lets
@@ -771,6 +789,7 @@ export function createDispatcher(
771
789
  // event.user-Wert; Identity-Switches nutzen weiterhin queryAs/writeAs.
772
790
  user,
773
791
  _userId: user.id,
792
+ _tenantId: user.tenantId,
774
793
  _handlerType: type,
775
794
  ...bridge,
776
795
  } as HandlerContext;
@@ -860,6 +879,7 @@ export function createDispatcher(
860
879
  // pass-through in that common case.
861
880
  function checkFeatureEnabled(
862
881
  qualifiedHandler: string,
882
+ tenantId: TenantId,
863
883
  ): import("../errors").FeatureDisabledError | undefined {
864
884
  if (!effectiveFeatures) return undefined;
865
885
  const owner = registry.getHandlerFeature(qualifiedHandler);
@@ -867,13 +887,13 @@ export function createDispatcher(
867
887
  // happen for registry-built handlers, but guards against edge-case
868
888
  // runtime injections.
869
889
  if (!owner) return undefined;
870
- const set = effectiveFeatures();
890
+ const set = effectiveFeatures(tenantId);
871
891
  if (set.has(owner)) return undefined;
872
892
  return new FeatureDisabledError(owner, qualifiedHandler);
873
893
  }
874
894
 
875
- function ensureFeatureEnabled(qualifiedHandler: string): void {
876
- const err = checkFeatureEnabled(qualifiedHandler);
895
+ function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): void {
896
+ const err = checkFeatureEnabled(qualifiedHandler, tenantId);
877
897
  if (err) throw err;
878
898
  }
879
899
 
@@ -935,7 +955,7 @@ export function createDispatcher(
935
955
  // disabled feature must not consume the rate-limit quota — the call
936
956
  // never happened from the feature's perspective. Order is: lookup →
937
957
  // feature-gate → rate-limit → access → validation → handler.
938
- ensureFeatureEnabled(type);
958
+ ensureFeatureEnabled(type, user.tenantId);
939
959
 
940
960
  // Rate-limit gate runs BEFORE access-check on purpose: anonymous /
941
961
  // unauthorized callers must hit the cap too (otherwise the limit
@@ -1173,7 +1193,7 @@ export function createDispatcher(
1173
1193
 
1174
1194
  // Feature-toggle gate: disabled handlers must short-circuit before any
1175
1195
  // rate-limit/access/validation work — see executeQueryInner comment.
1176
- const disabledErr = checkFeatureEnabled(type);
1196
+ const disabledErr = checkFeatureEnabled(type, user.tenantId);
1177
1197
  if (disabledErr) return writeFailure(disabledErr);
1178
1198
 
1179
1199
  // Rate-limit gate before access (same reasoning as in executeQueryInner).
@@ -2,6 +2,7 @@ import { and, asc, eq, gt, sql } from "drizzle-orm";
2
2
  import { requestContext } from "../api/request-context";
3
3
  import type { DbConnection, DbTx, PgClient } from "../db/connection";
4
4
  import type { AppContext } from "../engine/types";
5
+ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
5
6
  import {
6
7
  EVENTS_PUBSUB_CHANNEL,
7
8
  eventsTable,
@@ -487,7 +488,15 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
487
488
  // Feature-toggle snapshot taken once per pass (not per consumer): all
488
489
  // consumers see the same disabled-set even if an operator flips a
489
490
  // toggle mid-pass, so "this event batch" decisions stay consistent.
490
- const effective = context.effectiveFeatures?.();
491
+ //
492
+ // Sprint-8a tier-composition: per-tenant resolver per-pass-konsultiert
493
+ // mit SYSTEM_TENANT_ID. Async-events sind tier-agnostic — wenn ein
494
+ // Tenant downgrade'd, sollen seine queued events trotzdem verarbeitet
495
+ // werden (events sind immutable, projection ist eventually-consistent).
496
+ // Tier-cuts wirken request-time im sync-dispatcher + lifecycle-pipeline,
497
+ // nicht im async-replay. App-level resolver entscheidet was er bei
498
+ // SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
499
+ const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
491
500
 
492
501
  // Seriell pro consumer. Parallelisierung wäre möglich (je eigene TX), aber
493
502
  // das einfache Modell reicht für v1 — jeder consumer hat geringe
@@ -20,6 +20,24 @@ function resolveTracer(context: AppContext): Tracer {
20
20
  return context.tracer ?? getFallbackTracer();
21
21
  }
22
22
 
23
+ /**
24
+ * Resolve effective-feature set for the active context's tenant.
25
+ *
26
+ * `_tenantId` wird vom dispatcher beim HandlerContext-Bau aus `user.tenantId`
27
+ * gespiegelt. Bei legacy-callsites ohne user (system-jobs, boot-time
28
+ * pipeline) ist es undefined — `effectiveFeatures(undefined)` wäre type-
29
+ * unsafe; wir return undefined und behandeln das als "all features on"
30
+ * (registry filterhooks akzeptieren undefined als "skip filter").
31
+ *
32
+ * Single source — die 4 lifecycle-hook-callsites (preSave, postSave,
33
+ * preDelete, postDelete) rufen alle hier durch, damit der tenant-
34
+ * scoping-Pfad nie inkonsistent ist.
35
+ */
36
+ function currentEffectiveFeatures(context: AppContext): ReadonlySet<string> | undefined {
37
+ if (context._tenantId === undefined) return undefined;
38
+ return context.effectiveFeatures?.(context._tenantId);
39
+ }
40
+
23
41
  export type SystemHookDef<TFn> = {
24
42
  readonly name: string;
25
43
  readonly priority: number;
@@ -250,7 +268,7 @@ export function createLifecycleHooks(
250
268
  async runPreSave(handlerName, changes, previous, isNew, context) {
251
269
  let currentChanges = changes;
252
270
  const hookContext = { ...context, previous, isNew };
253
- const eff = context.effectiveFeatures?.();
271
+ const eff = currentEffectiveFeatures(context);
254
272
 
255
273
  for (const hook of registry.getPreSaveHooks(handlerName, eff)) {
256
274
  currentChanges = await hook(currentChanges, hookContext);
@@ -266,7 +284,7 @@ export function createLifecycleHooks(
266
284
  },
267
285
 
268
286
  async runPostSave(handlerName, result, context, phase = HookPhases.afterCommit) {
269
- const eff = context.effectiveFeatures?.();
287
+ const eff = currentEffectiveFeatures(context);
270
288
  await runHookSet({
271
289
  handlerName,
272
290
  payload: result,
@@ -283,7 +301,7 @@ export function createLifecycleHooks(
283
301
  async runPreDelete(handlerName, payload, context) {
284
302
  // preDelete hooks run in-transaction and throw on failure (not best-effort).
285
303
  // They're used to check invariants before delete, so phase filter is "inTransaction".
286
- const eff = context.effectiveFeatures?.();
304
+ const eff = currentEffectiveFeatures(context);
287
305
  for (const hook of registry.getPreDeleteHooks(handlerName, HookPhases.inTransaction, eff)) {
288
306
  await hook(payload, context);
289
307
  }
@@ -308,7 +326,7 @@ export function createLifecycleHooks(
308
326
  },
309
327
 
310
328
  async runPostDelete(handlerName, payload, context, phase = HookPhases.afterCommit) {
311
- const eff = context.effectiveFeatures?.();
329
+ const eff = currentEffectiveFeatures(context);
312
330
  await runHookSet({
313
331
  handlerName,
314
332
  payload,
@@ -89,7 +89,7 @@ export type TestStackOptions = {
89
89
  * GlobalFeatureToggleRuntime.effectiveFeatures for real DB-backed
90
90
  * toggles, or a plain `() => new Set<string>(registry.features.keys())`
91
91
  * to force a specific snapshot in a unit-style setup. */
92
- effectiveFeatures?: () => ReadonlySet<string>;
92
+ effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
93
93
  /** Pin the underlying Postgres DB name instead of the default
94
94
  * `kumiko_test_<8chars>`. Forwarded to createTestDb. Primary use
95
95
  * case: dev servers that want persistent storage across restarts —