@cosmicdrift/kumiko-framework 0.289.0 → 0.290.0

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.
Files changed (41) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  6. package/src/api/request-context.ts +28 -0
  7. package/src/api/server.ts +30 -4
  8. package/src/changes.json +18 -0
  9. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  10. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  11. package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
  12. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  13. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  14. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  15. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  16. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  17. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
  18. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  19. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  20. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  21. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
  23. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  24. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  25. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  26. package/src/engine/boot-validator/action-wiring.ts +2 -1
  27. package/src/engine/boot-validator/screens.ts +4 -12
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/qualified-name.ts +9 -0
  30. package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
  31. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  32. package/src/event-store/event-store.ts +23 -2
  33. package/src/jobs/job-runner.ts +10 -2
  34. package/src/pipeline/active-membership.ts +10 -4
  35. package/src/pipeline/append-event-core.ts +2 -11
  36. package/src/pipeline/dispatch-shared.ts +17 -5
  37. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  38. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  39. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  40. package/src/testing/e2e-generator.ts +4 -3
  41. package/src/ui-types/index.ts +2 -0
@@ -20,8 +20,12 @@ const contactEntity = createEntity({
20
20
  // hook) writes it. secretNote's ownership rule checks authorId, so
21
21
  // create only succeeds if the hook ran BEFORE the field-ownership check
22
22
  // (kumiko-framework#1672 — see also event-store-executor-write.ts).
23
- authorId: createTextField(),
24
- secretNote: createTextField({ access: { write: { User: from("user:id", "authorId") } } }),
23
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
24
+ secretNote: createTextField({
25
+ personal: false,
26
+ reason: "test_fixture",
27
+ access: { write: { User: from("user:id", "authorId") } },
28
+ }),
25
29
  },
26
30
  });
27
31
 
@@ -41,7 +41,12 @@ describe("createLongTextField — runtime shape", () => {
41
41
  });
42
42
 
43
43
  test("encrypted + sensitive flags type-allowed", () => {
44
- const f = createLongTextField({ encrypted: true, sensitive: true });
44
+ const f = createLongTextField({
45
+ personal: false,
46
+ reason: "test_fixture",
47
+ encrypted: true,
48
+ sensitive: true,
49
+ });
45
50
  expect(f.encrypted).toBe(true);
46
51
  expect(f.sensitive).toBe(true);
47
52
  });
@@ -7,9 +7,19 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { defineFeature } from "../../define-feature";
9
9
  import { createEntity, createTextField } from "../../factories";
10
- import type { FeatureDefinition } from "../../types";
10
+ import type { FeatureDefinition, TextFieldDef } from "../../types";
11
11
  import { validateRecordOwnedSubjects } from "../record-owned";
12
12
 
13
+ // Presence/absence of the annotation is the test variable here; after #2810
14
+ // the factory can no longer produce the unannotated shape.
15
+ const unannotatedText: TextFieldDef = {
16
+ type: "text",
17
+ maxLength: 200,
18
+ required: false,
19
+ searchable: false,
20
+ sortable: false,
21
+ };
22
+
13
23
  function featureWith(
14
24
  idType: "serial" | "uuid" | undefined,
15
25
  withRecordOwnedField: boolean,
@@ -23,7 +33,7 @@ function featureWith(
23
33
  fields: {
24
34
  body: withRecordOwnedField
25
35
  ? createTextField({ personal: { of: "id" }, find: "none" })
26
- : createTextField(),
36
+ : { ...unannotatedText },
27
37
  },
28
38
  }),
29
39
  );
@@ -3,6 +3,7 @@ import {
3
3
  isFieldsEditSection,
4
4
  normalizeEditField,
5
5
  normalizeListColumn,
6
+ sectionFieldSpecs,
6
7
  } from "../screen-helpers";
7
8
  import type {
8
9
  EditFieldSpec,
@@ -125,7 +126,7 @@ function validateEditLayoutNoFunctions(
125
126
  continue;
126
127
  }
127
128
  if (!isFieldsEditSection(section)) continue;
128
- for (const fieldSpec of section.fields) {
129
+ for (const fieldSpec of sectionFieldSpecs(section)) {
129
130
  validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
130
131
  }
131
132
  }
@@ -16,6 +16,7 @@ import {
16
16
  normalizeEditField,
17
17
  normalizeListColumn,
18
18
  resolveNavParentScreen,
19
+ sectionFieldSpecs,
19
20
  } from "../screen-helpers";
20
21
  import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
21
22
  import { metricField } from "../types";
@@ -608,15 +609,6 @@ function validateFieldsXorGroups(
608
609
  }
609
610
  }
610
611
 
611
- function flattenFieldsOrGroups(section: {
612
- readonly fields: readonly EditFieldSpec[];
613
- readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
614
- }): readonly EditFieldSpec[] {
615
- return section.groups !== undefined
616
- ? section.groups.flatMap((group) => group.fields)
617
- : section.fields;
618
- }
619
-
620
612
  function validateFormLayoutSections(
621
613
  featureName: string,
622
614
  screenId: string,
@@ -657,7 +649,7 @@ function validateFormLayoutSections(
657
649
  );
658
650
  }
659
651
  validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
660
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
652
+ for (const fieldSpec of sectionFieldSpecs(section)) {
661
653
  const normalized = normalizeEditField(fieldSpec);
662
654
  if (!fieldNames.has(normalized.field)) {
663
655
  throw new Error(
@@ -1420,7 +1412,7 @@ export function validateScreens(
1420
1412
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
1421
1413
  section,
1422
1414
  );
1423
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
1415
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1424
1416
  const normalized = normalizeEditField(fieldSpec);
1425
1417
  if (!fieldNames.has(normalized.field)) {
1426
1418
  throw new Error(
@@ -1837,7 +1829,7 @@ export function validateScreens(
1837
1829
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
1838
1830
  section,
1839
1831
  );
1840
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
1832
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1841
1833
  const normalized = normalizeEditField(fieldSpec);
1842
1834
  if (!fieldNames.has(normalized.field)) {
1843
1835
  throw new Error(
@@ -266,6 +266,7 @@ export {
266
266
  isWriteFormEditSection,
267
267
  normalizeEditField,
268
268
  normalizeListColumn,
269
+ sectionFieldSpecs,
269
270
  } from "./screen-helpers";
270
271
  export type { TransitionGraph } from "./state-machine";
271
272
  export { defineTransitions, guardTransition } from "./state-machine";
@@ -130,6 +130,15 @@ export function isKebabSegment(name: string): boolean {
130
130
  return QN_SEGMENT.test(name);
131
131
  }
132
132
 
133
+ // Owning scope of a qualified name — the segment before the first ":".
134
+ // Deliberately lenient where parseQn() throws: callers here hold names that
135
+ // may be unqualified (system consumers, ad-hoc jobs) and want undefined, not
136
+ // an exception.
137
+ export function qnScope(qualifiedName: string): string | undefined {
138
+ const idx = qualifiedName.indexOf(":");
139
+ return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
140
+ }
141
+
133
142
  // Build a fully-qualified entity name from a feature name + QN type + short
134
143
  // name, running both names through toKebab first. This is the canonical
135
144
  // "how the registry qualifies things" helper — both createRegistry and
@@ -42,7 +42,7 @@ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
42
42
  const contactEntity = createEntity({
43
43
  fields: {
44
44
  email: createTextField({ required: true, personal: "self", find: "exact" }),
45
- displayName: createTextField(),
45
+ displayName: createTextField({ personal: false, reason: "test_fixture" }),
46
46
  },
47
47
  });
48
48
  const contactTable = buildEntityTable("contact", contactEntity);
@@ -82,7 +82,7 @@ const signalsFeature = defineFeature("signals", (r) => {
82
82
  // absent from an old event's payload, unlike contact.email (self-subject via row id).
83
83
  const noteEntity = createEntity({
84
84
  fields: {
85
- authorId: createTextField(),
85
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
86
86
  body: createTextField({ personal: { of: "authorId" }, find: "none" }),
87
87
  },
88
88
  });
@@ -0,0 +1,186 @@
1
+ // #3043 — event attribution. append() stamps metadata.feature +
2
+ // metadata.handler from the ambient execution scope, below every envelope
3
+ // builder, so no writer can forget it.
4
+ //
5
+ // Claims pinned here:
6
+ // 1. Dispatch path: an event a write-handler appends carries the handler's
7
+ // qualified name and its owning feature.
8
+ // 2. In-process path: the entity-executor's CRUD event carries the SAME
9
+ // attribution although nothing was passed through its signature.
10
+ // 3. MSP-apply: an event an apply writes is attributed to the consumer, not
11
+ // to the handler that started the chain.
12
+ // 4. No scope: a bare append() stamps the sentinel, never a guess.
13
+ // 5. appendRaw stays unstamped — historical rows keep their metadata verbatim
14
+ // and stay readable without the new fields.
15
+
16
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
17
+ import { UNATTRIBUTED_ORIGIN } from "@cosmicdrift/kumiko-types/event-store-types";
18
+ import { z } from "zod";
19
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
20
+ import { selectMany } from "../../db/query";
21
+ import { buildEntityTable } from "../../db/table-builder";
22
+ import { createEntity, createTextField, defineFeature } from "../../engine";
23
+ import {
24
+ resetEventStore,
25
+ setupTestStack,
26
+ type TestStack,
27
+ TestUsers,
28
+ unsafeCreateEntityTable,
29
+ } from "../../stack";
30
+ import { generateId as uuid } from "../../utils";
31
+ import { appendRaw } from "../admin-api";
32
+ import { append } from "../event-store";
33
+ import { eventsTable } from "../events-schema";
34
+
35
+ const orderEntity = createEntity({
36
+ table: "read_attribution_orders",
37
+ fields: {
38
+ item: createTextField({ personal: false, reason: "test_fixture", required: true }),
39
+ },
40
+ });
41
+
42
+ const orderTable = buildEntityTable("attr-order", orderEntity);
43
+
44
+ const PLACED = "attribution:event:placed";
45
+ const CONFIRMED = "attribution:event:confirmed";
46
+ const PLACE_HANDLER = "attribution:write:order:place";
47
+ const CONFIRMER_MSP = "attribution:projection:confirmer";
48
+
49
+ const attributionFeature = defineFeature("attribution", (r) => {
50
+ r.entity("attr-order", orderEntity);
51
+
52
+ const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
53
+ const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
54
+ piiFields: "none",
55
+ });
56
+
57
+ const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
58
+ entityName: "attr-order",
59
+ });
60
+
61
+ r.writeHandler(
62
+ "order:place",
63
+ z.object({ item: z.string() }),
64
+ async (event, ctx) => {
65
+ const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
66
+ if (!created.isSuccess) return created;
67
+ await ctx.unsafeAppendEvent({
68
+ aggregateId: String(created.data.id),
69
+ aggregateType: "attr-order",
70
+ type: placed.name,
71
+ payload: { orderId: String(created.data.id) },
72
+ });
73
+ return created;
74
+ },
75
+ { access: { roles: ["Admin"] } },
76
+ );
77
+
78
+ r.multiStreamProjection({
79
+ name: "confirmer",
80
+ apply: {
81
+ [placed.name]: async (event, _tx, ctx) => {
82
+ if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
83
+ await ctx.unsafeAppendEvent({
84
+ aggregateId: event.aggregateId,
85
+ aggregateType: "attr-order",
86
+ type: confirmed.name,
87
+ payload: { orderId: event.aggregateId },
88
+ });
89
+ },
90
+ },
91
+ });
92
+ });
93
+
94
+ let stack: TestStack;
95
+ const admin = TestUsers.admin;
96
+
97
+ beforeAll(async () => {
98
+ stack = await setupTestStack({ features: [attributionFeature], systemHooks: [] });
99
+ await unsafeCreateEntityTable(stack.db, orderEntity, "attr-order");
100
+ });
101
+
102
+ afterAll(async () => {
103
+ await stack.cleanup();
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await resetEventStore(stack, ["read_attribution_orders"]);
108
+ });
109
+
110
+ type Origin = { feature?: string; handler?: string };
111
+
112
+ async function originOf(type: string): Promise<Origin> {
113
+ const rows = await selectMany(stack.db, eventsTable);
114
+ const row = rows.find((r: Record<string, unknown>) => r["type"] === type);
115
+ expect(row).toBeDefined();
116
+ return (row?.["metadata"] ?? {}) as Origin;
117
+ }
118
+
119
+ describe("#3043 — event attribution from the execution scope", () => {
120
+ test("dispatch path: ctx.appendEvent stamps the handler and its feature", async () => {
121
+ await stack.http.writeOk(PLACE_HANDLER, { item: "widget" }, admin);
122
+
123
+ expect(await originOf(PLACED)).toMatchObject({
124
+ feature: "attribution",
125
+ handler: PLACE_HANDLER,
126
+ });
127
+ });
128
+
129
+ test("in-process path: the entity-executor CRUD event carries the same attribution", async () => {
130
+ await stack.http.writeOk(PLACE_HANDLER, { item: "sprocket" }, admin);
131
+
132
+ // Nothing is threaded through createEntityExecutor → EventStoreExecutor.write —
133
+ // the write runs inside the handler's scope, so the stamp finds it anyway.
134
+ expect(await originOf("attr-order.created")).toMatchObject({
135
+ feature: "attribution",
136
+ handler: PLACE_HANDLER,
137
+ });
138
+ });
139
+
140
+ test("MSP-apply: the follow-up event is attributed to the consumer, not the trigger", async () => {
141
+ await stack.http.writeOk(PLACE_HANDLER, { item: "gasket" }, admin);
142
+ await stack.eventDispatcher?.runOnce();
143
+
144
+ expect(await originOf(CONFIRMED)).toMatchObject({
145
+ feature: "attribution",
146
+ handler: CONFIRMER_MSP,
147
+ });
148
+ });
149
+
150
+ test("no scope: a bare append() stamps the sentinel instead of guessing", async () => {
151
+ const aggregateId = uuid();
152
+ await append(stack.db, {
153
+ aggregateId,
154
+ aggregateType: "attr-order",
155
+ tenantId: admin.tenantId,
156
+ expectedVersion: 0,
157
+ type: PLACED,
158
+ payload: { orderId: aggregateId },
159
+ metadata: { userId: admin.id },
160
+ });
161
+
162
+ expect(await originOf(PLACED)).toMatchObject({
163
+ feature: UNATTRIBUTED_ORIGIN,
164
+ handler: UNATTRIBUTED_ORIGIN,
165
+ });
166
+ });
167
+
168
+ test("appendRaw keeps historical metadata verbatim and stays readable", async () => {
169
+ const aggregateId = uuid();
170
+ await appendRaw(stack.db, {
171
+ aggregateId,
172
+ aggregateType: "attr-order",
173
+ tenantId: admin.tenantId,
174
+ expectedVersion: 0,
175
+ type: CONFIRMED,
176
+ payload: { orderId: aggregateId },
177
+ metadata: { userId: admin.id },
178
+ createdAt: Temporal.Instant.from("2023-01-15T10:00:00Z"),
179
+ createdBy: admin.id,
180
+ });
181
+
182
+ const origin = await originOf(CONFIRMED);
183
+ expect(origin.feature).toBeUndefined();
184
+ expect(origin.handler).toBeUndefined();
185
+ });
186
+ });
@@ -1,4 +1,8 @@
1
- import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
1
+ import {
2
+ type EventMetadata,
3
+ type StoredEvent,
4
+ UNATTRIBUTED_ORIGIN,
5
+ } from "@cosmicdrift/kumiko-types/event-store-types";
2
6
  // Value-only import, aliased to avoid shadowing the ambient global
3
7
  // `Temporal` TYPE this file's other Temporal.Instant annotations resolve
4
8
  // against (StoredEvent.createdAt et al. — importing the bare name here
@@ -6,6 +10,7 @@ import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event
6
10
  // as a runtime value on globalThis, so the un-aliased call below crashed
7
11
  // with "Temporal is not defined" outside boot paths that install it (#1480).
8
12
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
13
+ import { requestContext } from "../api/request-context";
9
14
  import { encryptEventPayloadPii } from "../crypto/event-pii";
10
15
  import type { DbRunner } from "../db";
11
16
  import { constraintOf, isUniqueViolation } from "../db/pg-error";
@@ -83,7 +88,7 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
83
88
  aggregateType: event.aggregateType,
84
89
  aggregateId: event.aggregateId,
85
90
  });
86
- const toStore = payload === event.payload ? event : { ...event, payload };
91
+ const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
87
92
  const newVersion = toStore.expectedVersion + 1;
88
93
  const eventVersion = toStore.eventVersion ?? 1;
89
94
 
@@ -116,6 +121,22 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
116
121
  }
117
122
  }
118
123
 
124
+ // #3043 — attribution is derived from the execution scope, never taken from
125
+ // the caller: a passed-in value can lie about who wrote the row, a derived
126
+ // one cannot. Hence overwrite rather than merge. appendRaw/appendRawBatch
127
+ // bypass this deliberately — they replay historical rows verbatim.
128
+ function stampOrigin(event: EventToAppend): EventToAppend {
129
+ const origin = requestContext.get();
130
+ return {
131
+ ...event,
132
+ metadata: {
133
+ ...event.metadata,
134
+ feature: origin?.feature ?? UNATTRIBUTED_ORIGIN,
135
+ handler: origin?.handler ?? UNATTRIBUTED_ORIGIN,
136
+ },
137
+ };
138
+ }
139
+
119
140
  type InsertReturn = { id: bigint; createdAt: Temporal.Instant };
120
141
 
121
142
  async function insertFirstEvent(
@@ -4,6 +4,7 @@ import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
5
5
  import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
6
6
  import { createDerivativesContext } from "../derivatives/derivatives-context";
7
+ import { qnScope } from "../engine/qualified-name";
7
8
  import { createSystemUser } from "../engine/system-user";
8
9
  import {
9
10
  type AppContext,
@@ -665,8 +666,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
665
666
 
666
667
  const runInSpan = async (): Promise<void> => {
667
668
  try {
668
- await requestContext.run({ requestId: jobRequestId, correlationId: jobCorrelationId }, () =>
669
- jobDef.handler(payload, jobContext),
669
+ await requestContext.run(
670
+ {
671
+ requestId: jobRequestId,
672
+ correlationId: jobCorrelationId,
673
+ // #3043 — events a job writes carry the job as their origin.
674
+ handler: jobName,
675
+ feature: qnScope(jobName),
676
+ },
677
+ () => jobDef.handler(payload, jobContext),
670
678
  );
671
679
  const duration = Date.now() - startTime;
672
680
  await options.onJobComplete?.(jobName, jobId, duration, logs);
@@ -97,18 +97,21 @@ export function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugi
97
97
  return usage.options;
98
98
  }
99
99
 
100
- function resolveLifecyclePlugin(registry: Registry): TenantLifecycleStatusPlugin | undefined {
100
+ export function resolveTenantLifecyclePlugin(
101
+ registry: Registry,
102
+ caller: string,
103
+ ): TenantLifecycleStatusPlugin | undefined {
101
104
  const usages = registry.getExtensionUsages(EXT_TENANT_LIFECYCLE_STATUS);
102
105
  if (usages.length > 1) {
103
106
  throw new InternalError({
104
- message: `dispatcher.resolveActiveMembership: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
107
+ message: `${caller}: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
105
108
  });
106
109
  }
107
110
  const usage = usages[0];
108
111
  if (!usage) return undefined;
109
112
  if (!isTenantLifecycleStatusPlugin(usage.options)) {
110
113
  throw new InternalError({
111
- message: `dispatcher.resolveActiveMembership: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
114
+ message: `${caller}: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
112
115
  });
113
116
  }
114
117
  return usage.options;
@@ -160,7 +163,10 @@ export async function resolveActiveMembershipFn(
160
163
  return { kind: "rejected", reason: "principal_blocked" };
161
164
  }
162
165
 
163
- const lifecyclePlugin = resolveLifecyclePlugin(registry);
166
+ const lifecyclePlugin = resolveTenantLifecyclePlugin(
167
+ registry,
168
+ "dispatcher.resolveActiveMembership",
169
+ );
164
170
  if (lifecyclePlugin) {
165
171
  const lifecycle = await lifecyclePlugin.resolveStatus(tenantId, { db });
166
172
  if (isTeardownRejected(lifecycle, policy)) {
@@ -1,6 +1,6 @@
1
1
  import { requestContext } from "../api/request-context";
2
2
  import type { DbRunner } from "../db/connection";
3
- import { toKebab } from "../engine/qualified-name";
3
+ import { qnScope, toKebab } from "../engine/qualified-name";
4
4
  import type { AppendEventArgs, Registry, TenantId } from "../engine/types";
5
5
  import { InternalError, validationErrorFromZod } from "../errors";
6
6
  import { isStreamArchived } from "../event-store/archive";
@@ -32,15 +32,6 @@ export type AppendDomainEventCoreDeps = {
32
32
  readonly callerFeature?: string;
33
33
  };
34
34
 
35
- // Extract the owning feature from a qualified event name. Events are
36
- // registered as "<feature>:event:<short>" (see registry.ts qualify()) so the
37
- // prefix before the first ":" is the owner. Falls back to undefined if the
38
- // name isn't qualified — callers then skip the cross-feature check.
39
- function eventOwnerFeature(qualifiedName: string): string | undefined {
40
- const idx = qualifiedName.indexOf(":");
41
- return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
42
- }
43
-
44
35
  // System-event prefix: events under this namespace bypass the registry +
45
36
  // ownership checks. Reserved for framework-internal coordination (step-
46
37
  // engine deferred dispatch, lifecycle signals). The matching MSP filters
@@ -71,7 +62,7 @@ export async function appendDomainEventCore(
71
62
  // into kebab-case for the event/handler names (pubsub-orders:event:…) — so
72
63
  // we compare the kebab form on both sides.
73
64
  if (deps.callerFeature && !isSystemEvent) {
74
- const owner = eventOwnerFeature(args.type);
65
+ const owner = qnScope(args.type);
75
66
  const callerKebab = toKebab(deps.callerFeature);
76
67
  if (owner && owner !== callerKebab) {
77
68
  throw new InternalError({
@@ -1,4 +1,4 @@
1
- import { requestContext } from "../api/request-context";
1
+ import { requestContext, runWithOrigin } from "../api/request-context";
2
2
  import type { SseBroker } from "../api/sse-broker";
3
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
@@ -907,7 +907,12 @@ export async function runHandlerInstrumented<T>(
907
907
  },
908
908
  async (span) => {
909
909
  try {
910
- const result = await inner();
910
+ // #3043 — everything the handler writes, including entity-executor
911
+ // writes below it, is attributed to this handler.
912
+ const result = await runWithOrigin(
913
+ { handler: type, feature: registry.getHandlerFeature(type) },
914
+ inner,
915
+ );
911
916
  if (operation === "write" && isFailedWriteResult(result)) {
912
917
  success = false;
913
918
  errorClass = result.error?.code ?? "UnknownError";
@@ -963,11 +968,18 @@ export async function* runStreamInstrumented<T>(
963
968
  attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
964
969
  });
965
970
  const it = inner();
971
+ // Each pull re-enters both scopes: AsyncLocalStorage does not survive a
972
+ // generator suspension, so wrapping inner() once would leave every event a
973
+ // stream writes unattributed.
974
+ const inScope = <R>(fn: () => R): R =>
975
+ runWithOrigin({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
976
+ observabilityContext.run({ activeSpan: span }, fn),
977
+ );
966
978
  try {
967
- let next = await observabilityContext.run({ activeSpan: span }, () => it.next());
979
+ let next = await inScope(() => it.next());
968
980
  while (!next.done) {
969
981
  yield next.value;
970
- next = await observabilityContext.run({ activeSpan: span }, () => it.next());
982
+ next = await inScope(() => it.next());
971
983
  }
972
984
  completedNormally = true;
973
985
  return next.value;
@@ -980,7 +992,7 @@ export async function* runStreamInstrumented<T>(
980
992
  } finally {
981
993
  // forward close so inner()'s finally still fires — yield* did this for free
982
994
  try {
983
- await observabilityContext.run({ activeSpan: span }, () => it.return?.(undefined));
995
+ await inScope(() => it.return?.(undefined));
984
996
  } catch (closeError) {
985
997
  // Only fold this in when nothing has already failed — a close-time
986
998
  // error while an earlier error is in flight would mask the real
@@ -14,6 +14,7 @@ import {
14
14
  } from "../db/queries/event-consumer";
15
15
  import { selectEventsHeadId } from "../db/queries/event-store";
16
16
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
17
+ import { qnScope } from "../engine/qualified-name";
17
18
  import type { AppContext } from "../engine/types";
18
19
  import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
19
20
  import {
@@ -240,9 +241,20 @@ export async function deliverEvents(
240
241
  const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
241
242
  const causationId = String(stored.id);
242
243
  const requestId = requestContext.generateId();
243
- await requestContext.run({ requestId, correlationId, causationId }, async () => {
244
- await consumer.handler(stored, context);
245
- });
244
+ // #3043 — an event this apply writes is attributed to the consumer, not
245
+ // to whatever wrote the triggering event; causationId already links back.
246
+ await requestContext.run(
247
+ {
248
+ requestId,
249
+ correlationId,
250
+ causationId,
251
+ handler: consumer.name,
252
+ feature: consumer.featureName ?? qnScope(consumer.name),
253
+ },
254
+ async () => {
255
+ await consumer.handler(stored, context);
256
+ },
257
+ );
246
258
  cursor = row.id;
247
259
  attempts = 0;
248
260
  lastError = null;
@@ -37,7 +37,7 @@ function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
37
37
  return createEntity({
38
38
  table: "fwbootguard_memos",
39
39
  fields: {
40
- ownerId: createTextField({ required: true }),
40
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
41
41
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
42
42
  },
43
43
  ...(access ? { access } : {}),
@@ -97,6 +97,56 @@ describe("generateE2ESpec", () => {
97
97
  ]);
98
98
  });
99
99
 
100
+ test("groups-only sections liefern Required-Felder, Fill-Ops und Text-Assertion", () => {
101
+ const feature = defineFeature("tasks", (r) => {
102
+ r.systemScope();
103
+ r.entity("task", taskEntity);
104
+ r.writeHandler(
105
+ defineEntityCreateHandler("task", taskEntity, {
106
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
107
+ }),
108
+ );
109
+ r.screen({
110
+ id: "task-list",
111
+ type: "entityList",
112
+ entity: "task",
113
+ columns: ["title", "status", "done"],
114
+ });
115
+ r.screen({
116
+ id: "task-edit",
117
+ type: "entityEdit",
118
+ entity: "task",
119
+ layout: {
120
+ sections: [
121
+ {
122
+ title: "tasks:section.basics",
123
+ fields: [],
124
+ groups: [
125
+ { title: "tasks:group.core", fields: ["title", "status"] },
126
+ { title: "tasks:group.state", fields: ["done"] },
127
+ ],
128
+ },
129
+ ],
130
+ },
131
+ });
132
+ });
133
+ const specs = generateE2ESpec(createRegistry([feature]));
134
+ const editSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-edit");
135
+
136
+ const validates = editSpecs.find((s) => s.kind === "edit-validates-required");
137
+ if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
138
+ expect(validates.requiredFields).toEqual(["title"]);
139
+
140
+ const persists = editSpecs.find((s) => s.kind === "edit-save-persists");
141
+ if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
142
+ expect(persists.fills).toEqual([
143
+ { kind: "fill", field: "title", value: "e2e title" },
144
+ { kind: "select", field: "status", value: "todo" },
145
+ { kind: "check", field: "done", value: true },
146
+ ]);
147
+ expect(persists.identifyingField).toBe("title");
148
+ });
149
+
100
150
  test("date emittiert fill, timestamp wird übersprungen (zwei Inputs seit #369)", () => {
101
151
  const entity = createEntity({
102
152
  table: "events",