@kernhq/module-hr 0.16.0 → 0.17.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 (49) hide show
  1. package/dist/contract/index.d.ts +1 -0
  2. package/dist/contract/index.d.ts.map +1 -1
  3. package/dist/contract/index.js +1 -0
  4. package/dist/contract/index.js.map +1 -1
  5. package/dist/contract/models.d.ts +1 -0
  6. package/dist/contract/models.d.ts.map +1 -1
  7. package/dist/contract/models.js +15 -0
  8. package/dist/contract/models.js.map +1 -1
  9. package/dist/contract/permissions.d.ts +8 -0
  10. package/dist/contract/permissions.d.ts.map +1 -1
  11. package/dist/contract/permissions.js +20 -0
  12. package/dist/contract/permissions.js.map +1 -1
  13. package/dist/contract/privacy.d.ts +860 -0
  14. package/dist/contract/privacy.d.ts.map +1 -0
  15. package/dist/contract/privacy.js +412 -0
  16. package/dist/contract/privacy.js.map +1 -0
  17. package/dist/contract/router.d.ts +815 -0
  18. package/dist/contract/router.d.ts.map +1 -1
  19. package/dist/contract/router.js +105 -0
  20. package/dist/contract/router.js.map +1 -1
  21. package/dist/server/router.d.ts +876 -0
  22. package/dist/server/router.d.ts.map +1 -1
  23. package/dist/server/router.js +312 -2
  24. package/dist/server/router.js.map +1 -1
  25. package/dist/server/schema.d.ts +397 -1
  26. package/dist/server/schema.d.ts.map +1 -1
  27. package/dist/server/schema.js +109 -0
  28. package/dist/server/schema.js.map +1 -1
  29. package/dist/server/services/audit.d.ts +249 -0
  30. package/dist/server/services/audit.d.ts.map +1 -0
  31. package/dist/server/services/audit.js +230 -0
  32. package/dist/server/services/audit.js.map +1 -0
  33. package/dist/server/services/people.d.ts +53 -1
  34. package/dist/server/services/people.d.ts.map +1 -1
  35. package/dist/server/services/people.js +74 -1
  36. package/dist/server/services/people.js.map +1 -1
  37. package/dist/server/services/privacy.d.ts +514 -0
  38. package/dist/server/services/privacy.d.ts.map +1 -0
  39. package/dist/server/services/privacy.js +972 -0
  40. package/dist/server/services/privacy.js.map +1 -0
  41. package/migrations/0011_privacy.sql +77 -0
  42. package/migrations/meta/0011_snapshot.json +4450 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +1 -1
  45. package/src/contract/index.ts +1 -0
  46. package/src/contract/models.ts +15 -0
  47. package/src/contract/permissions.ts +22 -0
  48. package/src/contract/privacy.ts +452 -0
  49. package/src/contract/router.ts +121 -0
@@ -71,6 +71,13 @@
71
71
  "when": 1787851246006,
72
72
  "tag": "0010_approval_timeouts",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 11,
77
+ "version": "7",
78
+ "when": 1787858088123,
79
+ "tag": "0011_privacy",
80
+ "breakpoints": true
74
81
  }
75
82
  ]
76
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -14,5 +14,6 @@ export * from './leave.js'
14
14
  export * from './models.js'
15
15
  export * from './permissions.js'
16
16
  export * from './policies.js'
17
+ export * from './privacy.js'
17
18
  export * from './router.js'
18
19
  export * from './settings.js'
@@ -71,6 +71,21 @@ export const Person = z.object({
71
71
  * from data the directory does not even fetch. So the one place that does the nulling says so.
72
72
  */
73
73
  personnelHidden: z.boolean().default(false),
74
+ /**
75
+ * When this person's record was erased on request, or null.
76
+ *
77
+ * Erasure is redaction, not deletion: the identifying columns are cleared and the rows a payroll
78
+ * audit needs survive against a person who is no longer nameable. So an erased record still
79
+ * appears — in a headcount, behind a ledger entry, as the subject of an approval from two years
80
+ * ago — and a screen that cannot tell it apart from an ordinary one shows a blank name and lets
81
+ * the reader conclude the record is broken.
82
+ *
83
+ * The date rather than a boolean, because the two questions a reader has are "is this erased" and
84
+ * "when", and the first is `!== null`. `erasedBy` and `erasureReason` are deliberately *not* here:
85
+ * they answer an administrator's question, not a directory's, and putting them on the model every
86
+ * screen fetches would spread them further than the act they describe.
87
+ */
88
+ erasedAt: Timestamp.nullable(),
74
89
  createdAt: Timestamp,
75
90
  updatedAt: Timestamp,
76
91
  })
@@ -304,6 +304,27 @@ export const hrPermissions = definePermissions([
304
304
  dangerous: false,
305
305
  },
306
306
 
307
+ // ---------------------------------------------------------------- privacy
308
+ {
309
+ key: 'hr.privacy.manage',
310
+ label: 'Handle privacy requests: export, erase and retention',
311
+ description:
312
+ 'Subject access requests, erasure and retention horizons under GDPR and KVKK. An export ' +
313
+ 'contains decrypted bank and identity details, and an erasure cannot be undone. Grant to a ' +
314
+ 'named data-protection owner, not to a role.',
315
+ scope: 'workspace',
316
+ // Nobody by default, like `hr.person.view_sensitive` and for the same reason: whether anybody
317
+ // below an owner may erase a colleague is the workspace's decision, and it has to be made
318
+ // deliberately rather than inherited by everyone who happens to be an admin.
319
+ defaultRoles: [],
320
+ dangerous: true,
321
+ },
322
+ // One key, four procedures, one commit — `privacy.subjectAccess`, `privacy.erase`,
323
+ // `privacy.retention.get` and `privacy.retention.set` all ship gated on it. Four screens suggest
324
+ // four keys and the temptation is real; the cost of taking it is recorded twice in this repository
325
+ // already, at the removed `hr.overtime.*` keys above and at `directoryVisibleToMembers` in
326
+ // `settings.ts`. A separate `hr.privacy.view` arrives when a procedure asks for it and not before.
327
+
307
328
  // ---------------------------------------------------------------- fields
308
329
  {
309
330
  key: 'hr.field.manage',
@@ -352,4 +373,5 @@ export const HR_PERMISSIONS = {
352
373
  periodManage: 'hr.period.manage',
353
374
  approvalManage: 'hr.approval.manage',
354
375
  approvalDelegate: 'hr.approval.delegate',
376
+ privacyManage: 'hr.privacy.manage',
355
377
  } as const
@@ -0,0 +1,452 @@
1
+ import { Timestamp, WorkspaceId } from '@kernhq/contracts'
2
+ import { z } from 'zod'
3
+ import { ApprovalDecision, ApprovalRequest, ApprovalStep, Delegation } from './approvals.js'
4
+ import { AttendanceDay, Punch, Regularization } from './attendance.js'
5
+ import { LeaveLedgerEntry, LeaveRequest, LeaveType } from './leave.js'
6
+ import { Employment, IsoDate, OfficeAssignment, Person, PersonDocument, PersonSensitive } from './models.js'
7
+ import { ResolvedPolicy } from './policies.js'
8
+
9
+ /**
10
+ * Subject access, erasure and retention — the three things a data-protection law asks a personnel
11
+ * system for, and the one part of HR whose whole job is to say what it did.
12
+ *
13
+ * Three positions hold this file together, and each of them is a decision the rest of the module
14
+ * already made somewhere else:
15
+ *
16
+ * - **Erasure is redaction, never deletion.** `leave_ledger` is append-only because a balance is the
17
+ * sum of it and nothing else; `punches` is append-only because an attendance record somebody can
18
+ * quietly rewrite is worth nothing in the dispute it exists for; `employments` is effective-dated
19
+ * so March is still answerable in June. Deleting an erased person's rows would break all three and
20
+ * would answer a payroll audit with "she left, so we deleted her file". So erasure clears the
21
+ * columns that identify a person and leaves every record that a wage, an entitlement or an
22
+ * authorisation was computed from. Afterwards the ledger still has somebody to attribute a day
23
+ * to, and nobody has a name.
24
+ * - **It says what it kept, per class, and why.** The `kept` half of the report is not a courtesy:
25
+ * an erasure that silently retains is the same failure as an export that silently omits, and the
26
+ * only difference is which of the two the subject finds out about later.
27
+ * - **Nothing here is a capability.** A workspace that could switch privacy off would be a workspace
28
+ * that stopped honouring subject requests, which fails the capability registry's own second rule —
29
+ * a switch must be reversible without destroying data, and this one would not even be a switch
30
+ * anybody should be offered. One permission key, `hr.privacy.manage`, gates all four procedures,
31
+ * and it ships in the same change as them.
32
+ */
33
+
34
+ const ws = { workspaceId: WorkspaceId }
35
+
36
+ // =====================================================================================
37
+ // retention
38
+ // =====================================================================================
39
+
40
+ /**
41
+ * A horizon in days, or null.
42
+ *
43
+ * **Null is the shipped value for every class and it means "keep indefinitely".** "Seven years" is a
44
+ * fact about one country and one document class, not about the world, so no number here is a
45
+ * default: the suggested figures live in the help text on the settings screen, beside the sentence
46
+ * saying Kern gives no legal advice. The workspace owner sets them, and where their legal entities
47
+ * differ they set them per entity — which this shape cannot yet express, and says so below.
48
+ */
49
+ const RetentionDays = z.number().int().min(1).max(36_500).nullable().default(null)
50
+
51
+ /**
52
+ * The classes retention is expressed in.
53
+ *
54
+ * They follow the redact/retain split rather than the table list, because that is the split a
55
+ * retention decision is actually made in: "how long do we keep the evidence behind a day sheet" is
56
+ * one question whatever tables it lands in.
57
+ */
58
+ export const RetentionClass = z.enum([
59
+ /** Where a punch happened, on what device, and what was typed on it. */
60
+ 'punchDetail',
61
+ /** The punch rows themselves — the evidence behind a day sheet. */
62
+ 'punches',
63
+ /** The derived day sheet: hours, overtime, lateness. This is pay. */
64
+ 'attendanceDays',
65
+ /** Ledger movements and the requests behind them. Also pay. */
66
+ 'leave',
67
+ /** The audit trail. At the horizon the values go and the rows stay — see `privacy.erase`. */
68
+ 'personHistory',
69
+ /** Contracts, payslips, identity scans. */
70
+ 'personDocuments',
71
+ /** People who have left, and have not been erased. */
72
+ 'terminatedPeople',
73
+ /** Who read somebody's identity, birth date or bank details. Itself personal data. */
74
+ 'sensitiveAccessLog',
75
+ ])
76
+ export type RetentionClass = z.infer<typeof RetentionClass>
77
+
78
+ /**
79
+ * How long HR keeps each class, per workspace.
80
+ *
81
+ * A table rather than module settings, and the reason is mechanical: `core.settings.setModule`
82
+ * validates the merged blob against the module's declared zod schema and a zod object strips what it
83
+ * does not declare — so a retention key stored beside `country` and `employeeNumberPrefix` would be
84
+ * deleted by the next unrelated settings write, silently, from a screen that never mentions
85
+ * retention. Extending `HrSettings` instead is the other honest route and is a change to a shared
86
+ * shape; this one keeps the numbers where nothing can quietly drop them.
87
+ *
88
+ * Workspace-wide for now. The honest end state is per legal entity — a Dutch entity and a Turkish
89
+ * one have different obligations, which is exactly why `periods` is already per legal entity — and
90
+ * the row carries a jsonb config so that move is an added column rather than a new table.
91
+ */
92
+ export const HrRetention = z.object({
93
+ punchDetail: RetentionDays,
94
+ punches: RetentionDays,
95
+ attendanceDays: RetentionDays,
96
+ leave: RetentionDays,
97
+ personHistory: RetentionDays,
98
+ personDocuments: RetentionDays,
99
+ terminatedPeople: RetentionDays,
100
+ sensitiveAccessLog: RetentionDays,
101
+ })
102
+ export type HrRetention = z.infer<typeof HrRetention>
103
+
104
+ /** One class, its horizon, and what has already passed it. */
105
+ export const RetentionClassState = z.object({
106
+ class: RetentionClass,
107
+ /** Null means "kept indefinitely", which is what every class ships as. */
108
+ days: z.number().int().nullable(),
109
+ /**
110
+ * How many rows are already older than the horizon — the dry run, asked for with `withCounts`.
111
+ *
112
+ * Null when no horizon is set (nothing to be past) or when the caller did not ask for counts. A
113
+ * retention screen that cannot say what a sweep would touch before it runs is a data-loss button
114
+ * with no confirmation, so this is the number that belongs beside every horizon on it.
115
+ */
116
+ dueNow: z.number().int().nullable(),
117
+ })
118
+ export type RetentionClassState = z.infer<typeof RetentionClassState>
119
+
120
+ export const RetentionSettings = z.object({
121
+ ...ws,
122
+ classes: z.array(RetentionClassState),
123
+ updatedAt: Timestamp.nullable(),
124
+ updatedBy: z.uuid().nullable(),
125
+ /**
126
+ * Whether anything in HR deletes on these horizons yet. **It is false, and saying so is the
127
+ * point.**
128
+ *
129
+ * The horizons are read in two places today: here, to count what has passed one, and by
130
+ * `privacy.erase`, to say under which horizon each surviving class was kept. No timed sweep acts
131
+ * on them. An unattended job that prunes personnel records is the one act in this module that
132
+ * cannot be undone by re-running anything, so it ships off, with a dry run and a per-run report
133
+ * naming every person it touched — and until it exists this field must not claim otherwise.
134
+ */
135
+ sweepEnabled: z.literal(false),
136
+ })
137
+ export type RetentionSettings = z.infer<typeof RetentionSettings>
138
+
139
+ // =====================================================================================
140
+ // erasure
141
+ // =====================================================================================
142
+
143
+ /** The units erasure reports in. One per group of columns that survive or go together. */
144
+ export const ErasureClass = z.enum([
145
+ 'identity',
146
+ 'sensitive',
147
+ 'history',
148
+ 'documents',
149
+ 'punches',
150
+ 'attendance',
151
+ 'leaveRequests',
152
+ 'leaveLedger',
153
+ 'employment',
154
+ 'officeAssignments',
155
+ 'approvals',
156
+ 'approvalDecisions',
157
+ 'regularizations',
158
+ 'delegations',
159
+ 'headship',
160
+ ])
161
+ export type ErasureClass = z.infer<typeof ErasureClass>
162
+
163
+ /** A class the erasure cleared, and exactly which columns it cleared. */
164
+ export const ErasureRedaction = z.object({
165
+ class: ErasureClass,
166
+ table: z.string(),
167
+ /** Rows that still had something to clear. Zero on a replay, which is what makes it replayable. */
168
+ rows: z.number().int(),
169
+ columns: z.array(z.string()),
170
+ })
171
+ export type ErasureRedaction = z.infer<typeof ErasureRedaction>
172
+
173
+ /**
174
+ * Why a record survived an erasure.
175
+ *
176
+ * An enum rather than a sentence: a reason composed on the server is English for every reader, and
177
+ * this one is read by the person who asked to be forgotten. The client renders it.
178
+ */
179
+ export const RetentionBasis = z.enum([
180
+ /** A wage, an entitlement or the hours behind one. Statutory wherever Kern ships a country pack. */
181
+ 'payRecord',
182
+ /** What changed, when, and who authorised it. Erasing the trail and erasing the data differ. */
183
+ 'auditTrail',
184
+ /** The workspace set a horizon for this class and it has not passed. */
185
+ 'retentionHorizon',
186
+ /** HR cannot remove it: the bytes belong to another module. See `caveats`. */
187
+ 'notRemovable',
188
+ /** It is somebody else's record. Erasing A is not authority to rewrite what A did to B's row. */
189
+ 'anotherPersonsRecord',
190
+ ])
191
+ export type RetentionBasis = z.infer<typeof RetentionBasis>
192
+
193
+ export const ErasureRetained = z.object({
194
+ class: ErasureClass,
195
+ table: z.string(),
196
+ rows: z.number().int(),
197
+ basis: RetentionBasis,
198
+ /** The horizon in force for the retention class covering this, when the workspace set one. */
199
+ retentionDays: z.number().int().nullable(),
200
+ })
201
+ export type ErasureRetained = z.infer<typeof ErasureRetained>
202
+
203
+ /**
204
+ * Things the response has to state in words, as keys the client renders.
205
+ *
206
+ * Each one is a promise this module would otherwise be making and not keeping.
207
+ */
208
+ export const ErasureCaveat = z.enum([
209
+ /**
210
+ * `person_documents` rows and their files both survive. `core.files.get` is the only file
211
+ * procedure a module can reach — there is no `files.delete` — so an erasure claiming to remove
212
+ * employee documents would null a row and leave the passport scan in the bucket. The rows stay
213
+ * because they are also the only remaining record of which files those are.
214
+ */
215
+ 'documentFilesRemain',
216
+ /** The profile photo pointer was cleared; the object behind it is in `filesRemaining`. */
217
+ 'photoFileOrphaned',
218
+ /** Sick notes attached to leave requests, same reason and same list. */
219
+ 'leaveDocumentFilesRemain',
220
+ /** The national identity number was kept, because the caller asked for it to be. */
221
+ 'nationalIdKeptForAudit',
222
+ /**
223
+ * History rows where this person was the **actor** on somebody else's record keep their values.
224
+ * Erasing A is not authority to rewrite the trail of what A did to B.
225
+ */
226
+ 'actorHistoryKept',
227
+ /** At least one attendance day or punch sits in a locked period and was left exactly as it is. */
228
+ 'lockedPeriodUntouched',
229
+ ])
230
+ export type ErasureCaveat = z.infer<typeof ErasureCaveat>
231
+
232
+ /**
233
+ * What an erasure did, or would do.
234
+ *
235
+ * The same shape either way, because a preview computed differently from the thing it previews is a
236
+ * preview that eventually lies — the rule `accrual.preview` is written under.
237
+ */
238
+ export const ErasureReport = z.object({
239
+ ...ws,
240
+ personId: z.uuid(),
241
+ /** True when nothing was written. See `privacy.erase` for why this is what the default gives you. */
242
+ dryRun: z.boolean(),
243
+ /**
244
+ * When this person was erased. Null on a dry run of a person who has not been.
245
+ *
246
+ * On a replay it carries the **first** erasure's timestamp: the second run finds nothing left to
247
+ * clear, reports zero rows everywhere, and does not restamp the tombstone.
248
+ */
249
+ erasedAt: Timestamp.nullable(),
250
+ /** The pseudonym the directory shows now. Never a name, never empty — `displayName` is not null. */
251
+ displayName: z.string(),
252
+ redacted: z.array(ErasureRedaction),
253
+ kept: z.array(ErasureRetained),
254
+ caveats: z.array(ErasureCaveat),
255
+ /**
256
+ * File objects in core's storage that this erasure could not delete, and which are recorded on the
257
+ * person row so a later release can finish the job rather than losing them.
258
+ */
259
+ filesRemaining: z.array(z.uuid()),
260
+ })
261
+ export type ErasureReport = z.infer<typeof ErasureReport>
262
+
263
+ // =====================================================================================
264
+ // the sensitive read log
265
+ // =====================================================================================
266
+
267
+ /**
268
+ * How a sensitive record was reached.
269
+ *
270
+ * Derived from the principal, never taken from the caller — a client-supplied value is one the
271
+ * reader controls, and this log exists to say what happened rather than what the reader claimed.
272
+ * `export` is the exception and is not reachable from a request: it is passed by the one server-side
273
+ * path that takes a bulk copy of a record, which is `privacy.subjectAccess`.
274
+ *
275
+ * There is no `mcp` and no `service`, and both omissions are the same rule rather than an oversight.
276
+ * Core's MCP proxy forwards a plain bearer token, so an assistant's read arrives as an ordinary
277
+ * `kind: 'user'` principal that nothing here can honestly tell from a browser; and a service
278
+ * principal carries no user id at all, so the read is refused before a row is built. A value nothing
279
+ * can ever write is the same lie as a permission key nothing asks about. See `services/audit.ts`.
280
+ */
281
+ export const SensitiveAccessVia = z.enum(['ui', 'api', 'export'])
282
+ export type SensitiveAccessVia = z.infer<typeof SensitiveAccessVia>
283
+
284
+ /**
285
+ * One read of somebody's identity, birth date or bank details.
286
+ *
287
+ * Written in the same transaction as the read, in HR's own schema. `mod_core.activity_events` also
288
+ * gets a best-effort copy so the workspace's single audit console shows that HR sensitive data was
289
+ * touched — but it cannot be the record, for two reasons: it is read behind `core.audit.view`,
290
+ * which owners and admins hold by default and `hr.person.view_sensitive` deliberately nobody does,
291
+ * so publishing the read there inverts the model the sensitive split exists to create; and the
292
+ * cross-service call is allowed to fail, which is right for a notification and wrong for evidence.
293
+ * A subject-access response that answers "who looked at my data" out of a log with silent holes is
294
+ * worse than one that says it cannot answer.
295
+ */
296
+ export const SensitiveAccess = z.object({
297
+ id: z.uuid(),
298
+ /** Whose record was read. */
299
+ personId: z.uuid(),
300
+ /**
301
+ * The account the disclosure is recorded against. Never null: a reader nobody can be named for is
302
+ * refused before the record is loaded, because "somebody read your bank details" is not an answer
303
+ * to give a subject.
304
+ */
305
+ actorUserId: z.uuid(),
306
+ /** The reader's own HR record, when they have one. Plenty of accounts are not employees. */
307
+ actorPersonId: z.uuid().nullable(),
308
+ /**
309
+ * Only the fields that actually came back with a value.
310
+ *
311
+ * Logging "read the record" when three of the four were empty overstates what happened, and this
312
+ * log is itself read by the subject.
313
+ */
314
+ fields: z.array(z.string()),
315
+ /** Why, when the caller said. Shown to the subject in their own bundle — nowhere else. */
316
+ purpose: z.string().max(500).nullable(),
317
+ via: SensitiveAccessVia,
318
+ at: Timestamp,
319
+ })
320
+ export type SensitiveAccess = z.infer<typeof SensitiveAccess>
321
+
322
+ // =====================================================================================
323
+ // the subject-access bundle
324
+ // =====================================================================================
325
+
326
+ /** `person_history`, with the values. Redacting it in a subject's own bundle defeats the purpose. */
327
+ export const PersonHistoryEntry = z.object({
328
+ id: z.uuid(),
329
+ field: z.string(),
330
+ from: z.unknown(),
331
+ to: z.unknown(),
332
+ at: Timestamp,
333
+ actorId: z.uuid().nullable(),
334
+ source: z.string(),
335
+ })
336
+ export type PersonHistoryEntry = z.infer<typeof PersonHistoryEntry>
337
+
338
+ /** A request exploded into days — what the balance was actually charged for. */
339
+ export const LeaveRequestDay = z.object({
340
+ id: z.uuid(),
341
+ requestId: z.uuid(),
342
+ date: IsoDate,
343
+ fraction: z.number(),
344
+ counted: z.boolean(),
345
+ status: z.string(),
346
+ })
347
+ export type LeaveRequestDay = z.infer<typeof LeaveRequestDay>
348
+
349
+ /** A section that hit the row cap. Named, with its numbers, so nothing is short by surprise. */
350
+ export const BundleTruncation = z.object({
351
+ section: z.string(),
352
+ returned: z.number().int(),
353
+ cap: z.number().int(),
354
+ })
355
+
356
+ /** Something this bundle does not contain, and why. */
357
+ export const BundleExclusion = z.object({
358
+ section: z.string(),
359
+ reason: z.enum([
360
+ /**
361
+ * The bytes of a file. HR holds the metadata and the id; the object lives in core's storage and
362
+ * a module can only ask for a download URL for one file at a time.
363
+ */
364
+ 'fileContentsNotExportable',
365
+ /** The caller asked for the fast path. */
366
+ 'notRequested',
367
+ ]),
368
+ })
369
+
370
+ export const SubjectAccessManifest = z.object({
371
+ ...ws,
372
+ personId: z.uuid(),
373
+ generatedAt: Timestamp,
374
+ generatedBy: z.uuid().nullable(),
375
+ /** From `packageVersion(import.meta.url)`, never a literal — the literals drifted for months. */
376
+ moduleVersion: z.string(),
377
+ truncated: z.array(BundleTruncation),
378
+ excluded: z.array(BundleExclusion),
379
+ })
380
+ export type SubjectAccessManifest = z.infer<typeof SubjectAccessManifest>
381
+
382
+ /**
383
+ * Everything HR holds about one person, including the parts they cannot reach through the product.
384
+ *
385
+ * Structured JSON rather than a zip of files, and one person at a time. Both are deliberate:
386
+ *
387
+ * - **The rows are not the problem; documents are.** Five years of four punches a day is about nine
388
+ * thousand rows and two to three megabytes — large for a response and bounded. A contract PDF,
389
+ * three payslips a year and an identity scan has no upper bound at all, which is why the file
390
+ * bytes are named in `manifest.excluded` instead of being streamed through a request handler.
391
+ * Every section is capped and every cap that bites is named in `manifest.truncated`.
392
+ * - **There is no workspace-wide variant, and there will not be.** Five hundred people is two and a
393
+ * half million rows with decrypted bank details in flight. `personId` is required and there is no
394
+ * filter.
395
+ *
396
+ * `sensitive` is decrypted here, which makes this the most dangerous object the module produces —
397
+ * so producing it writes a `sensitive_access_log` row with `via: 'export'` before it is returned. An
398
+ * export is a bulk read of that record, not a smaller one.
399
+ */
400
+ export const SubjectAccessBundle = z.object({
401
+ manifest: SubjectAccessManifest,
402
+ /**
403
+ * The whole row, not the directory card.
404
+ *
405
+ * `forViewer` narrows `personalEmail`, `phone`, `hiredOn` and `terminatedOn` for a reader who may
406
+ * not see somebody's personnel record. Those four are the subject's own; they are not narrowed
407
+ * here.
408
+ */
409
+ person: Person,
410
+ sensitive: PersonSensitive,
411
+ employment: z.array(Employment),
412
+ offices: z.array(OfficeAssignment),
413
+ history: z.array(PersonHistoryEntry),
414
+ /** Metadata and file ids. The bytes are named in `manifest.excluded`. */
415
+ documents: z.array(PersonDocument),
416
+ leave: z.object({
417
+ types: z.array(LeaveType),
418
+ requests: z.array(LeaveRequest),
419
+ days: z.array(LeaveRequestDay),
420
+ ledger: z.array(LeaveLedgerEntry),
421
+ /** The running balance down the ledger, so "why is my balance this number" is answered here. */
422
+ closingBalanceMinutes: z.number().int(),
423
+ }),
424
+ attendance: z.object({
425
+ punches: z.array(Punch),
426
+ days: z.array(AttendanceDay),
427
+ }),
428
+ regularizations: z.array(Regularization),
429
+ approvals: z.object({
430
+ /** Requests this person raised. */
431
+ raised: z.array(ApprovalRequest),
432
+ /** Steps they were named on as an approver, with the decisions filed against them. */
433
+ approverOn: z.array(ApprovalStep),
434
+ /** Their own decisions, wherever they were filed. */
435
+ decisions: z.array(ApprovalDecision),
436
+ }),
437
+ /** Both directions: what they handed over, and what was handed to them. */
438
+ delegations: z.object({
439
+ given: z.array(Delegation),
440
+ received: z.array(Delegation),
441
+ }),
442
+ /**
443
+ * The accrual, overtime and rounding policy resolved for this person today, with the rung that
444
+ * answered. Included whatever the `leave_accrual` capability is set to: it is the subject's own
445
+ * data, and "why does she accrue differently from her team" is the commonest follow-up a subject
446
+ * access request produces.
447
+ */
448
+ policiesInForce: z.array(ResolvedPolicy),
449
+ /** Who read their identity, birth date or bank details. A bundle without it is incomplete. */
450
+ accessLog: z.array(SensitiveAccess),
451
+ })
452
+ export type SubjectAccessBundle = z.infer<typeof SubjectAccessBundle>
@@ -62,6 +62,13 @@ import {
62
62
  PolicySubjectKind,
63
63
  ResolvedPolicy,
64
64
  } from './policies.js'
65
+ import {
66
+ ErasureReport,
67
+ HrRetention,
68
+ RetentionSettings,
69
+ SensitiveAccess,
70
+ SubjectAccessBundle,
71
+ } from './privacy.js'
65
72
 
66
73
  const ws = z.object({ workspaceId: WorkspaceId })
67
74
  const t = ['hr'] as const
@@ -1259,5 +1266,119 @@ export const hrContract = {
1259
1266
  .input(ws.extend({ fieldId: z.uuid() }))
1260
1267
  .output(ok),
1261
1268
  },
1269
+
1270
+ // ---------------------------------------------------------------- privacy
1271
+ /**
1272
+ * Subject access, erasure and retention. All four behind `hr.privacy.manage`, which is granted to
1273
+ * nobody by default and ships in the same change as these — see `privacy.ts` for why there is one
1274
+ * key rather than four, and why none of this is a capability.
1275
+ */
1276
+ privacy: {
1277
+ /**
1278
+ * Everything HR holds about one person, in one response.
1279
+ *
1280
+ * `personId` is required and there is no filter: a workspace-wide export is two and a half
1281
+ * million rows with decrypted bank details in flight, and it is not offered.
1282
+ *
1283
+ * The decrypt makes this a bulk read of the sensitive record, so it writes a
1284
+ * `sensitive_access_log` row with `via: 'export'` in the same transaction.
1285
+ */
1286
+ subjectAccess: baseContract
1287
+ .route({ method: 'GET', path: '/people/{personId}/privacy/subject-access', tags: t })
1288
+ .input(
1289
+ ws.extend({
1290
+ personId: z.uuid(),
1291
+ /** Recorded on the access-log row and shown to the subject in their own bundle. */
1292
+ purpose: z.string().max(500).nullish(),
1293
+ }),
1294
+ )
1295
+ .output(SubjectAccessBundle),
1296
+
1297
+ /**
1298
+ * Redact a person, and say what survived.
1299
+ *
1300
+ * **`dryRun` defaults to true**, which is the opposite of every other write in this module and
1301
+ * deliberate. This is the one irreversible act HR offers, so a caller that forgets the flag has
1302
+ * to get the preview rather than the erasure. It matters more than it reads: core generates an
1303
+ * MCP tool from every hosted module's OpenAPI document, so a procedure with a REST route is
1304
+ * agent-callable the day it ships, and the call made with no arguments has to be the harmless
1305
+ * one. The preview runs the same predicates the run does and returns the same shape.
1306
+ *
1307
+ * Nothing is deleted. The identifying columns are cleared and every record a wage, an
1308
+ * entitlement or an authorisation was computed from stays, listed in `kept` with the basis it
1309
+ * survived under. Running it twice is safe and reports zero rows the second time: each step
1310
+ * matches only rows that still have something to clear.
1311
+ */
1312
+ erase: baseContract
1313
+ .route({ method: 'POST', path: '/people/{personId}/privacy/erase', tags: t })
1314
+ .input(
1315
+ ws.extend({
1316
+ personId: z.uuid(),
1317
+ dryRun: z.boolean().default(true),
1318
+ /** Recorded on the person row. The only place this reason is kept. */
1319
+ reason: z.string().max(500).nullish(),
1320
+ /**
1321
+ * Keep the national identity number.
1322
+ *
1323
+ * The one decision in here that is not the module's to make. A Turkish payroll audit may
1324
+ * want it; a GDPR erasure request wants it gone first. The default clears it, the response
1325
+ * says which was done, and `caveats` carries `nationalIdKeptForAudit` when it did not.
1326
+ */
1327
+ keepNationalIdForAudit: z.boolean().default(false),
1328
+ }),
1329
+ )
1330
+ .output(ErasureReport),
1331
+
1332
+ /**
1333
+ * Who read somebody's identity, birth date or bank details.
1334
+ *
1335
+ * **About yourself this carries no permission**, which is why it has no `requires()` and sits in
1336
+ * `module.test.ts`'s `SELF_SERVICE` allowlist. Reading your own access log is a thing nobody may
1337
+ * lack, exactly like `people.me`; a grantable key here could only ever be one somebody could be
1338
+ * denied, and being denied sight of who has been looking at your bank details is not a state
1339
+ * this product should be able to express.
1340
+ *
1341
+ * About anybody else it needs `hr.privacy.manage`, and the handler is what asks — a `personId`
1342
+ * that is not the caller's own, or an `actorUserId` at all. The second is not a smaller question
1343
+ * than the first: "what has this account been looking at" is an investigation into a colleague,
1344
+ * and it is the query that makes this log something to be careful with rather than only
1345
+ * something to be reassured by.
1346
+ */
1347
+ accessLog: {
1348
+ list: baseContract
1349
+ .route({ method: 'GET', path: '/privacy/access-log', tags: t })
1350
+ .input(
1351
+ ws.extend({
1352
+ ...PageInput.shape,
1353
+ /** Whose record. Defaults to the caller's own, which is the self-service case. */
1354
+ personId: z.uuid().optional(),
1355
+ /** Whose reading. Always an investigation, so always `hr.privacy.manage`. */
1356
+ actorUserId: z.uuid().optional(),
1357
+ }),
1358
+ )
1359
+ .output(page(SensitiveAccess)),
1360
+ },
1361
+
1362
+ retention: {
1363
+ /**
1364
+ * The horizons, and — with `withCounts` — how much is already past each one.
1365
+ *
1366
+ * The counts are the dry run. They cost a query per class that has a horizon set, so they are
1367
+ * asked for rather than always computed; a retention screen should ask for them.
1368
+ */
1369
+ get: baseContract
1370
+ .route({ method: 'GET', path: '/privacy/retention', tags: t })
1371
+ .input(ws.extend({ withCounts: z.boolean().default(false) }))
1372
+ .output(RetentionSettings),
1373
+ /**
1374
+ * Set them. A field left out is unchanged; a field sent as `null` goes back to "keep
1375
+ * indefinitely", which is what every class ships as.
1376
+ */
1377
+ set: baseContract
1378
+ .route({ method: 'PUT', path: '/privacy/retention', tags: t })
1379
+ .input(ws.extend({ retention: HrRetention.partial() }))
1380
+ .output(RetentionSettings),
1381
+ },
1382
+ },
1262
1383
  }
1263
1384
  export type HrContract = typeof hrContract