@kindgi/compliance 0.0.0-bootstrap.0 → 0.1.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.
package/package.json CHANGED
@@ -1,7 +1,53 @@
1
1
  {
2
2
  "name": "@kindgi/compliance",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder so a trusted publisher can be attached. Releases are published from https://github.com/kindgi/kindgi-sdk with provenance; use 0.1.0 or later.",
3
+ "version": "0.1.0",
4
+ "description": "Kindgi™ compliance evidence contract. Types for the Evidence wire record and its sub-types (EvidenceActor, EvidenceSubject, EvidenceKind, EvidenceOutcome, EvidencePayload, EvidenceSignature, ProvenanceRef, ActorKind; ComplianceEvidence alias), the ComplianceProvider seam (EmitEvidenceInput, ListEvidenceFilter, EvidenceSigner), the ComplianceEvidenceGenerator interface with its inputs (RecordFromRunOptions, RunEvidenceContext, ModelCallSummary, ToolInvocationSummary, GuardrailResultSummary), EvidenceFilter, EvidencePage, SignedEvidenceBundle and EvidenceBundleBody, the classifier file format (ComplianceClassifierFile, Classification, LoadedClassifier), and the ComplianceError union. Runtime exports: the EVIDENCE_KINDS list, the pure auditEventToEvidence transform, and emitResolveEvent / emitLifecycleEvent, which append env-* and secret-* audit events to an AuditEventBinding. The generator implementation and the classifier loader are provided by the Kindgi runtime or by the caller.",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/kindgi/kindgi-sdk.git" }
7
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kindgi/kindgi-sdk.git",
9
+ "directory": "packages/compliance"
10
+ },
11
+ "homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/compliance#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kindgi/kindgi-sdk/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "dependencies": {
30
+ "@kindgi/audit-events": "0.1.0",
31
+ "@kindgi/platform": "0.1.0",
32
+ "@kindgi/types": "0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@kindgi/specs": "0.1.0",
36
+ "@types/node": "^22.10.5",
37
+ "typescript": "^5.7.3",
38
+ "vitest": "^2.1.8"
39
+ },
40
+ "engines": {
41
+ "node": ">=22.0.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "provenance": true
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.build.json",
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run",
51
+ "clean": "rm -rf dist *.tsbuildinfo"
52
+ }
53
+ }
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Per-kind classification decision. The retention + signing + export
6
+ * behavior for one event kind. Loaded from a classifier JSON file at
7
+ * deployment boot; consumed by retention cleanup and signed export.
8
+ */
9
+ export interface Classification {
10
+ readonly retention: {
11
+ /**
12
+ * Baseline retention in days. Rows older than this are eligible
13
+ * for deletion UNLESS `legalHold` is set OR `onDenyDays` is set
14
+ * and applies.
15
+ */
16
+ readonly days: number;
17
+ /**
18
+ * When set, denied/failed variants of this kind are retained
19
+ * longer than the baseline. Security investigations often need
20
+ * denials retained past the operational rows. Only meaningful
21
+ * for kinds that carry a deny signal (authz-decision:
22
+ * `allowed=false`).
23
+ */
24
+ readonly onDenyDays?: number;
25
+ /**
26
+ * When true, retention cleanup NEVER deletes rows of this kind.
27
+ * Legal-hold semantics — deletion requires operator action past
28
+ * this classifier. Overrides `days`.
29
+ */
30
+ readonly legalHold?: boolean;
31
+ };
32
+ /**
33
+ * Kinds that get Ed25519-signed at export time when a regulator
34
+ * bundle is generated. Signatures are byte-exact over canonical
35
+ * event bytes; the classifier doesn't hold the key, only the
36
+ * decision to sign.
37
+ */
38
+ readonly signed: boolean;
39
+ /**
40
+ * Kinds included in regulator-facing signed export bundles.
41
+ * Non-exportable kinds stay out of the bundle regardless of
42
+ * signature policy.
43
+ */
44
+ readonly exportable: boolean;
45
+ }
46
+
47
+ /** Wire schema for the classifier JSON file. Versioned envelope. */
48
+ export interface ComplianceClassifierFile {
49
+ readonly version: 1;
50
+ /** Default classification for kinds not explicitly listed. */
51
+ readonly default: Classification;
52
+ /** Per-kind overrides. Missing kind → `default`. */
53
+ readonly byKind: Readonly<Record<string, Classification>>;
54
+ }
55
+
56
+ /**
57
+ * Loaded classifier — same shape as the file, but the `resolve` helper
58
+ * returns the correct classification (per-kind or default) in one call.
59
+ *
60
+ * Built from a `ComplianceClassifierFile` by the runtime's loader or by
61
+ * the caller; deployments pass it to `@kindgi/api` as
62
+ * `CreateAppInput.complianceClassifier`. This package only defines the
63
+ * shape.
64
+ */
65
+ export interface LoadedClassifier {
66
+ readonly file: ComplianceClassifierFile;
67
+ /** Return the classification for the given event kind. */
68
+ resolve(kind: string): Classification;
69
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { ProjectId, Result, SigningKeyId } from '@kindgi/types';
5
+
6
+ import type { Evidence } from './types.js';
7
+
8
+ /**
9
+ * Errors emitted by the compliance surface. Every variant carries a `code`
10
+ * for pattern matching; messages are human-readable, not API contract.
11
+ */
12
+ export type ComplianceError =
13
+ | InvalidEvidenceError
14
+ | PersistenceError
15
+ | SignerFailureError
16
+ | SigningKeyMissingError
17
+ | SigningFailureError
18
+ | InvalidCursorError
19
+ | ProjectNotFoundError
20
+ | UnsupportedVersionError;
21
+
22
+ /**
23
+ * Structural validation of an evidence record against the compliance
24
+ * schema failed — e.g. `generator.recordFromRun` assembled a record that
25
+ * doesn't match the wire schema. A caller-shape bug or a schema drift,
26
+ * never a persistence issue.
27
+ */
28
+ export interface InvalidEvidenceError {
29
+ readonly code: 'invalid-evidence';
30
+ readonly message: string;
31
+ readonly issues: readonly { readonly path: string; readonly message: string }[];
32
+ }
33
+
34
+ /**
35
+ * The caller-plugged persistence binding threw or returned an error.
36
+ */
37
+ export interface PersistenceError {
38
+ readonly code: 'persistence-error';
39
+ readonly message: string;
40
+ readonly cause: unknown;
41
+ }
42
+
43
+ /**
44
+ * A caller-plugged `EvidenceSigner` function threw. Distinct from
45
+ * `SigningFailureError` (which is specific to the generator's own
46
+ * Ed25519 signing path, e.g. `exportSigned`) so callers can distinguish
47
+ * the two signing paths.
48
+ */
49
+ export interface SignerFailureError {
50
+ readonly code: 'signer-failure';
51
+ readonly message: string;
52
+ readonly cause: unknown;
53
+ }
54
+
55
+ /**
56
+ * The caller asked for a signed export but the requested `SigningKeyId`
57
+ * has no material in the mounted `SigningKeyBinding`. Typically a
58
+ * misconfiguration.
59
+ */
60
+ export interface SigningKeyMissingError {
61
+ readonly code: 'signing-key-missing';
62
+ readonly message: string;
63
+ readonly signingKeyId: SigningKeyId;
64
+ }
65
+
66
+ /**
67
+ * The Ed25519 signing primitive itself rejected the material — malformed
68
+ * key bytes, wrong length, or an underlying primitive that refused the
69
+ * input.
70
+ */
71
+ export interface SigningFailureError {
72
+ readonly code: 'signing-failure';
73
+ readonly message: string;
74
+ readonly signingKeyId: SigningKeyId;
75
+ readonly cause: unknown;
76
+ }
77
+
78
+ /**
79
+ * An evidence listing received a `cursor` that isn't decodable to a
80
+ * `(timestamp, id)` pair. Almost always a client bug.
81
+ */
82
+ export interface InvalidCursorError {
83
+ readonly code: 'invalid-cursor';
84
+ readonly message: string;
85
+ }
86
+
87
+ /**
88
+ * An evidence write (`recordFromRun`, `ComplianceProvider.emit`) named a
89
+ * `projectId` that doesn't exist inside the tenant. Distinct from
90
+ * `PersistenceError` so the API layer can map the caller-shape bug to a
91
+ * 404 wire response rather than 500.
92
+ */
93
+ export interface ProjectNotFoundError {
94
+ readonly code: 'project-not-found';
95
+ readonly message: string;
96
+ readonly projectId: ProjectId;
97
+ }
98
+
99
+ /**
100
+ * A record read from storage carried a `payload.version` newer than this
101
+ * reader knows how to normalize — typically during a rolling deploy.
102
+ */
103
+ export interface UnsupportedVersionError {
104
+ readonly code: 'unsupported-version';
105
+ readonly message: string;
106
+ readonly version: number;
107
+ readonly evidenceId: string;
108
+ }
109
+
110
+ export type EmitResult = Result<Evidence, ComplianceError>;
111
+ export type ListResult = Result<readonly Evidence[], ComplianceError>;
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { AuditEvent } from '@kindgi/audit-events';
5
+ import type { ComplianceEvidenceId, Result, RunId, SigningKeyId, TenantId } from '@kindgi/types';
6
+
7
+ import type { ComplianceError } from './errors.js';
8
+ import type {
9
+ ComplianceEvidence,
10
+ EvidenceFilter,
11
+ EvidenceOutcome,
12
+ EvidencePayload,
13
+ RecordFromRunOptions,
14
+ SignedEvidenceBundle,
15
+ } from './types.js';
16
+
17
+ /**
18
+ * The runtime service that turns terminated runs into audit-event
19
+ * records and produces signed export bundles.
20
+ *
21
+ * Public interface — deployments plug an implementation into
22
+ * `@kindgi/api` via `CreateAppInput.complianceGenerator`. The Kindgi
23
+ * runtime provides one; callers may supply their own. Implementations
24
+ * may expose additional subscription / auto-emit methods; this interface
25
+ * is the minimum surface `@kindgi/api` consumes.
26
+ */
27
+ export interface ComplianceEvidenceGenerator {
28
+ recordFromRun(
29
+ options: RecordFromRunOptions,
30
+ ): Promise<Result<{ readonly evidenceId: ComplianceEvidenceId }, ComplianceError>>;
31
+
32
+ exportSigned(
33
+ tenantId: TenantId,
34
+ filter: EvidenceFilter,
35
+ signingKeyId: SigningKeyId,
36
+ ): Promise<Result<SignedEvidenceBundle, ComplianceError>>;
37
+
38
+ describe(): { readonly name: string; readonly version: string };
39
+ }
40
+
41
+ /**
42
+ * Materialize a `ComplianceEvidence` wire record from an `AuditEvent`.
43
+ * The bundle body carries `ComplianceEvidence[]` for wire-shape
44
+ * stability — regulators verifying bundles read one record shape
45
+ * regardless of how events are stored.
46
+ *
47
+ * Pure function — no I/O, no random. Safe to call from any layer.
48
+ */
49
+ export function auditEventToEvidence(event: AuditEvent): ComplianceEvidence {
50
+ return {
51
+ id: event.id as ComplianceEvidenceId,
52
+ tenantId: event.tenantId,
53
+ // Tenant-level events carry no project; the key is omitted, never `undefined`.
54
+ ...(event.projectId !== undefined && { projectId: event.projectId }),
55
+ kind: event.kind,
56
+ timestamp: event.timestamp,
57
+ payload: extractDoc(event.payload) as EvidencePayload,
58
+ ...(event.outcome !== undefined && { outcome: event.outcome as EvidenceOutcome }),
59
+ ...(event.runId !== undefined && { provenanceRef: { runId: event.runId as RunId } }),
60
+ };
61
+ }
62
+
63
+ function extractDoc(payload: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
64
+ const inner = payload.doc;
65
+ if (inner !== null && typeof inner === 'object' && !Array.isArray(inner)) {
66
+ return inner as Readonly<Record<string, unknown>>;
67
+ }
68
+ return payload;
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ // ============ Wire types ============
5
+ export { EVIDENCE_KINDS } from './types.js';
6
+ export type {
7
+ ActorKind,
8
+ ComplianceEvidence,
9
+ ComplianceProvider,
10
+ EmitEvidenceInput,
11
+ Evidence,
12
+ EvidenceActor,
13
+ EvidenceKind,
14
+ EvidenceOutcome,
15
+ EvidencePayload,
16
+ EvidenceSignature,
17
+ EvidenceSigner,
18
+ EvidenceSubject,
19
+ ListEvidenceFilter,
20
+ ProvenanceRef,
21
+ } from './types.js';
22
+
23
+ // ============ Pipeline data types ============
24
+ export type {
25
+ EvidenceBundleBody,
26
+ EvidenceFilter,
27
+ EvidencePage,
28
+ GuardrailResultSummary,
29
+ ModelCallSummary,
30
+ RecordFromRunOptions,
31
+ RunEvidenceContext,
32
+ SignedEvidenceBundle,
33
+ ToolInvocationSummary,
34
+ } from './types.js';
35
+
36
+ // ============ Errors ============
37
+ export type {
38
+ ComplianceError,
39
+ EmitResult,
40
+ InvalidCursorError,
41
+ InvalidEvidenceError,
42
+ ListResult,
43
+ PersistenceError,
44
+ ProjectNotFoundError,
45
+ SignerFailureError,
46
+ SigningFailureError,
47
+ SigningKeyMissingError,
48
+ UnsupportedVersionError,
49
+ } from './errors.js';
50
+
51
+ // ============ Classifier ============
52
+ export type {
53
+ Classification,
54
+ ComplianceClassifierFile,
55
+ LoadedClassifier,
56
+ } from './classifier.js';
57
+
58
+ // ============ Generator interface + pure transform ============
59
+ export type { ComplianceEvidenceGenerator } from './generator.js';
60
+ export { auditEventToEvidence } from './generator.js';
61
+
62
+ // ============ Resolve + lifecycle event emitters ============
63
+ export { emitLifecycleEvent, emitResolveEvent } from './resolve-emit.js';
64
+ export type {
65
+ EmitLifecycleEventInput,
66
+ EmitResolveEventInput,
67
+ ResolveContext,
68
+ } from './resolve-emit.js';
@@ -0,0 +1,256 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Shared helpers for emitting `env-*` and `secret-*` audit events.
6
+ * `EnvBinding` and `SecretBinding` implementations call
7
+ * `emitResolveEvent` from their `resolve` paths and `emitLifecycleEvent`
8
+ * for `set` / `delete` / `rotate` / `revoke`.
9
+ *
10
+ * Writes land on the single `AuditEventBinding`. Classification
11
+ * (retention / signing / export) is a lens on top, not baked into the
12
+ * write path.
13
+ *
14
+ * Design guardrails:
15
+ * - Framework NEVER copies a secret plaintext `value` into a payload.
16
+ * Payloads carry only metadata (name, version, scope,
17
+ * caller, errorCode).
18
+ * - Emission is fire-and-forget: failures inside the binding are
19
+ * logged (via `console.warn`) but never poison the caller's op.
20
+ * - The `resolveContext.caller` discriminant maps to a distinct
21
+ * `actor` string.
22
+ */
23
+
24
+ import { randomUUID } from 'node:crypto';
25
+
26
+ import type { AuditEvent, AuditEventBinding } from '@kindgi/audit-events';
27
+ import type { Scope } from '@kindgi/platform';
28
+ import type { EnvName, ProjectId, TenantId, Timestamp } from '@kindgi/types';
29
+
30
+ /**
31
+ * Uniform caller-of-resolution shape. Structurally identical to
32
+ * `@kindgi/api.ResolveContext` (kept as a duplicate declaration here
33
+ * to avoid a compliance → api dep cycle — TS structural typing
34
+ * makes them assignable across the boundary).
35
+ */
36
+ export interface ResolveContext {
37
+ readonly runId?: string;
38
+ readonly deploymentId?: string;
39
+ readonly nodeId?: string;
40
+ readonly caller: 'dispatch' | 'deploy-sync' | 'admin-cli' | 'boot-bridge';
41
+ }
42
+
43
+ /** Input to `emitResolveEvent`. */
44
+ export interface EmitResolveEventInput {
45
+ readonly kind: 'env' | 'secret';
46
+ readonly scope: Scope;
47
+ readonly envName: EnvName;
48
+ readonly name: string;
49
+ readonly outcome: 'succeeded' | 'failed';
50
+ readonly resolveContext: ResolveContext;
51
+ readonly version?: number;
52
+ readonly errorCode?: string;
53
+ readonly auditEvents: AuditEventBinding;
54
+ readonly tenantId: TenantId;
55
+ readonly projectId: ProjectId;
56
+ }
57
+
58
+ /**
59
+ * Emit an `env-resolved` or `secret-resolved` audit event.
60
+ *
61
+ * The helper NEVER accepts nor emits the resolved value — the resolve
62
+ * outcome carries the plaintext to the caller; the audit event carries
63
+ * only the fact-of-resolution + version + scope + caller attribution.
64
+ *
65
+ * Fire-and-forget: any error is logged but swallowed. The resolve path
66
+ * must not fail because the audit sink is unreachable.
67
+ */
68
+ export async function emitResolveEvent(input: EmitResolveEventInput): Promise<void> {
69
+ const kind = input.kind === 'env' ? 'env-resolved' : 'secret-resolved';
70
+ const payload = buildResolvePayload(input);
71
+ const actor = actorFromResolveContext(input.resolveContext);
72
+ const event = buildEvent({
73
+ tenantId: input.tenantId,
74
+ projectId: input.projectId,
75
+ kind,
76
+ outcome: input.outcome,
77
+ actor,
78
+ runId: input.resolveContext.runId,
79
+ payload,
80
+ });
81
+ await safeEmit(input.auditEvents, event);
82
+ }
83
+
84
+ /**
85
+ * Input to `emitLifecycleEvent`. Used for env `set` / `delete` and
86
+ * secret `set` / `rotate` / `revoke` — the non-resolve write ops that
87
+ * still need audit-trail attribution.
88
+ */
89
+ export interface EmitLifecycleEventInput {
90
+ readonly evidenceKind: string;
91
+ readonly bindingKind: 'env' | 'secret';
92
+ readonly scope: Scope;
93
+ readonly envName: EnvName;
94
+ readonly name: string;
95
+ readonly outcome: 'succeeded' | 'failed';
96
+ /**
97
+ * Caller-of-op attribution. Same shape as the resolve path — env
98
+ * `set` from an admin CLI produces `actor = 'admin'`; a
99
+ * deployment-sync `set` produces `actor = 'system:<deploymentId>'`.
100
+ */
101
+ readonly resolveContext?: ResolveContext;
102
+ readonly version?: number;
103
+ readonly previousVersion?: number;
104
+ readonly errorCode?: string;
105
+ readonly hard?: boolean;
106
+ readonly reason?: string;
107
+ readonly auditEvents: AuditEventBinding;
108
+ readonly tenantId: TenantId;
109
+ readonly projectId: ProjectId;
110
+ }
111
+
112
+ /**
113
+ * Emit a non-resolve lifecycle event (`env-set` / `env-deleted` /
114
+ * `secret-set` / `secret-rotated` / `secret-rotation-started` /
115
+ * `secret-rotation-failed` / `secret-revoked` / `secret-hard-revoked`).
116
+ * Same fire-and-forget contract as `emitResolveEvent`.
117
+ */
118
+ export async function emitLifecycleEvent(input: EmitLifecycleEventInput): Promise<void> {
119
+ const payload = buildLifecyclePayload(input);
120
+ const actor =
121
+ input.resolveContext !== undefined ? actorFromResolveContext(input.resolveContext) : undefined;
122
+ const event = buildEvent({
123
+ tenantId: input.tenantId,
124
+ projectId: input.projectId,
125
+ kind: input.evidenceKind,
126
+ outcome: input.outcome,
127
+ actor,
128
+ runId: input.resolveContext?.runId,
129
+ payload,
130
+ });
131
+ await safeEmit(input.auditEvents, event);
132
+ }
133
+
134
+ // -----------------------------------------------------------------------------
135
+ // Internals
136
+ // -----------------------------------------------------------------------------
137
+
138
+ interface BuildEventInput {
139
+ readonly tenantId: TenantId;
140
+ readonly projectId: ProjectId;
141
+ readonly kind: string;
142
+ readonly outcome: string;
143
+ readonly actor: string | undefined;
144
+ readonly runId: string | undefined;
145
+ readonly payload: Readonly<Record<string, unknown>>;
146
+ }
147
+
148
+ function buildEvent(input: BuildEventInput): AuditEvent {
149
+ return {
150
+ id: randomUUID(),
151
+ tenantId: input.tenantId,
152
+ projectId: input.projectId,
153
+ kind: input.kind,
154
+ timestamp: new Date().toISOString() as unknown as Timestamp,
155
+ actor: input.actor ?? 'user:system',
156
+ outcome: input.outcome,
157
+ ...(input.runId !== undefined && { runId: input.runId }),
158
+ payload: { v: 1, doc: input.payload },
159
+ };
160
+ }
161
+
162
+ async function safeEmit(binding: AuditEventBinding, event: AuditEvent): Promise<void> {
163
+ try {
164
+ const result = await binding.append([event]);
165
+ if (result.kind === 'err') {
166
+ // eslint-disable-next-line no-console -- no injected logger here
167
+ console.warn(`[compliance] emit ${event.kind} audit event failed: ${result.error.message}`);
168
+ }
169
+ } catch (cause) {
170
+ // eslint-disable-next-line no-console -- no injected logger here
171
+ console.warn(
172
+ `[compliance] emit ${event.kind} audit event threw: ${
173
+ cause instanceof Error ? cause.message : String(cause)
174
+ }`,
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Map the `resolveContext.caller` discriminant to a subject string.
181
+ * Format matches the audit-events `actor` convention
182
+ * (`<type>:<id>` — `agent:<runId>`, `system:<deploymentId>`, `user:admin`,
183
+ * `user:system`).
184
+ */
185
+ function actorFromResolveContext(ctx: ResolveContext): string | undefined {
186
+ switch (ctx.caller) {
187
+ case 'dispatch':
188
+ return ctx.runId !== undefined ? `agent:${ctx.runId}` : 'agent:unknown';
189
+ case 'deploy-sync':
190
+ return ctx.deploymentId !== undefined ? `system:${ctx.deploymentId}` : 'system:deploy';
191
+ case 'admin-cli':
192
+ return 'user:admin';
193
+ case 'boot-bridge':
194
+ return 'user:system';
195
+ default:
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ function scopeToPayloadShape(scope: Scope): Readonly<Record<string, unknown>> {
201
+ if (scope.kind === 'tenant') {
202
+ return { kind: 'tenant', tenantId: scope.tenantId as unknown as string };
203
+ }
204
+ if (scope.kind === 'org') {
205
+ return {
206
+ kind: 'org',
207
+ tenantId: scope.tenantId as unknown as string,
208
+ orgId: scope.orgId as unknown as string,
209
+ };
210
+ }
211
+ return {
212
+ kind: 'project',
213
+ tenantId: scope.tenantId as unknown as string,
214
+ projectId: scope.projectId as unknown as string,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Build the resolve-event payload. Structural guarantee: NO `value`
220
+ * key ever appears. Every field is either metadata (name,
221
+ * version, scope) or attribution (caller, errorCode).
222
+ */
223
+ function buildResolvePayload(input: EmitResolveEventInput): Readonly<Record<string, unknown>> {
224
+ const payload: Record<string, unknown> = {
225
+ scope: scopeToPayloadShape(input.scope),
226
+ envName: input.envName as unknown as string,
227
+ name: input.name,
228
+ caller: input.resolveContext.caller,
229
+ };
230
+ if (input.version !== undefined) payload.recordVersion = input.version;
231
+ if (input.errorCode !== undefined) payload.errorCode = input.errorCode;
232
+ if (input.resolveContext.nodeId !== undefined) payload.nodeId = input.resolveContext.nodeId;
233
+ if (input.resolveContext.runId !== undefined) payload.runId = input.resolveContext.runId;
234
+ return payload;
235
+ }
236
+
237
+ /**
238
+ * Build the lifecycle-event payload. Same structural rule as
239
+ * `buildResolvePayload` — never carries the value.
240
+ */
241
+ function buildLifecyclePayload(input: EmitLifecycleEventInput): Readonly<Record<string, unknown>> {
242
+ const payload: Record<string, unknown> = {
243
+ scope: scopeToPayloadShape(input.scope),
244
+ envName: input.envName as unknown as string,
245
+ name: input.name,
246
+ };
247
+ if (input.resolveContext !== undefined) payload.caller = input.resolveContext.caller;
248
+ if (input.version !== undefined) payload.recordVersion = input.version;
249
+ if (input.previousVersion !== undefined) payload.previousVersion = input.previousVersion;
250
+ if (input.errorCode !== undefined) payload.errorCode = input.errorCode;
251
+ if (input.hard !== undefined) payload.hard = input.hard;
252
+ if (input.reason !== undefined) payload.reason = input.reason;
253
+ if (input.resolveContext?.nodeId !== undefined) payload.nodeId = input.resolveContext.nodeId;
254
+ if (input.resolveContext?.runId !== undefined) payload.runId = input.resolveContext.runId;
255
+ return payload;
256
+ }