@cosmicdrift/kumiko-bundled-features 0.252.1 → 0.253.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 (76) 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/cap-counter/entity.ts +8 -1
  11. package/src/config/table.ts +4 -2
  12. package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
  13. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
  14. package/src/crypto-shredding/constants.ts +1 -0
  15. package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
  16. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  17. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
  18. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
  19. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
  20. package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
  21. package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
  22. package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
  23. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  24. package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
  25. package/src/custom-fields/entity.ts +24 -4
  26. package/src/data-retention/__tests__/resolver.test.ts +6 -4
  27. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  28. package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
  29. package/src/delivery/tables.ts +6 -2
  30. package/src/document-ingest-foundation/entity.ts +6 -2
  31. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
  32. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
  33. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
  34. package/src/folders/__tests__/folders.integration.test.ts +8 -1
  35. package/src/folders/entity.ts +27 -5
  36. package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
  37. package/src/form-draft/entity.ts +6 -1
  38. package/src/inbound-mail-foundation/entities.ts +57 -12
  39. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
  40. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
  41. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
  42. package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
  43. package/src/jobs/feature.ts +5 -0
  44. package/src/jobs/job-run-logger.ts +7 -2
  45. package/src/jobs/job-run-table.ts +11 -6
  46. package/src/ledger/entity.ts +27 -7
  47. package/src/managed-pages/screens/branding-screen.ts +27 -5
  48. package/src/managed-pages/table.ts +23 -4
  49. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
  50. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
  51. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
  52. package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
  53. package/src/notes-history/entity.ts +12 -2
  54. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
  55. package/src/personal-access-tokens/schema/api-token.ts +29 -1
  56. package/src/secrets/table.ts +1 -1
  57. package/src/sessions/schema/user-session.ts +4 -0
  58. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
  59. package/src/tags/__tests__/tags.integration.test.ts +8 -1
  60. package/src/tags/entity.ts +20 -5
  61. package/src/template-resolver/table.ts +8 -7
  62. package/src/template-resolver/user-content-table.ts +6 -4
  63. package/src/tenant/invitation-table.ts +6 -1
  64. package/src/tenant/membership-table.ts +2 -2
  65. package/src/tenant/schema/tenant.ts +22 -3
  66. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
  67. package/src/tier-engine/entity.ts +12 -2
  68. package/src/user/schema/user.ts +15 -1
  69. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
  70. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
  71. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
  72. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
  73. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  74. package/src/user-data-rights/schema/download-attempt.ts +25 -7
  75. package/src/user-data-rights/schema/download-token.ts +4 -0
  76. package/src/user-data-rights/schema/export-job.ts +6 -0
@@ -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);
@@ -36,9 +36,19 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
36
36
  "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
37
  access,
38
38
  fields: {
39
- entityType: createTextField({ required: true, maxLength: 64 }),
39
+ entityType: createTextField({
40
+ required: true,
41
+ maxLength: 64,
42
+ personal: false,
43
+ reason: "technical_reference",
44
+ }),
40
45
  // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
41
- entityId: createTextField({ required: true, maxLength: 128 }),
46
+ entityId: createTextField({
47
+ required: true,
48
+ maxLength: 128,
49
+ personal: false,
50
+ reason: "technical_reference",
51
+ }),
42
52
  // Never client-supplied — stamped by the deriveAuthorId preSave hook from
43
53
  // ctx.user.id (see feature.ts), so a note can't be authored as someone
44
54
  // else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
@@ -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);
@@ -23,11 +23,15 @@ export const apiTokenEntity = createEntity({
23
23
  required: true,
24
24
  maxLength: 36,
25
25
  access: { write: access.privileged },
26
+ personal: false,
27
+ reason: "pseudonymous_fk",
26
28
  }),
27
29
  tenantId: createTextField({
28
30
  required: true,
29
31
  maxLength: 36,
30
32
  access: { write: access.privileged },
33
+ personal: false,
34
+ reason: "pseudonymous_fk",
31
35
  }),
32
36
  name: createTextField({
33
37
  required: true,
@@ -36,19 +40,43 @@ export const apiTokenEntity = createEntity({
36
40
  personal: { of: "userId" },
37
41
  find: "none",
38
42
  }),
43
+ // SHA-256 hash of the plaintext token — credential material, but kept
44
+ // Klasse 1 deliberately: resolver.ts point-reads this row with a raw
45
+ // exact-match `{ tokenHash: hashPatToken(rawToken) }` query on the hot
46
+ // auth path. A subject annotation here would make encryptForDirectWrite
47
+ // store ciphertext while the resolver keeps querying for the plaintext
48
+ // hash — every bearer-token request would 401 once a KMS is active.
49
+ // Fixing that needs a blind-index-backed lookup (like email's
50
+ // find:"exact" companion column), a resolver.ts rewrite out of scope
51
+ // here — see PR body "Offene Fragen".
39
52
  tokenHash: createTextField({
40
53
  required: true,
41
54
  maxLength: 64,
42
55
  access: { write: access.privileged },
56
+ personal: false,
57
+ reason: "credential_hash_used_as_exact_lookup_key_2809",
43
58
  }),
59
+ // Short displayable prefix ("your tokens" list) — not the secret itself,
60
+ // but still identifies which of the user's tokens this row is. Kept
61
+ // Klasse 1: list.query.ts returns `r.prefix` straight through with no
62
+ // decrypt call (unlike `name`, which goes through decryptStoredPii) — a
63
+ // subject annotation would leak ciphertext to the UI once a KMS is
64
+ // active. See PR body "Offene Fragen".
44
65
  prefix: createTextField({
45
66
  required: true,
46
67
  maxLength: 16,
47
68
  access: { write: access.privileged },
69
+ personal: false,
70
+ reason: "credential_prefix_read_without_decrypt_2809",
48
71
  }),
49
72
  // JSON-encoded string[] of granted scope names — mirrors the roles-column
50
73
  // convention (parseRoles-style text); the resolver JSON.parses it.
51
- scopes: createTextField({ required: true, access: { write: access.privileged } }),
74
+ scopes: createTextField({
75
+ required: true,
76
+ access: { write: access.privileged },
77
+ personal: false,
78
+ reason: "technical_reference",
79
+ }),
52
80
  createdAt: createTimestampField({ required: true, access: { write: access.privileged } }),
53
81
  expiresAt: createTimestampField({ access: { write: access.privileged } }),
54
82
  revokedAt: createTimestampField({ access: { write: access.privileged } }),
@@ -50,7 +50,7 @@ export const tenantSecretEntity = createEntity({
50
50
  description:
51
51
  "One encrypted secret per tenant and key, holding the sealed envelope, the key-encryption-key version it was sealed under and its last rotation time; plaintext values are never stored or returned.",
52
52
  fields: {
53
- key: createTextField({ required: true }),
53
+ key: createTextField({ required: true, personal: false, reason: "technical_reference" }),
54
54
  kekVersion: createNumberField({ required: true, integer: true }),
55
55
  },
56
56
  });
@@ -33,11 +33,15 @@ export const userSessionEntity = createEntity({
33
33
  required: true,
34
34
  maxLength: 36,
35
35
  access: { write: access.privileged },
36
+ personal: false,
37
+ reason: "pseudonymous_fk",
36
38
  }),
37
39
  tenantId: createTextField({
38
40
  required: true,
39
41
  maxLength: 36,
40
42
  access: { write: access.privileged },
43
+ personal: false,
44
+ reason: "pseudonymous_fk",
41
45
  }),
42
46
  createdAt: createTimestampField({
43
47
  required: true,
@@ -47,8 +47,18 @@ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
47
47
  const projectEntity = createEntity({
48
48
  table: PROJECT_TABLE,
49
49
  fields: {
50
- teamId: createTextField({ required: true, maxLength: 64 }),
51
- name: createTextField({ required: true, maxLength: 64 }),
50
+ teamId: createTextField({
51
+ required: true,
52
+ maxLength: 64,
53
+ personal: false,
54
+ reason: "technical_reference",
55
+ }),
56
+ name: createTextField({
57
+ required: true,
58
+ maxLength: 64,
59
+ personal: false,
60
+ reason: "technical_reference",
61
+ }),
52
62
  },
53
63
  access: projectOwnership,
54
64
  });
@@ -33,7 +33,14 @@ const tagsFeature = createTagsFeature();
33
33
 
34
34
  const CREDIT_TABLE = "tags_test_credits";
35
35
  const NOTE_TABLE = "tags_test_notes";
36
- const hostFields = { name: createTextField({ required: true, maxLength: 64 }) };
36
+ const hostFields = {
37
+ name: createTextField({
38
+ required: true,
39
+ maxLength: 64,
40
+ personal: false,
41
+ reason: "technical_reference",
42
+ }),
43
+ };
37
44
  const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
38
45
  const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
39
46
  const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
@@ -28,11 +28,11 @@ export const tagEntity = createEntity({
28
28
  reason: "catalog_label",
29
29
  }),
30
30
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
31
- color: createTextField({ maxLength: 32 }),
31
+ color: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
32
32
  // Optional entity-type scope (GitLab project-vs-group labels): empty = global
33
33
  // (offered on every entity); a value like "note" restricts the tag to that
34
34
  // entityType in the picker. No enforcement on assign — purely a picker hint.
35
- scope: createTextField({ maxLength: 64 }),
35
+ scope: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
36
36
  },
37
37
  });
38
38
 
@@ -63,10 +63,25 @@ export function createTagAssignmentEntity(access?: EntityDefinition["access"]) {
63
63
  softDelete: true,
64
64
  access,
65
65
  fields: {
66
- tagId: createTextField({ required: true, maxLength: 64 }),
67
- entityType: createTextField({ required: true, maxLength: 64 }),
66
+ tagId: createTextField({
67
+ required: true,
68
+ maxLength: 64,
69
+ personal: false,
70
+ reason: "technical_reference",
71
+ }),
72
+ entityType: createTextField({
73
+ required: true,
74
+ maxLength: 64,
75
+ personal: false,
76
+ reason: "technical_reference",
77
+ }),
68
78
  // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
69
- entityId: createTextField({ required: true, maxLength: 128 }),
79
+ entityId: createTextField({
80
+ required: true,
81
+ maxLength: 128,
82
+ personal: false,
83
+ reason: "technical_reference",
84
+ }),
70
85
  },
71
86
  });
72
87
  }
@@ -18,16 +18,17 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } f
18
18
  export const templateResourceEntity = createEntity({
19
19
  table: "read_template_resources",
20
20
  fields: {
21
- slug: createTextField({ required: true }),
21
+ slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
22
22
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
23
- locale: createTextField({ required: true }),
23
+ locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
24
24
  // Human-facing heading for kind="text-block" (legal pages, marketing
25
25
  // copy). Nullable because render templates carry their subject inside
26
26
  // the body; a NOT NULL here would break every existing mail row.
27
- title: createTextField({}),
27
+ // Same authorship class as `content` below.
28
+ title: createTextField({ personal: false, reason: "is_business_data" }),
28
29
  // Folder path for the content tree, `/`-separated ("page/marketing").
29
30
  // null = root node.
30
- folder: createTextField({}),
31
+ folder: createTextField({ personal: false, reason: "technical_reference" }),
31
32
  // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
32
33
  // business data — kein end-user UGC.
33
34
  content: createLongTextField({
@@ -35,10 +36,10 @@ export const templateResourceEntity = createEntity({
35
36
  reason: "is_business_data",
36
37
  }),
37
38
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
38
- variableSchema: createLongTextField({}),
39
- linkedResources: createLongTextField({}),
39
+ variableSchema: createLongTextField({ personal: false, reason: "technical_reference" }),
40
+ linkedResources: createLongTextField({ personal: false, reason: "technical_reference" }),
40
41
  scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
41
- parentTemplateId: createTextField({}),
42
+ parentTemplateId: createTextField({ personal: false, reason: "technical_reference" }),
42
43
  status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
43
44
  },
44
45
  indexes: [
@@ -25,11 +25,13 @@ export const userContentEntryEntity = createEntity({
25
25
  table: "read_user_content_entries",
26
26
  fields: {
27
27
  ownerId: createTextField({ required: true, personal: "ref" }),
28
- slug: createTextField({ required: true }),
28
+ slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
29
29
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
30
- locale: createTextField({ required: true }),
31
- title: createTextField({}),
32
- folder: createTextField({}),
30
+ locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
31
+ // Organizational label for the entry (e.g. "Default signature"), not the
32
+ // owner's PII itself — that lives in `content` below.
33
+ title: createTextField({ personal: false, reason: "technical_reference" }),
34
+ folder: createTextField({ personal: false, reason: "technical_reference" }),
33
35
  content: createLongTextField({
34
36
  personal: { of: "ownerId" },
35
37
  find: "none",
@@ -61,7 +61,12 @@ export const tenantInvitationEntity = createEntity({
61
61
  email: createTextField({ required: true, maxLength: 320, personal: "self", find: "exact" }),
62
62
  // Membership-Rolle die dem User nach Accept gegeben wird. Default
63
63
  // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
64
- role: createTextField({ required: true, maxLength: 50 }),
64
+ role: createTextField({
65
+ required: true,
66
+ maxLength: 50,
67
+ personal: false,
68
+ reason: "technical_reference",
69
+ }),
65
70
  // Lifecycle-State. Default "pending"; transitions:
66
71
  // pending → accepted | cancelled | expired
67
72
  status: createSelectField({