@cosmicdrift/kumiko-bundled-features 0.252.1 → 0.254.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 (93) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/agent-manifest.test.ts +2 -2
  3. package/src/agent-tools/__tests__/agent-optout.test.ts +22 -3
  4. package/src/agent-tools/__tests__/agent-tools.integration.test.ts +7 -1
  5. package/src/agent-tools/__tests__/tool-catalog.test.ts +11 -4
  6. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +13 -2
  7. package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
  8. package/src/audit/__tests__/audit.integration.test.ts +2 -2
  9. package/src/billing-foundation/entities.ts +30 -5
  10. package/src/billing-foundation/handlers/process-event.write.ts +1 -0
  11. package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -0
  12. package/src/cap-counter/entity.ts +8 -1
  13. package/src/config/table.ts +4 -2
  14. package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
  15. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
  16. package/src/crypto-shredding/constants.ts +1 -0
  17. package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
  18. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  19. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
  20. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
  21. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
  22. package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
  23. package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
  24. package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
  25. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  26. package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
  27. package/src/custom-fields/entity.ts +24 -4
  28. package/src/data-retention/__tests__/resolver.test.ts +6 -4
  29. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  30. package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
  31. package/src/delivery/tables.ts +6 -2
  32. package/src/document-ingest-foundation/entity.ts +6 -2
  33. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
  34. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
  35. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
  36. package/src/folders/__tests__/folders.integration.test.ts +8 -1
  37. package/src/folders/entity.ts +27 -5
  38. package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
  39. package/src/form-draft/entity.ts +6 -1
  40. package/src/inbound-mail-foundation/entities.ts +57 -12
  41. package/src/inbound-mail-foundation/handlers/connect-account.write.ts +8 -4
  42. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +3 -4
  43. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
  44. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
  45. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
  46. package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
  47. package/src/jobs/feature.ts +5 -0
  48. package/src/jobs/job-run-logger.ts +7 -2
  49. package/src/jobs/job-run-table.ts +11 -6
  50. package/src/ledger/entity.ts +27 -7
  51. package/src/managed-pages/screens/branding-screen.ts +27 -5
  52. package/src/managed-pages/table.ts +23 -4
  53. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
  54. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
  55. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
  56. package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
  57. package/src/notes-history/entity.ts +63 -19
  58. package/src/notes-history/executor.ts +6 -1
  59. package/src/notes-history/feature.ts +7 -1
  60. package/src/notes-history/handlers/add-note.write.ts +19 -3
  61. package/src/notes-history/index.ts +7 -2
  62. package/src/notes-history/schemas.ts +5 -0
  63. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
  64. package/src/notes-history-user-data/__tests__/mention-forget.integration.test.ts +244 -0
  65. package/src/notes-history-user-data/hooks.ts +69 -16
  66. package/src/notes-history-user-data/index.ts +11 -2
  67. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  68. package/src/personal-access-tokens/schema/api-token.ts +29 -1
  69. package/src/secrets/table.ts +1 -1
  70. package/src/sessions/schema/user-session.ts +4 -0
  71. package/src/sessions/session-callbacks.ts +1 -0
  72. package/src/shared/encrypt-for-direct-write.ts +9 -3
  73. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
  74. package/src/tags/__tests__/tags.integration.test.ts +8 -1
  75. package/src/tags/entity.ts +20 -5
  76. package/src/template-resolver/table.ts +8 -7
  77. package/src/template-resolver/user-content-table.ts +6 -4
  78. package/src/tenant/invitation-table.ts +6 -1
  79. package/src/tenant/membership-table.ts +2 -2
  80. package/src/tenant/schema/tenant.ts +22 -3
  81. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
  82. package/src/tier-engine/entity.ts +12 -2
  83. package/src/user/schema/user.ts +15 -1
  84. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
  85. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
  86. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
  87. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
  88. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -0
  89. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  90. package/src/user-data-rights/index.ts +6 -1
  91. package/src/user-data-rights/schema/download-attempt.ts +25 -7
  92. package/src/user-data-rights/schema/download-token.ts +4 -0
  93. package/src/user-data-rights/schema/export-job.ts +6 -0
@@ -38,7 +38,12 @@ const REINDEX_ENTITY_JOB = "jobs:job:reindex-entity";
38
38
  const widgetEntity = createEntity({
39
39
  table: "read_reindex_job_widgets",
40
40
  fields: {
41
- name: createTextField({ required: true, searchable: true }),
41
+ name: createTextField({
42
+ required: true,
43
+ searchable: true,
44
+ personal: false,
45
+ reason: "technical_reference",
46
+ }),
42
47
  },
43
48
  });
44
49
 
@@ -12,8 +12,9 @@
12
12
  // the run becomes retriable via the existing jobs:write:retry gate
13
13
  // (retry.write.ts only allows retry from "failed").
14
14
 
15
- import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
16
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
17
+ import { encryptFailureError } from "../../job-run-logger";
17
18
  import { jobRunsTable } from "../../job-run-table";
18
19
 
19
20
  export const STALE_JOB_RUN_ERROR =
@@ -30,22 +31,43 @@ export async function markStaleJobRunsFailed(
30
31
  const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
31
32
  const now = Temporal.Now.instant();
32
33
 
34
+ // Fetch the matched rows first: `error` carries the same per-triggering-
35
+ // user subject annotation as job-run-logger.ts's onJobFailed path
36
+ // (personal: { of: "triggeredById" } on job-run-table.ts), and a batch
37
+ // updateMany can't encrypt per-row — different matched runs can belong to
38
+ // different users, each under their own DEK. One updateMany per row keeps
39
+ // this crash-recovery sweep's write on the same encrypted footing as the
40
+ // normal failure path instead of writing STALE_JOB_RUN_ERROR in the clear.
41
+ const stale = await selectMany<{ id: string; triggeredById: string | null }>(db, jobRunsTable, {
42
+ status: "running",
43
+ startedAt: { lt: cutoff },
44
+ });
45
+
33
46
  // duration is deliberately left untouched: we don't know when the run
34
47
  // actually died, only that it crossed the timeout, so recording a
35
48
  // duration would misrepresent it as measured. detail-screen/list-screen
36
49
  // both already render a null duration as "—".
37
- const updated = await updateMany(
38
- db,
39
- jobRunsTable,
40
- {
41
- status: "failed",
42
- error: STALE_JOB_RUN_ERROR,
43
- finishedAt: now,
44
- modifiedAt: now,
45
- modifiedById: "system",
46
- },
47
- { status: "running", startedAt: { lt: cutoff } },
48
- );
50
+ let runsMarkedFailed = 0;
51
+ for (const run of stale) {
52
+ const encryptedError = await encryptFailureError(STALE_JOB_RUN_ERROR, run.triggeredById);
53
+ const updated = await updateMany(
54
+ db,
55
+ jobRunsTable,
56
+ {
57
+ status: "failed",
58
+ error: encryptedError,
59
+ finishedAt: now,
60
+ modifiedAt: now,
61
+ modifiedById: "system",
62
+ },
63
+ // Re-assert status: "running" here (not just id) — closes the race
64
+ // window between the selectMany above and this write, where the run
65
+ // could have completed/failed normally in between and must not be
66
+ // clobbered back to "failed" with the stale-timeout message.
67
+ { id: run.id, status: "running" },
68
+ );
69
+ runsMarkedFailed += updated.length;
70
+ }
49
71
 
50
- return { runsMarkedFailed: updated.length };
72
+ return { runsMarkedFailed };
51
73
  }
@@ -48,6 +48,11 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
48
48
  r.systemScope();
49
49
  r.storeTable(jobRunsTableMeta, {
50
50
  reason: "direct_write.job_runs",
51
+ // payload/error carry personal:{of:"triggeredById"} (job-run-table.ts).
52
+ // job-run-logger.ts manually encrypts both before every insert/update
53
+ // (encryptStartedPayload/encryptFailureError) since this store bypasses
54
+ // the executor's automatic PII pipeline (#2243).
55
+ piiEncryptedOnWrite: true,
51
56
  });
52
57
  r.storeTable(jobRunLogsTableMeta, {
53
58
  reason: "read_side.job_run_logs",
@@ -106,8 +106,13 @@ async function encryptLogMessages<T extends { readonly message: string }>(
106
106
  // Mirrors encryptFailureError for the failure-path `error` column (#2307):
107
107
  // stored alongside logs[].message under the same triggering user's DEK, so
108
108
  // a log/error pair from the same failed run decrypt together or erase
109
- // together.
110
- async function encryptFailureError(error: string, triggeredById: string | null): Promise<string> {
109
+ // together. Exported so stale-run-sweep.ts's crash-recovery write (a
110
+ // separate, per-row batch path outside onJobFailed) applies the identical
111
+ // per-subject encryption instead of writing `error` in the clear.
112
+ export async function encryptFailureError(
113
+ error: string,
114
+ triggeredById: string | null,
115
+ ): Promise<string> {
111
116
  if (triggeredById === null) return error;
112
117
  const kms = configuredPiiSubjectKms();
113
118
  if (!kms) return error;
@@ -41,16 +41,21 @@ export type JobLogLevel = "info" | "warn" | "error";
41
41
  export const jobRunEntity = createEntity({
42
42
  table: "store_job_runs",
43
43
  fields: {
44
- jobName: createTextField({ required: true }),
45
- bullJobId: createTextField({ required: true }),
46
- status: createTextField({ required: true }),
47
- payload: createTextField(),
48
- error: createTextField(),
44
+ jobName: createTextField({ required: true, personal: false, reason: "system_metadata" }),
45
+ bullJobId: createTextField({ required: true, personal: false, reason: "system_metadata" }),
46
+ status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
47
+ // The run-started payload can carry arbitrary caller-supplied data,
48
+ // encrypted by job-run-logger.ts under the triggering user's DEK (see
49
+ // jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`.
50
+ payload: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
51
+ // Failure messages can echo payload content back — same subject/DEK as
52
+ // `payload` (job-run-logger.ts encryptFailureError).
53
+ error: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
49
54
  attempt: createNumberField({ required: true, default: 1, integer: true }),
50
55
  startedAt: createTimestampField({ required: true }),
51
56
  finishedAt: createTimestampField(),
52
57
  duration: createNumberField({ integer: true }),
53
- triggeredById: createTextField(),
58
+ triggeredById: createTextField({ personal: false, reason: "pseudonymous_fk" }),
54
59
  },
55
60
  });
56
61
 
@@ -19,12 +19,17 @@ export const accountEntity = createEntity({
19
19
  description:
20
20
  "One node of a tenant's chart of accounts: a name, an asset/liability/equity/income/expense type that decides how reports read its balance, an optional account code and an optional parent account forming the account tree; balances are derived from postings, never stored here.",
21
21
  fields: {
22
- name: createTextField({ required: true, maxLength: 120 }),
22
+ name: createTextField({
23
+ required: true,
24
+ maxLength: 120,
25
+ personal: false,
26
+ reason: "is_business_data",
27
+ }),
23
28
  type: createSelectField({ options: ACCOUNT_TYPES, required: true }),
24
29
  // Optional account number (Kontonummer / SKR code) — free text in v1.
25
- code: createTextField({ maxLength: 32 }),
30
+ code: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
26
31
  // Parent account id, or absent for a root account. No FK (event-sourced).
27
- parentId: createTextField({ maxLength: 64 }),
32
+ parentId: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
28
33
  },
29
34
  });
30
35
 
@@ -52,8 +57,13 @@ export const transactionEntity = createEntity({
52
57
  personal: false,
53
58
  reason: "is_business_data",
54
59
  }),
55
- // For a Storno entry this points at the reversed transaction's id.
56
- reference: createTextField({ maxLength: 120 }),
60
+ // Free-text memo (a reversal or confirmed schedule period overwrites it with
61
+ // a system-generated id, but a manual entry can carry caller-supplied text).
62
+ reference: createTextField({
63
+ maxLength: 120,
64
+ personal: false,
65
+ reason: "is_business_data",
66
+ }),
57
67
  status: createSelectField({ options: TRANSACTION_STATUS, required: true }),
58
68
  // Posting lines are born with the entry and never change on their own —
59
69
  // a correction is a new (reversing) entry. The embedded list validates
@@ -94,7 +104,17 @@ export const scheduleEntity = createEntity({
94
104
  endDate: createDateField(),
95
105
  interval: createSelectField({ options: SCHEDULE_INTERVALS, required: true }),
96
106
  amount: createNumberField({ required: true, min: 1, integer: true }),
97
- debitAccountId: createTextField({ required: true, maxLength: 64 }),
98
- creditAccountId: createTextField({ required: true, maxLength: 64 }),
107
+ debitAccountId: createTextField({
108
+ required: true,
109
+ maxLength: 64,
110
+ personal: false,
111
+ reason: "technical_reference",
112
+ }),
113
+ creditAccountId: createTextField({
114
+ required: true,
115
+ maxLength: 64,
116
+ personal: false,
117
+ reason: "technical_reference",
118
+ }),
99
119
  },
100
120
  });
@@ -38,16 +38,30 @@ export function createBrandingSettingsScreen(opts: {
38
38
  layoutPreset: BRANDING_QN.layoutPreset,
39
39
  },
40
40
  fields: {
41
- title: createTextField({ maxLength: 200 }),
41
+ title: createTextField({ maxLength: 200, personal: false, reason: "is_business_data" }),
42
42
  description: createTextField({
43
43
  maxLength: 500,
44
44
  multiline: { rows: 3 },
45
45
  personal: false,
46
46
  reason: "is_business_data",
47
47
  }),
48
- siteUrl: createTextField({ maxLength: 2000, format: "url" }),
49
- accentColor: createTextField({ maxLength: 9 }),
50
- logoUrl: createTextField({ maxLength: 2000, format: "url" }),
48
+ siteUrl: createTextField({
49
+ maxLength: 2000,
50
+ format: "url",
51
+ personal: false,
52
+ reason: "is_business_data",
53
+ }),
54
+ accentColor: createTextField({
55
+ maxLength: 9,
56
+ personal: false,
57
+ reason: "is_business_data",
58
+ }),
59
+ logoUrl: createTextField({
60
+ maxLength: 2000,
61
+ format: "url",
62
+ personal: false,
63
+ reason: "is_business_data",
64
+ }),
51
65
  layoutPreset: createSelectField({ options: LAYOUT_PRESETS }),
52
66
  },
53
67
  layout: {
@@ -74,7 +88,15 @@ export function createBrandingSettingsScreen(opts: {
74
88
  configKeys: { ...base.configKeys, customCss: BRANDING_QN.customCss },
75
89
  fields: {
76
90
  ...base.fields,
77
- customCss: createTextField({ maxLength: 8000, multiline: { rows: 12 } }),
91
+ // Tenant-admin-authored branding CSS (raw-CSS gated behind
92
+ // allowCustomCss), not end-user input — same category as the other
93
+ // branding fields above.
94
+ customCss: createTextField({
95
+ maxLength: 8000,
96
+ multiline: { rows: 12 },
97
+ personal: false,
98
+ reason: "is_business_data",
99
+ }),
78
100
  },
79
101
  layout: {
80
102
  sections: [
@@ -16,9 +16,28 @@ export const pageEntity = createEntity({
16
16
  description:
17
17
  "One tenant-editable public web page, unique per slug and language, holding a markdown body plus the title, SEO description and OG image, with a published flag that decides whether anonymous visitors are served it or get a 404.",
18
18
  fields: {
19
- slug: createTextField({ required: true, maxLength: 64, sortable: true, searchable: true }),
20
- lang: createTextField({ required: true, maxLength: 8, sortable: true }),
21
- title: createTextField({ required: true, maxLength: 200, searchable: true }),
19
+ slug: createTextField({
20
+ required: true,
21
+ maxLength: 64,
22
+ sortable: true,
23
+ searchable: true,
24
+ personal: false,
25
+ reason: "technical_reference",
26
+ }),
27
+ lang: createTextField({
28
+ required: true,
29
+ maxLength: 8,
30
+ sortable: true,
31
+ personal: false,
32
+ reason: "technical_reference",
33
+ }),
34
+ title: createTextField({
35
+ required: true,
36
+ maxLength: 200,
37
+ searchable: true,
38
+ personal: false,
39
+ reason: "is_business_data",
40
+ }),
22
41
  // Body + description sind vom Tenant-Admin authored Business-Content
23
42
  // (Markdown), keine User-Generated-PII. `multiline` → der entityEdit-
24
43
  // Renderer gibt ein <textarea> aus (createLongTextField hat aktuell
@@ -32,7 +51,7 @@ export const pageEntity = createEntity({
32
51
  reason: "is_business_data",
33
52
  }),
34
53
  description: createTextField({ maxLength: 500, personal: false, reason: "is_business_data" }),
35
- ogImage: createTextField({ maxLength: 2000 }),
54
+ ogImage: createTextField({ maxLength: 2000, personal: false, reason: "is_business_data" }),
36
55
  published: createBooleanField({ default: false }),
37
56
  },
38
57
  indexes: [{ unique: true, columns: ["tenantId", "slug", "lang"], name: "read_pages_unique" }],
@@ -43,7 +43,14 @@ const tenantId = testTenantId(1);
43
43
  const CONTACT_TABLE = "notes_kms_test_contacts";
44
44
  const contactEntity = createEntity({
45
45
  table: CONTACT_TABLE,
46
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
46
+ fields: {
47
+ name: createTextField({
48
+ required: true,
49
+ maxLength: 64,
50
+ personal: false,
51
+ reason: "technical_reference",
52
+ }),
53
+ },
47
54
  });
48
55
  const contactFixtureFeature = defineFeature("notes-kms-test-contact-fixture", (r) => {
49
56
  r.entity("contact", contactEntity);
@@ -69,7 +69,14 @@ const teamOwnership: NonNullable<EntityDefinition["access"]> = {
69
69
  const PROJECT_TABLE = "notes_ownership_test_projects";
70
70
  const projectEntity: EntityDefinition = createEntity({
71
71
  table: PROJECT_TABLE,
72
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
72
+ fields: {
73
+ name: createTextField({
74
+ required: true,
75
+ maxLength: 64,
76
+ personal: false,
77
+ reason: "technical_reference",
78
+ }),
79
+ },
73
80
  });
74
81
  const projectFixtureFeature = defineFeature("notes-ownership-test-project-fixture", (r) => {
75
82
  r.entity("project", projectEntity);
@@ -48,8 +48,18 @@ function createProjectEntity(access?: EntityDefinition["access"]): EntityDefinit
48
48
  return createEntity({
49
49
  table: PROJECT_TABLE,
50
50
  fields: {
51
- teamId: createTextField({ required: true, maxLength: 64 }),
52
- name: createTextField({ required: true, maxLength: 64 }),
51
+ teamId: createTextField({
52
+ required: true,
53
+ maxLength: 64,
54
+ personal: false,
55
+ reason: "technical_reference",
56
+ }),
57
+ name: createTextField({
58
+ required: true,
59
+ maxLength: 64,
60
+ personal: false,
61
+ reason: "technical_reference",
62
+ }),
53
63
  },
54
64
  access,
55
65
  });
@@ -62,7 +72,14 @@ const guardedProjectEntity = createProjectEntity(projectOwnership);
62
72
  // too, not just made-up names.
63
73
  const contactEntity = createEntity({
64
74
  table: "notes_pv_test_contacts",
65
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
75
+ fields: {
76
+ name: createTextField({
77
+ required: true,
78
+ maxLength: 64,
79
+ personal: false,
80
+ reason: "technical_reference",
81
+ }),
82
+ },
66
83
  });
67
84
 
68
85
  const fixturesFeature = defineFeature("notes-pv-test-fixtures", (r) => {
@@ -36,7 +36,14 @@ const notesHistoryFeature = createNotesHistoryFeature();
36
36
  const CONTACT_TABLE = "notes_history_test_contacts";
37
37
  const contactEntity = createEntity({
38
38
  table: CONTACT_TABLE,
39
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
39
+ fields: {
40
+ name: createTextField({
41
+ required: true,
42
+ maxLength: 64,
43
+ personal: false,
44
+ reason: "technical_reference",
45
+ }),
46
+ },
40
47
  });
41
48
  const contactFixtureFeature = defineFeature("notes-history-test-contact-fixture", (r) => {
42
49
  r.entity("contact", contactEntity);
@@ -17,18 +17,26 @@ import {
17
17
  // that is the whole point of a note *history* instead of the single
18
18
  // overwritable textarea this bundle replaces (solon#13).
19
19
  //
20
- // `body` is about the HOST entity (entityType/entityId), not the author —
21
- // the author is never the data subject of a note's content, so it is
22
- // deliberately NOT `personal: { of: "authorId" }`. That axis would
23
- // crypto-shred every note's content the moment its author's data-rights key
24
- // is destroyed (an employee leaving triggers a forget), garbling unrelated
25
- // business notes about customers/projects/etc. — data loss, not privacy.
26
- // `body` stays plaintext (`personal: false`), same call as tags' catalog
27
- // `name` field. GDPR erasure of the AUTHOR still shreds `authorName`
28
- // (`personal: { of: "authorId" }`, the name as stamped at write time) —
29
- // `body` and the note itself are unaffected. If a note's body incidentally
30
- // names or describes a third party, that isn't tracked by field annotation
31
- // today; no PII hooks exist for note content.
20
+ // `body` is about the HOST entity (entityType/entityId), not the author — the
21
+ // author is never the data subject of a note's content, so it is deliberately
22
+ // NOT `personal: { of: "authorId" }`. That axis would crypto-shred every
23
+ // note's content the moment its author's data-rights key is destroyed (an
24
+ // employee leaving triggers a forget), garbling unrelated business notes
25
+ // about customers/projects/etc. — data loss, not privacy.
26
+ //
27
+ // `body` is instead `personal: { of: "id" }` (Row-Subject, fw#2596/#2786):
28
+ // the note's OWN row is its content's subject, so `body` is encrypted under a
29
+ // per-note key that nothing else shares. That key gets erased only when
30
+ // `note-mention` (below) names this note as reached by a forgotten subject
31
+ // (see `notes-history-user-data/hooks.ts`) — a note with no mention on the
32
+ // forgotten subject is untouched. GDPR erasure of the AUTHOR still separately
33
+ // shreds `authorName` (`personal: { of: "authorId" }`, the name as stamped at
34
+ // write time); the two erasures are independent.
35
+ //
36
+ // Known gap (fw#2596, not fixed by this entity): prose that names a third
37
+ // party without a structured @-mention is not tracked — regex/NLP over
38
+ // free text is unreliable. The operator's manual `forget-subject` path
39
+ // covers what this structured trigger misses.
32
40
  export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
33
41
  return createEntity({
34
42
  table: "read_note_entries",
@@ -36,9 +44,19 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
36
44
  "One note attached to a host entity by entityType and entityId, holding the note body plus the id and the display name of the author as it stood when the note was written. Rows are append-only: a correction is a further note, never an edit of this one.",
37
45
  access,
38
46
  fields: {
39
- entityType: createTextField({ required: true, maxLength: 64 }),
47
+ entityType: createTextField({
48
+ required: true,
49
+ maxLength: 64,
50
+ personal: false,
51
+ reason: "technical_reference",
52
+ }),
40
53
  // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
41
- entityId: createTextField({ required: true, maxLength: 128 }),
54
+ entityId: createTextField({
55
+ required: true,
56
+ maxLength: 128,
57
+ personal: false,
58
+ reason: "technical_reference",
59
+ }),
42
60
  // Never client-supplied — stamped by the deriveAuthorId preSave hook from
43
61
  // ctx.user.id (see feature.ts), so a note can't be authored as someone
44
62
  // else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
@@ -56,14 +74,40 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
56
74
  body: createLongTextField({
57
75
  required: true,
58
76
  maxLength: 20_000,
59
- // Describes the host entity, not the author — no per-user subject to
60
- // crypto-shred against. `personal: false` silences the
61
- // user-content-name heuristic (`body` is on PII_USER_OWNED_NAME_HINTS).
62
- personal: false,
63
- reason: "note_content_not_owned_by_author",
77
+ // Row-Subject (fw#2596/#2786): the note itself is the subject, so its
78
+ // key is erased per-note via `note-mention` lookups, not tied to any
79
+ // user's own forget.
80
+ personal: { of: "id" },
81
+ find: "none",
64
82
  }),
65
83
  },
66
84
  });
67
85
  }
68
86
 
69
87
  export const noteEntryEntity = createNoteEntryEntity();
88
+
89
+ // note-mention — join row recording that a note names a subject via a
90
+ // structured @-mention in the editor (never parsed out of `body` text — see
91
+ // the header comment above). One row per (note, mentioned subject); a note
92
+ // with several mentions gets several rows.
93
+ //
94
+ // `subjectId` is the plain FK to the mentioned user's id — `personal: "ref"`
95
+ // marks it as a subject reference for the GDPR-hook-coverage boot guard
96
+ // (same role as `authorId` on note-entry above), not itself encrypted.
97
+ // `notes-history-user-data/hooks.ts` looks this table up by `subjectId` when
98
+ // a user is forgotten, to find every note whose row-subject key must be
99
+ // erased; see that file for the cascade and the retention-consult step it
100
+ // runs first.
101
+ export function createNoteMentionEntity() {
102
+ return createEntity({
103
+ table: "read_note_mentions",
104
+ description:
105
+ "One row recording that a note names a subject via a structured @-mention, keyed by noteId and the mentioned subject's id.",
106
+ fields: {
107
+ noteId: createTextField({ required: true, maxLength: 64 }),
108
+ subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
109
+ },
110
+ });
111
+ }
112
+
113
+ export const noteMentionEntity = createNoteMentionEntity();
@@ -1,7 +1,12 @@
1
1
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
- import { noteEntryEntity } from "./entity";
2
+ import { noteEntryEntity, noteMentionEntity } from "./entity";
3
3
 
4
4
  export const { executor: noteEntryExecutor, table: noteEntryTable } = createEntityExecutor(
5
5
  "note-entry",
6
6
  noteEntryEntity,
7
7
  );
8
+
9
+ export const { executor: noteMentionExecutor, table: noteMentionTable } = createEntityExecutor(
10
+ "note-mention",
11
+ noteMentionEntity,
12
+ );
@@ -22,7 +22,7 @@ import {
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
23
  import { hasWhereRule } from "../shared";
24
24
  import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants";
25
- import { createNoteEntryEntity } from "./entity";
25
+ import { createNoteEntryEntity, noteMentionEntity } from "./entity";
26
26
  import { createAddNoteHandler } from "./handlers/add-note.write";
27
27
  import { NOTES_HISTORY_FEATURE_I18N } from "./i18n";
28
28
 
@@ -43,6 +43,12 @@ function registerNotesHistory(
43
43
 
44
44
  const entity = createNoteEntryEntity(ownership);
45
45
  r.entity("note-entry", entity);
46
+ // No write/query handler of its own — populated only as a side effect of
47
+ // add-note (see handlers/add-note.write.ts), looked up by
48
+ // notes-history-user-data's forget cascade. Registering the entity (not
49
+ // just building it inline in the handler) is what makes it visible to the
50
+ // registry-wide GDPR boot guards and to executor.ts's table/projection setup.
51
+ r.entity("note-mention", noteMentionEntity);
46
52
 
47
53
  r.writeHandler(createAddNoteHandler(access, parents));
48
54
  r.queryHandler(
@@ -4,7 +4,7 @@ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/error
4
4
  import { decryptStoredPii, parentRowIsVisible } from "../../shared";
5
5
  import { userTable } from "../../user";
6
6
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
7
- import { noteEntryExecutor } from "../executor";
7
+ import { noteEntryExecutor, noteMentionExecutor } from "../executor";
8
8
  import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
9
9
 
10
10
  // add-note — appends a note-entry to (entityType, entityId). authorId is
@@ -69,11 +69,27 @@ export function createAddNoteHandler(
69
69
  authorName = null;
70
70
  }
71
71
 
72
- return noteEntryExecutor.create(
73
- { ...payload, authorId: event.user.id, authorName },
72
+ const { mentions, ...notePayload } = payload;
73
+ const created = await noteEntryExecutor.create(
74
+ { ...notePayload, authorId: event.user.id, authorName },
74
75
  event.user,
75
76
  ctx.db,
76
77
  );
78
+ if (!created.isSuccess) return created;
79
+
80
+ // Same tx as the note create above (ctx.db) — a mention-row failure
81
+ // rolls the note back with it, same as the framework's own nested-write
82
+ // parent+child pattern (dispatch-write.ts).
83
+ for (const subjectId of new Set(mentions ?? [])) {
84
+ const mentionResult = await noteMentionExecutor.create(
85
+ { noteId: created.data.id, subjectId },
86
+ event.user,
87
+ ctx.db,
88
+ );
89
+ if (!mentionResult.isSuccess) return mentionResult;
90
+ }
91
+
92
+ return created;
77
93
  },
78
94
  };
79
95
  }
@@ -6,8 +6,13 @@ export {
6
6
  NotesHistoryHandlers,
7
7
  NotesHistoryQueries,
8
8
  } from "./constants";
9
- export { noteEntryEntity } from "./entity";
10
- export { noteEntryExecutor, noteEntryTable } from "./executor";
9
+ export { noteEntryEntity, noteMentionEntity } from "./entity";
10
+ export {
11
+ noteEntryExecutor,
12
+ noteEntryTable,
13
+ noteMentionExecutor,
14
+ noteMentionTable,
15
+ } from "./executor";
11
16
  export {
12
17
  createNotesHistoryFeature,
13
18
  type NotesHistoryFeatureOptions,
@@ -4,9 +4,14 @@ import { z } from "zod";
4
4
  // needed here since note-entry has no deterministic aggregate-id derived
5
5
  // from these fields (see aggregate-id note in entity.ts): a literal value
6
6
  // can't shift stream-id tuple boundaries when there is no such tuple.
7
+ // `mentions` carries structured @-mentions from the editor as user ids —
8
+ // never parsed out of `body` text (regex/NLP over free text is unreliable,
9
+ // see entity.ts). Capped well above any realistic note; a client sending more
10
+ // is a bug, not a use case worth accommodating.
7
11
  export const addNotePayloadSchema = z.object({
8
12
  entityType: z.string().min(1).max(64),
9
13
  entityId: z.string().min(1).max(128),
10
14
  body: z.string().trim().min(1).max(20_000),
15
+ mentions: z.array(z.uuid()).max(50).optional(),
11
16
  });
12
17
  export type AddNotePayload = z.infer<typeof addNotePayloadSchema>;
@@ -30,7 +30,14 @@ const other = createTestUser({ id: 2, roles: ["TenantMember"] });
30
30
  const CONTACT_TABLE = "notes_user_data_test_contacts";
31
31
  const contactEntity = createEntity({
32
32
  table: CONTACT_TABLE,
33
- fields: { name: createTextField({ required: true, maxLength: 64 }) },
33
+ fields: {
34
+ name: createTextField({
35
+ required: true,
36
+ maxLength: 64,
37
+ personal: false,
38
+ reason: "technical_reference",
39
+ }),
40
+ },
34
41
  });
35
42
  const contactFixtureFeature = defineFeature("notes-user-data-test-contact-fixture", (r) => {
36
43
  r.entity("contact", contactEntity);