dpdp-erasure-cli 1.0.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 (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,227 @@
1
+ import { fail } from "@/errors";
2
+ import type { S3AwsCredentials } from "../network/object-store/aws/type";
3
+ import { resolveAwsCredentials } from "../network";
4
+ import { signAwsRequest } from "../network/object-store/aws/sigv4";
5
+
6
+ export interface S3ChunkSampleOptions {
7
+ bucket: string;
8
+ key: string;
9
+ region: string;
10
+ credentials?: S3AwsCredentials;
11
+ env?: Record<string, string | undefined>;
12
+ fetchFn?: typeof fetch;
13
+ maxBytes?: number;
14
+ timeoutMs?: number;
15
+ }
16
+
17
+ export interface S3ClassificationSample {
18
+ bytes: Uint8Array;
19
+ warnings: string[];
20
+ binaryFormat: "parquet" | "avro" | null;
21
+ decompressed: boolean;
22
+ }
23
+
24
+ function encodeS3KeyPath(key: string): string {
25
+ return `/${key.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
26
+ }
27
+
28
+ function hasPrefix(bytes: Uint8Array, prefix: readonly number[]): boolean {
29
+ if (bytes.length < prefix.length) {
30
+ return false;
31
+ }
32
+
33
+ return prefix.every((byte, index) => bytes[index] === byte);
34
+ }
35
+
36
+ function detectBinaryFormat(key: string, bytes: Uint8Array, contentType: string | null): "parquet" | "avro" | null {
37
+ const normalizedKey = key.toLowerCase();
38
+ const normalizedContentType = contentType?.toLowerCase() ?? "";
39
+ if (normalizedKey.endsWith(".parquet") || normalizedContentType.includes("parquet") || hasPrefix(bytes, [0x50, 0x41, 0x52, 0x31])) {
40
+ return "parquet";
41
+ }
42
+
43
+ if (normalizedKey.endsWith(".avro") || normalizedContentType.includes("avro") || hasPrefix(bytes, [0x4f, 0x62, 0x6a, 0x01])) {
44
+ return "avro";
45
+ }
46
+
47
+ return null;
48
+ }
49
+
50
+ function shouldGunzip(key: string, contentEncoding: string | null, contentType: string | null): boolean {
51
+ const normalizedKey = key.toLowerCase();
52
+ const normalizedEncoding = contentEncoding?.toLowerCase() ?? "";
53
+ const normalizedContentType = contentType?.toLowerCase() ?? "";
54
+ return (
55
+ normalizedKey.endsWith(".gz") ||
56
+ normalizedKey.endsWith(".gzip") ||
57
+ normalizedEncoding.includes("gzip") ||
58
+ normalizedContentType.includes("gzip")
59
+ );
60
+ }
61
+
62
+ function copyToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
63
+ const copy = new Uint8Array(bytes.byteLength);
64
+ copy.set(bytes);
65
+ return copy.buffer as ArrayBuffer;
66
+ }
67
+
68
+ async function gunzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
69
+ if (typeof DecompressionStream !== "undefined") {
70
+ const stream = new Blob([copyToArrayBuffer(bytes)]).stream().pipeThrough(new DecompressionStream("gzip"));
71
+ return new Uint8Array(await new Response(stream).arrayBuffer());
72
+ }
73
+
74
+ fail({
75
+ code: "INTROSPECTOR_GZIP_UNSUPPORTED",
76
+ title: "Gzip decompression unavailable",
77
+ detail: "Runtime does not expose the Web DecompressionStream API required for non-blocking gzip introspection.",
78
+ category: "configuration",
79
+ retryable: false,
80
+ fatal: true,
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Fetches a bounded S3 object prefix using SigV4 and an HTTP Range request.
86
+ *
87
+ * The returned buffer may contain raw PII. Callers must finish classification and then
88
+ * wipe it with `.fill(0)` in a `finally` block.
89
+ *
90
+ * @param options - S3 target, credentials, and byte limit.
91
+ * @returns First object chunk, capped at `maxBytes`.
92
+ * @throws {WorkerError} When S3 rejects or cannot serve the bounded request.
93
+ */
94
+ export async function sampleS3ObjectChunk(options: S3ChunkSampleOptions): Promise<Uint8Array> {
95
+ const sample = await sampleS3ObjectForClassification(options);
96
+ return sample.bytes;
97
+ }
98
+
99
+ /**
100
+ * Fetches a bounded S3 object prefix and normalizes it for PII classification.
101
+ *
102
+ * Gzip prefixes are decompressed with Bun-native zlib utilities. Parquet and Avro prefixes are
103
+ * flagged as binary structured formats and returned as an empty wiped-safe byte view because regex
104
+ * scanning their binary pages creates high false-positive rates.
105
+ *
106
+ * @param options - S3 target, credentials, and byte limit.
107
+ * @returns Classification sample bytes plus structural warnings.
108
+ * @throws {WorkerError} When S3 rejects or cannot serve the bounded request.
109
+ */
110
+ export async function sampleS3ObjectForClassification(options: S3ChunkSampleOptions): Promise<S3ClassificationSample> {
111
+ const maxBytes = options.maxBytes ?? 1_048_576;
112
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 1_048_576) {
113
+ fail({
114
+ code: "INTROSPECTOR_S3_RANGE_INVALID",
115
+ title: "Invalid S3 introspection range",
116
+ detail: "S3 introspection reads are capped at 1 MiB.",
117
+ category: "validation",
118
+ retryable: false,
119
+ context: { maxBytes },
120
+ });
121
+ }
122
+
123
+ const fetchFn = options.fetchFn ?? fetch;
124
+ const timeoutMs = options.timeoutMs ?? 10_000;
125
+ const url = new URL(`https://${options.bucket}.s3.${options.region}.amazonaws.com${encodeS3KeyPath(options.key)}`);
126
+ const headers = new Headers({
127
+ range: `bytes=0-${maxBytes - 1}`,
128
+ });
129
+ const credentials = options.credentials ?? await resolveAwsCredentials({
130
+ env: options.env,
131
+ fetchFn,
132
+ timeoutMs: Math.min(timeoutMs, 2_000),
133
+ });
134
+ const signedHeaders = await signAwsRequest({
135
+ method: "GET",
136
+ url,
137
+ region: options.region,
138
+ service: "s3",
139
+ headers,
140
+ credentials,
141
+ });
142
+
143
+ const controller = new AbortController();
144
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
145
+ try {
146
+ const response = await fetchFn(url, {
147
+ method: "GET",
148
+ headers: signedHeaders,
149
+ signal: controller.signal,
150
+ redirect: "error",
151
+ });
152
+
153
+ if (!response.ok && response.status !== 206) {
154
+ fail({
155
+ code: response.status >= 500 || response.status === 429
156
+ ? "INTROSPECTOR_S3_RETRYABLE"
157
+ : "INTROSPECTOR_S3_REJECTED",
158
+ title: "S3 introspection failed",
159
+ detail: `S3 range request returned HTTP ${response.status}.`,
160
+ category: response.status >= 500 || response.status === 429 ? "network" : "external",
161
+ retryable: response.status >= 500 || response.status === 429,
162
+ fatal: response.status === 401 || response.status === 403,
163
+ context: { bucket: options.bucket, region: options.region },
164
+ });
165
+ }
166
+
167
+ const bytes = new Uint8Array(await response.arrayBuffer());
168
+ if (bytes.byteLength > maxBytes) {
169
+ bytes.fill(0);
170
+ fail({
171
+ code: "INTROSPECTOR_S3_RANGE_EXCEEDED",
172
+ title: "S3 introspection range exceeded",
173
+ detail: "S3 returned more bytes than the configured hard cap.",
174
+ category: "external",
175
+ retryable: false,
176
+ fatal: true,
177
+ context: { maxBytes },
178
+ });
179
+ }
180
+
181
+ const contentType = response.headers.get("content-type");
182
+ const contentEncoding = response.headers.get("content-encoding");
183
+ const binaryFormat = detectBinaryFormat(options.key, bytes, contentType);
184
+ if (binaryFormat) {
185
+ bytes.fill(0);
186
+ return {
187
+ bytes: new Uint8Array(0),
188
+ warnings: ["BINARY_FORMAT_DETECTED: Structural Metadata Scan Required."],
189
+ binaryFormat,
190
+ decompressed: false,
191
+ };
192
+ }
193
+
194
+ if (!shouldGunzip(options.key, contentEncoding, contentType)) {
195
+ return {
196
+ bytes,
197
+ warnings: [],
198
+ binaryFormat: null,
199
+ decompressed: false,
200
+ };
201
+ }
202
+
203
+ try {
204
+ const decompressed = await gunzipBytes(bytes);
205
+ bytes.fill(0);
206
+ return {
207
+ bytes: decompressed,
208
+ warnings: [],
209
+ binaryFormat: null,
210
+ decompressed: true,
211
+ };
212
+ } catch {
213
+ bytes.fill(0);
214
+ fail({
215
+ code: "INTROSPECTOR_S3_GZIP_INVALID",
216
+ title: "S3 gzip introspection failed",
217
+ detail: "S3 object prefix was marked gzip but could not be decompressed.",
218
+ category: "external",
219
+ retryable: false,
220
+ fatal: false,
221
+ context: { bucket: options.bucket, key: options.key },
222
+ });
223
+ }
224
+ } finally {
225
+ clearTimeout(timer);
226
+ }
227
+ }
@@ -0,0 +1,131 @@
1
+ import type { Sql } from "@/types";
2
+
3
+ export interface QualifiedTable {
4
+ schema: string;
5
+ table: string;
6
+ }
7
+
8
+ export interface DagTarget {
9
+ table: QualifiedTable;
10
+ parentTable: QualifiedTable | null;
11
+ constraintName: string | null;
12
+ childColumns: string[];
13
+ parentColumns: string[];
14
+ depth: number;
15
+ fkCondition: string;
16
+ }
17
+
18
+ export interface PotentialLogicalLink {
19
+ sourceTable: QualifiedTable;
20
+ targetTable: QualifiedTable;
21
+ column: string;
22
+ reason: string;
23
+ }
24
+
25
+ export interface ColumnTaxonomy {
26
+ table: QualifiedTable;
27
+ column: string;
28
+ dataType: string;
29
+ metadataScore: number;
30
+ contentMatchRatio: number;
31
+ confidence: number;
32
+ sampleSize: number;
33
+ matchedSignatures: string[];
34
+ }
35
+
36
+ export interface RunIntrospectorOptions {
37
+ sql: Sql;
38
+ rootTable: string;
39
+ defaultSchema?: string;
40
+ maxDepth?: number;
41
+ samplePercent?: number;
42
+ sampleLimit?: number;
43
+ threshold?: number;
44
+ generatedAt?: Date
45
+ }
46
+
47
+ export interface IntrospectorTargetDraft {
48
+ table: QualifiedTable;
49
+ parentTable: QualifiedTable | null;
50
+ fkCondition: string;
51
+ childColumns: string[];
52
+ parentColumns: string[];
53
+ piiColumns: ColumnTaxonomy[];
54
+ depth: number;
55
+ }
56
+
57
+ export interface IntrospectorDraft {
58
+ root: QualifiedTable;
59
+ maxDepth: number;
60
+ generatedAt: string;
61
+ schemaHash: string;
62
+ targets: IntrospectorTargetDraft[];
63
+ potentialLogicalLinks: PotentialLogicalLink[];
64
+ }
65
+
66
+ export interface CompileDagOptions {
67
+ sql: Sql;
68
+ rootTable: string;
69
+ defaultSchema?: string;
70
+ maxDepth?: number;
71
+ }
72
+
73
+ export interface ClassifierOptions {
74
+ sql: Sql;
75
+ targets: DagTarget[];
76
+ samplePercent?: number;
77
+ sampleLimit?: number;
78
+ threshold?: number;
79
+ }
80
+
81
+ export interface IntrospectorReportFinding {
82
+ table: string;
83
+ column: string;
84
+ dataType: string;
85
+ confidence: number;
86
+ metadataScore: number;
87
+ contentMatchRatio: number;
88
+ sampleSize: number;
89
+ matchedSignatures: string[];
90
+ }
91
+
92
+ export interface IntrospectorReportSummary {
93
+ rootTable: string;
94
+ generatedAt: string;
95
+ schemaHash: string;
96
+ targetCount: number;
97
+ tablesWithPii: number;
98
+ piiColumnCount: number;
99
+ highConfidenceCount: number;
100
+ reviewRequiredCount: number;
101
+ potentialLogicalLinkCount: number;
102
+ }
103
+
104
+ export interface IntrospectorReport {
105
+ summary: IntrospectorReportSummary;
106
+ findings: IntrospectorReportFinding[];
107
+ potentialLogicalLinks: PotentialLogicalLink[];
108
+ nextSteps: string[];
109
+ }
110
+
111
+ export interface VerifySchemaIntegrityOptions {
112
+ sql: Sql;
113
+ configPath: string;
114
+ env?: Record<string, string | undefined>;
115
+ }
116
+
117
+ export interface IntrospectorCliOptions {
118
+ url?: string;
119
+ root?: string;
120
+ schema?: string;
121
+ output?: string;
122
+ maxDepth?: string;
123
+ samplePercent?: string;
124
+ sampleLimit?: string;
125
+ threshold?: string;
126
+ config?: string;
127
+ verifyOnly?: boolean;
128
+ report?: string;
129
+ jsonReport?: string;
130
+ failOnReview?: boolean;
131
+ }
@@ -0,0 +1,101 @@
1
+ import { formatQualifiedTable, yamlScalar } from "./naming";
2
+ import type { IntrospectorDraft, IntrospectorTargetDraft, PotentialLogicalLink } from "./types";
3
+
4
+ function formatNumber(value: number): string {
5
+ return Number.isInteger(value) ? value.toFixed(1) : value.toFixed(3);
6
+ }
7
+
8
+ function renderPiiColumns(target: IntrospectorTargetDraft): string {
9
+ if (target.piiColumns.length === 0) {
10
+ return "[]";
11
+ }
12
+
13
+ return `[${target.piiColumns.map((column) => yamlScalar(column.column)).join(", ")}]`;
14
+ }
15
+
16
+ function renderTarget(target: IntrospectorTargetDraft): string[] {
17
+ const lines = [
18
+ ` - table: ${yamlScalar(formatQualifiedTable(target.table))}`,
19
+ ];
20
+
21
+ if (target.parentTable) {
22
+ lines.push(
23
+ ` parent: ${yamlScalar(formatQualifiedTable(target.parentTable))}`,
24
+ ` join: ${JSON.stringify(target.fkCondition)}`,
25
+ ` parent_columns: [${target.parentColumns.map((column) => yamlScalar(column)).join(", ")}]`,
26
+ ` child_columns: [${target.childColumns.map((column) => yamlScalar(column)).join(", ")}]`
27
+ );
28
+ }
29
+
30
+ for (const column of target.piiColumns) {
31
+ lines.push(
32
+ ` # Introspector Confidence: ${formatNumber(column.confidence)} (${column.matchedSignatures.join(", ") || "metadata"})`
33
+ );
34
+ }
35
+
36
+ lines.push(` pii_columns: ${renderPiiColumns(target)}`);
37
+ if (target.parentTable && target.piiColumns.length > 0) {
38
+ lines.push(" primary_key_columns: [id]");
39
+ }
40
+ if (target.parentTable && target.piiColumns.length > 0) {
41
+ lines.push(
42
+ " action: redact",
43
+ " mutation_rules:"
44
+ );
45
+ for (const column of target.piiColumns) {
46
+ lines.push(` ${yamlScalar(column.column)}: HMAC`);
47
+ }
48
+ }
49
+ return lines;
50
+ }
51
+
52
+ function renderLogicalLinks(links: readonly PotentialLogicalLink[]): string[] {
53
+ if (links.length === 0) {
54
+ return ["# Potential Logical Links: none detected"];
55
+ }
56
+
57
+ return links.map(
58
+ (link) =>
59
+ `# [Potential Logical Link] ${formatQualifiedTable(link.sourceTable)}.${link.column} <-> ${formatQualifiedTable(link.targetTable)}.${link.column} - ${link.reason}`
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Renders a deterministic YAML draft without depending on a YAML serializer.
65
+ *
66
+ * @param draft - Compiled DAG and classified PII targets.
67
+ * @returns `compliance.worker.yml.draft` content for DPO review.
68
+ */
69
+ export function renderIntrospectorYaml(draft: IntrospectorDraft): string {
70
+ const lines = [
71
+ "# AUTO-GENERATED BY INTROSPECTOR",
72
+ "# REVIEW REQUIRED: DPO must validate every table, join condition, and PII column before production use.",
73
+ `# Generated At: ${draft.generatedAt}`,
74
+ "",
75
+ "legal_attestation:",
76
+ " dpo_identifier: PENDING_REVIEW",
77
+ " configuration_version: introspector-draft",
78
+ " legal_review_date: PENDING_REVIEW",
79
+ ` schema_hash: ${draft.schemaHash}`,
80
+ " generated_by: compliance-introspector-v1",
81
+ " acknowledgment: PENDING_REVIEW",
82
+ "",
83
+ "legal_disclaimer:",
84
+ " text: \"Auto-generated by Compliance Worker. The DPO/Developer is responsible for verifying all logical links and PII mappings.\"",
85
+ "",
86
+ "rules:",
87
+ " - id: dpdp_standard",
88
+ ` root_table: ${yamlScalar(formatQualifiedTable(draft.root))}`,
89
+ ` max_depth: ${draft.maxDepth}`,
90
+ " targets:",
91
+ ];
92
+
93
+ for (const target of draft.targets) {
94
+ lines.push(...renderTarget(target));
95
+ }
96
+
97
+ lines.push("", ...renderLogicalLinks(draft.potentialLogicalLinks));
98
+ lines.push("");
99
+ return `${lines.join("\n")}`;
100
+ }
101
+