@kontextmind/kxm 0.7.72 → 0.7.73
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/.claude-plugin/marketplace.json +1 -1
- package/docs/contracts/synchronization.md +6 -3
- package/docs/operations.md +35 -4
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +307 -235
- package/plugins/kxm/dist/client.js +67 -36
- package/plugins/kxm/dist/core.js +2 -0
- package/plugins/kxm/dist/extension.js +40 -37
- package/plugins/kxm/dist/mcp-server.js +40 -37
- package/plugins/kxm/dist/runtime-supervisor.js +917 -141
- package/plugins/kxm/dist/runtime.js +977 -237
- package/plugins/kxm/dist/server.js +16206 -8256
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/client.ts +85 -3
- package/plugins/kxm/src/database.ts +4 -4
- package/plugins/kxm/src/hub.ts +221 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/project-config.ts +15 -0
- package/plugins/kxm/src/protocol.ts +27 -0
- package/plugins/kxm/src/runtime-store.ts +109 -1
- package/plugins/kxm/src/runtime-supervisor.ts +150 -0
- package/plugins/kxm/src/store.ts +207 -4
- package/plugins/kxm/src/sync-transform.ts +380 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { KxmConfigError, kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts";
|
|
3
|
+
import { redactSecrets } from "./redact.ts";
|
|
4
|
+
import type { KxmRunEvent } from "./runtime-store.ts";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Runtime → hub sync transform (`docs/contracts/synchronization.md`).
|
|
8
|
+
*
|
|
9
|
+
* A local run event never reaches the outbox. This module builds a new
|
|
10
|
+
* `kxm.sync-event.v1` object from it: pick allowlisted fields, rebuild every
|
|
11
|
+
* nested record key by key, replace registered secret values and credential
|
|
12
|
+
* shapes, strip absolute paths and control characters, bound text, then
|
|
13
|
+
* validate the result. Anything the allowlist does not name is dropped and
|
|
14
|
+
* recorded by name in `redaction.fieldsOmitted`; its value goes nowhere.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const KXM_SYNC_EVENT_SCHEMA = "kxm.sync-event.v1";
|
|
18
|
+
export const KXM_SYNC_REDACTOR_VERSION = "redactor-v1";
|
|
19
|
+
|
|
20
|
+
/** The contract's default event policy. Normal projects never materialize it. */
|
|
21
|
+
export const KXM_DEFAULT_SYNC_POLICY = Object.freeze({
|
|
22
|
+
prompts: "title-only",
|
|
23
|
+
results: "bounded-summary",
|
|
24
|
+
evidence: "references",
|
|
25
|
+
artifacts: "metadata",
|
|
26
|
+
fileChanges: "paths-only",
|
|
27
|
+
rawLogs: false,
|
|
28
|
+
diffs: false,
|
|
29
|
+
environmentValues: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The policy revision recorded on every outbox transform. */
|
|
33
|
+
export const KXM_DEFAULT_SYNC_POLICY_REVISION = `sha256:${createHash("sha256")
|
|
34
|
+
.update(kxmCanonicalJson(KXM_DEFAULT_SYNC_POLICY as unknown as JsonValue), "utf8")
|
|
35
|
+
.digest("hex")}`;
|
|
36
|
+
|
|
37
|
+
export interface KxmSyncEvent {
|
|
38
|
+
schema: typeof KXM_SYNC_EVENT_SCHEMA;
|
|
39
|
+
sourceEventId: string;
|
|
40
|
+
eventType: string;
|
|
41
|
+
projectId: string;
|
|
42
|
+
runId: string;
|
|
43
|
+
homeRuntimeId: string;
|
|
44
|
+
sequence: number;
|
|
45
|
+
occurredAt: string;
|
|
46
|
+
configRevision: string;
|
|
47
|
+
memoryRevision: string;
|
|
48
|
+
executorPolicyRevision: string;
|
|
49
|
+
toolPolicyRevision: string;
|
|
50
|
+
syncPolicyRevision: string;
|
|
51
|
+
redaction: { version: string; fieldsOmitted: string[]; valuesReplaced: number };
|
|
52
|
+
payload: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Shortest value the redactor will register: a two-letter "secret" would
|
|
56
|
+
* shred ordinary words out of every summary. */
|
|
57
|
+
const MIN_REGISTERED_SECRET_CHARS = 4;
|
|
58
|
+
const REDACTED = "[redacted]";
|
|
59
|
+
const PATH_REDACTED = "[path]";
|
|
60
|
+
|
|
61
|
+
const CREDENTIAL_SHAPES: RegExp[] = [
|
|
62
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
|
|
63
|
+
/\bssh-(?:rsa|ed25519|dss|ecdsa-[a-z0-9-]+) AAAA[0-9A-Za-z+/=]+/g,
|
|
64
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
65
|
+
/\bgh[opsu]_[A-Za-z0-9]{20,}\b/g,
|
|
66
|
+
/\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*\S+/gi,
|
|
67
|
+
];
|
|
68
|
+
// URL user-info (`https://user:token@host`) keeps the scheme and host.
|
|
69
|
+
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi;
|
|
70
|
+
const ABSOLUTE_PATHS: RegExp[] = [
|
|
71
|
+
/\\\\[^\s\\"'`]+\\[^\s"'`]*/g, // UNC
|
|
72
|
+
/\b[A-Za-z]:[\\/][^\s"'`]*/g, // Windows drive
|
|
73
|
+
/(?<![\w.~:/-])~[\\/][^\s"'`]*/g, // home
|
|
74
|
+
/(?<![\w.~:/-])\/(?:[\w.@%+-]+\/)*[\w.@%+-]+\/?/g, // POSIX
|
|
75
|
+
];
|
|
76
|
+
// eslint-disable-next-line no-control-regex
|
|
77
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* In-memory redactor. The Runtime registers resolved secret values before a
|
|
81
|
+
* transform; they are never persisted to support later redaction, so a
|
|
82
|
+
* process restart forgets them.
|
|
83
|
+
*/
|
|
84
|
+
export class KxmSyncRedactor {
|
|
85
|
+
private readonly values = new Set<string>();
|
|
86
|
+
|
|
87
|
+
register(value: string): void {
|
|
88
|
+
const trimmed = value.trim();
|
|
89
|
+
if (trimmed.length >= MIN_REGISTERED_SECRET_CHARS) this.values.add(trimmed);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get size(): number {
|
|
93
|
+
return this.values.size;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Scrub one free-text value; `replaced` counts every substitution. */
|
|
97
|
+
scrub(input: string): { text: string; replaced: number } {
|
|
98
|
+
let replaced = 0;
|
|
99
|
+
let text = input;
|
|
100
|
+
// Longest first, so a registered value containing another is removed whole.
|
|
101
|
+
for (const secret of [...this.values].sort((left, right) => right.length - left.length)) {
|
|
102
|
+
const parts = text.split(secret);
|
|
103
|
+
if (parts.length > 1) {
|
|
104
|
+
replaced += parts.length - 1;
|
|
105
|
+
text = parts.join(REDACTED);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const substitute = (pattern: RegExp, replacement: string | ((match: string, ...groups: string[]) => string)): void => {
|
|
109
|
+
text = text.replace(pattern, (...args: unknown[]) => {
|
|
110
|
+
replaced += 1;
|
|
111
|
+
return typeof replacement === "string" ? replacement : replacement(...(args as [string, ...string[]]));
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
for (const pattern of CREDENTIAL_SHAPES) substitute(pattern, REDACTED);
|
|
115
|
+
substitute(URL_USERINFO, (_match, scheme) => `${scheme}${REDACTED}@`);
|
|
116
|
+
const shaped = redactSecrets(text);
|
|
117
|
+
if (shaped !== text) {
|
|
118
|
+
replaced += shaped.split(REDACTED).length - text.split(REDACTED).length;
|
|
119
|
+
text = shaped;
|
|
120
|
+
}
|
|
121
|
+
for (const pattern of ABSOLUTE_PATHS) substitute(pattern, PATH_REDACTED);
|
|
122
|
+
text = text.replace(CONTROL_CHARACTERS, " ").replace(/\s+/g, " ").trim();
|
|
123
|
+
return { text, replaced };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type Picked = Record<string, unknown>;
|
|
128
|
+
|
|
129
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
130
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Copy only the named keys whose values have the expected JS type. */
|
|
134
|
+
function pickScalars(source: unknown, keys: readonly string[], redactor?: KxmSyncRedactor): Picked | undefined {
|
|
135
|
+
if (!isRecord(source)) return undefined;
|
|
136
|
+
const result: Picked = {};
|
|
137
|
+
for (const key of keys) {
|
|
138
|
+
const value = source[key];
|
|
139
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
140
|
+
result[key] = value;
|
|
141
|
+
} else if (typeof value === "string") {
|
|
142
|
+
// Strings are scrubbed even in "scalar" positions: a secret echoed into
|
|
143
|
+
// a lease operation or receipt query must not survive to the hub.
|
|
144
|
+
if (redactor) {
|
|
145
|
+
const scrubbed = redactor.scrub(value);
|
|
146
|
+
if (scrubbed.text.length > 0) result[key] = scrubbed.text.slice(0, 200);
|
|
147
|
+
} else {
|
|
148
|
+
result[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class SyncPayloadBuilder {
|
|
156
|
+
readonly payload: Picked = {};
|
|
157
|
+
readonly omitted = new Set<string>();
|
|
158
|
+
replaced = 0;
|
|
159
|
+
readonly redactor: KxmSyncRedactor;
|
|
160
|
+
|
|
161
|
+
constructor(redactor: KxmSyncRedactor) {
|
|
162
|
+
this.redactor = redactor;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Bounded, scrubbed free text; empty after scrubbing means absent. */
|
|
166
|
+
text(value: unknown, max: number): string | undefined {
|
|
167
|
+
if (typeof value !== "string") return undefined;
|
|
168
|
+
const { text, replaced } = this.redactor.scrub(value);
|
|
169
|
+
this.replaced += replaced;
|
|
170
|
+
if (text.length === 0) return undefined;
|
|
171
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A nested record rebuilt key by key, with the named keys scrubbed as text. */
|
|
175
|
+
record(source: unknown, keys: readonly string[], textKeys: Readonly<Record<string, number>> = {}): Picked | undefined {
|
|
176
|
+
const scalars = pickScalars(source, keys.filter((key) => !(key in textKeys)));
|
|
177
|
+
const result: Picked = { ...(scalars ?? {}) };
|
|
178
|
+
if (isRecord(source)) {
|
|
179
|
+
for (const [key, max] of Object.entries(textKeys)) {
|
|
180
|
+
const value = this.text(source[key], max);
|
|
181
|
+
if (value !== undefined) result[key] = value;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
set(key: string, value: unknown): void {
|
|
188
|
+
if (value !== undefined) this.payload[key] = value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const SCALAR_FIELDS = [
|
|
193
|
+
"workflowId", "promptHash", "status", "previousStatus", "outcome", "stepId", "stepAttempt", "fromStepId", "toStepId",
|
|
194
|
+
"assignmentId", "attemptId", "agentId", "instanceNo", "scopeEpoch", "resolutionAction",
|
|
195
|
+
] as const;
|
|
196
|
+
const REF_KEYS = ["id", "kind", "hash", "status", "producerId"] as const;
|
|
197
|
+
const ARTIFACT_KEYS = ["id", "kind", "hash", "size", "classification", "availability"] as const;
|
|
198
|
+
const LEASE_KEYS = ["id", "fencingToken", "acquiredAt", "expiresAt", "operation"] as const;
|
|
199
|
+
const MAX_REFS = 64;
|
|
200
|
+
|
|
201
|
+
/** Every source payload key the transform knows how to carry (possibly renamed). */
|
|
202
|
+
const CARRIED_FIELDS = new Set<string>([
|
|
203
|
+
...SCALAR_FIELDS, "displayTitle", "summary", "reason", "executor", "model", "timing", "usage", "evidenceRefs",
|
|
204
|
+
"artifacts", "receipt", "error", "effect", "lease", "actor", "resolvedBy", "suppliedRefs", "counts",
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
/** Fields the sync schema makes conditionally required; a degraded object keeps them. */
|
|
208
|
+
const CONTROL_FIELDS = ["effect", "lease", "actor", "resolvedBy", "resolutionAction", "reason", "suppliedRefs", "outcome"] as const;
|
|
209
|
+
|
|
210
|
+
function buildPayload(source: Record<string, unknown>, builder: SyncPayloadBuilder): void {
|
|
211
|
+
for (const key of SCALAR_FIELDS) {
|
|
212
|
+
const value = source[key];
|
|
213
|
+
if (typeof value === "string" || typeof value === "number") builder.set(key, value);
|
|
214
|
+
}
|
|
215
|
+
builder.set("displayTitle", builder.text(source.displayTitle, 200));
|
|
216
|
+
builder.set("summary", builder.text(source.summary, 1000));
|
|
217
|
+
if (source.reason !== undefined) {
|
|
218
|
+
// `reason` is required on resolutions; a reason scrubbed to nothing still says one was given.
|
|
219
|
+
builder.set("reason", builder.text(source.reason, 1000) ?? REDACTED);
|
|
220
|
+
}
|
|
221
|
+
builder.set("executor", builder.record(source.executor, ["id", "kind", "harness"], {
|
|
222
|
+
executorVersion: 128, harnessVersion: 128, helperGeneration: 256,
|
|
223
|
+
}));
|
|
224
|
+
const model = builder.record(source.model, ["profile", "provider"], { model: 200, thinking: 64 });
|
|
225
|
+
if (model && isRecord(source.model) && Array.isArray(source.model.tags)) {
|
|
226
|
+
model.tags = source.model.tags.filter((tag): tag is string => typeof tag === "string").slice(0, 32);
|
|
227
|
+
}
|
|
228
|
+
builder.set("model", model);
|
|
229
|
+
builder.set("timing", builder.record(source.timing, ["startedAt", "finishedAt", "durationMs", "queueMs", "agentMs"]));
|
|
230
|
+
builder.set("usage", builder.record(source.usage, [
|
|
231
|
+
"inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "cost", "currency", "costKind",
|
|
232
|
+
]));
|
|
233
|
+
const refs = (value: unknown, keys: readonly string[]): Picked[] | undefined => Array.isArray(value)
|
|
234
|
+
? value.slice(0, MAX_REFS).map((entry) => pickScalars(entry, keys)).filter((entry): entry is Picked => entry !== undefined)
|
|
235
|
+
: undefined;
|
|
236
|
+
builder.set("evidenceRefs", refs(source.evidenceRefs, REF_KEYS));
|
|
237
|
+
builder.set("suppliedRefs", refs(source.suppliedRefs, REF_KEYS));
|
|
238
|
+
builder.set("artifacts", refs(source.artifacts, ARTIFACT_KEYS));
|
|
239
|
+
if (isRecord(source.receipt)) {
|
|
240
|
+
// The local receipt may carry a URL (possibly secret-bearing); the safe receipt never does.
|
|
241
|
+
const receipt = builder.record(source.receipt, ["provider", "kind", "hash", "observedAt"], { id: 512 });
|
|
242
|
+
if (receipt) builder.set("receipts", [receipt]);
|
|
243
|
+
if (source.receipt.url !== undefined) builder.omitted.add("receipt.url");
|
|
244
|
+
}
|
|
245
|
+
builder.set("error", builder.record(source.error, ["class", "retryable"], { component: 128 }));
|
|
246
|
+
if (isRecord(source.effect)) {
|
|
247
|
+
const effect = pickScalars(source.effect, ["id", "idempotencyKeyHash"], builder.redactor) ?? {};
|
|
248
|
+
const policy = pickScalars(source.effect.policy, ["class", "sharedMutable", "idempotencyKey", "receiptQuery"], builder.redactor);
|
|
249
|
+
if (policy) effect.policy = policy;
|
|
250
|
+
builder.set("effect", effect);
|
|
251
|
+
}
|
|
252
|
+
builder.set("lease", pickScalars(source.lease, LEASE_KEYS, builder.redactor));
|
|
253
|
+
builder.set("actor", builder.record(source.actor, ["kind"], { id: 200 }));
|
|
254
|
+
builder.set("resolvedBy", builder.record(source.resolvedBy, ["kind"], { id: 200 }));
|
|
255
|
+
if (isRecord(source.counts)) {
|
|
256
|
+
const counts = Object.fromEntries(Object.entries(source.counts)
|
|
257
|
+
.filter((entry): entry is [string, number] => Number.isInteger(entry[1]) && (entry[1] as number) >= 0)
|
|
258
|
+
.slice(0, 32));
|
|
259
|
+
builder.set("counts", counts);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface KxmSyncTransformOptions {
|
|
264
|
+
redactor?: KxmSyncRedactor;
|
|
265
|
+
policyRevision?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function envelope(
|
|
269
|
+
event: KxmRunEvent,
|
|
270
|
+
policyRevision: string,
|
|
271
|
+
payload: Picked,
|
|
272
|
+
fieldsOmitted: Iterable<string>,
|
|
273
|
+
valuesReplaced: number,
|
|
274
|
+
): KxmSyncEvent {
|
|
275
|
+
return {
|
|
276
|
+
schema: KXM_SYNC_EVENT_SCHEMA,
|
|
277
|
+
sourceEventId: event.eventId,
|
|
278
|
+
eventType: event.eventType,
|
|
279
|
+
projectId: event.projectId,
|
|
280
|
+
runId: event.runId,
|
|
281
|
+
homeRuntimeId: event.homeRuntimeId,
|
|
282
|
+
sequence: event.sequence,
|
|
283
|
+
occurredAt: event.occurredAt,
|
|
284
|
+
configRevision: event.configRevision,
|
|
285
|
+
memoryRevision: event.memoryRevision,
|
|
286
|
+
executorPolicyRevision: event.executorPolicyRevision,
|
|
287
|
+
toolPolicyRevision: event.toolPolicyRevision,
|
|
288
|
+
syncPolicyRevision: policyRevision,
|
|
289
|
+
redaction: {
|
|
290
|
+
version: KXM_SYNC_REDACTOR_VERSION,
|
|
291
|
+
fieldsOmitted: [...new Set(fieldsOmitted)].sort().slice(0, 128),
|
|
292
|
+
valuesReplaced: Math.min(valuesReplaced, 100_000),
|
|
293
|
+
},
|
|
294
|
+
payload,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Derive the sync-safe object for one committed local event.
|
|
300
|
+
*
|
|
301
|
+
* If the full derivation does not validate (a field the transform carries
|
|
302
|
+
* fell outside its sync bounds), a degraded object keeps only identity and
|
|
303
|
+
* the control fields the schema requires and marks `degraded: true`, so the
|
|
304
|
+
* run sequence stays gapless on the hub. If even that fails the event is
|
|
305
|
+
* refused with `sync_event_invalid`: nothing unvalidated enters the outbox.
|
|
306
|
+
*/
|
|
307
|
+
export function deriveKxmSyncEvent(event: KxmRunEvent, options: KxmSyncTransformOptions = {}): KxmSyncEvent {
|
|
308
|
+
const redactor = options.redactor ?? new KxmSyncRedactor();
|
|
309
|
+
const policyRevision = options.policyRevision ?? KXM_DEFAULT_SYNC_POLICY_REVISION;
|
|
310
|
+
const source = isRecord(event.payload) ? event.payload : {};
|
|
311
|
+
const builder = new SyncPayloadBuilder(redactor);
|
|
312
|
+
buildPayload(source, builder);
|
|
313
|
+
const omitted = new Set(builder.omitted);
|
|
314
|
+
for (const key of Object.keys(source)) if (!CARRIED_FIELDS.has(key)) omitted.add(key);
|
|
315
|
+
if ((event as unknown as Record<string, unknown>).protectedPayload !== undefined) omitted.add("protectedPayload");
|
|
316
|
+
|
|
317
|
+
const payload = Object.keys(builder.payload).length > 0 ? builder.payload : { counts: {} };
|
|
318
|
+
const full = envelope(event, policyRevision, payload, omitted, builder.replaced);
|
|
319
|
+
if (syncEventSchemaErrors(full) === undefined) return full;
|
|
320
|
+
|
|
321
|
+
const degradedPayload: Picked = { degraded: true };
|
|
322
|
+
for (const key of CONTROL_FIELDS) {
|
|
323
|
+
// A scrubbed-required field that was REMOVED (scrubbing produced an empty
|
|
324
|
+
// string and text() returned undefined) must be restored with a valid
|
|
325
|
+
// placeholder, not left absent — the schema requires it and a missing
|
|
326
|
+
// field aborts the caller's transaction with sync_event_invalid.
|
|
327
|
+
const value = builder.payload[key];
|
|
328
|
+
// A scrubbed-required field that was removed entirely (text() returned
|
|
329
|
+
// undefined) or is empty/whitespace must be restored with a valid
|
|
330
|
+
// placeholder. For nested objects, MERGE the surviving fields from the
|
|
331
|
+
// builder with [redacted] for any field the original had that scrubbing
|
|
332
|
+
// removed — not just the top level.
|
|
333
|
+
const restoreNested = (built: unknown, orig: unknown): unknown => {
|
|
334
|
+
if (!isRecord(orig)) return built !== undefined ? built : "[redacted]";
|
|
335
|
+
const result: Picked = {};
|
|
336
|
+
const builtRecord = isRecord(built) ? built : {};
|
|
337
|
+
for (const [k, v] of Object.entries(orig)) {
|
|
338
|
+
const builtValue = builtRecord[k];
|
|
339
|
+
if (builtValue === undefined || (typeof builtValue === "string" && builtValue.trim().length === 0)) {
|
|
340
|
+
result[k] = isRecord(v) ? restoreNested(undefined, v) : "[redacted]";
|
|
341
|
+
} else {
|
|
342
|
+
result[k] = builtValue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
};
|
|
347
|
+
if (value === undefined) {
|
|
348
|
+
const sourceValue = source[key];
|
|
349
|
+
if (sourceValue !== undefined) {
|
|
350
|
+
degradedPayload[key] = restoreNested(undefined, sourceValue);
|
|
351
|
+
}
|
|
352
|
+
} else if (typeof value === "string" && value.trim().length === 0) {
|
|
353
|
+
degradedPayload[key] = "[redacted]";
|
|
354
|
+
} else if (isRecord(value)) {
|
|
355
|
+
degradedPayload[key] = restoreNested(value, source[key]);
|
|
356
|
+
} else {
|
|
357
|
+
degradedPayload[key] = value;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const degradedOmitted = new Set(omitted);
|
|
361
|
+
for (const key of Object.keys(builder.payload)) if (!(key in degradedPayload)) degradedOmitted.add(key);
|
|
362
|
+
const degraded = envelope(event, policyRevision, degradedPayload, degradedOmitted, builder.replaced);
|
|
363
|
+
const errors = syncEventSchemaErrors(degraded);
|
|
364
|
+
if (errors === undefined) return degraded;
|
|
365
|
+
throw new KxmConfigError([{
|
|
366
|
+
phase: "schema",
|
|
367
|
+
code: "sync_event_invalid",
|
|
368
|
+
file: `${event.runId}#${event.sequence}`,
|
|
369
|
+
message: `event cannot be derived into kxm.sync-event.v1: ${errors}`,
|
|
370
|
+
}]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The exact bytes of a sync object: what the outbox stores and the hub compares. */
|
|
374
|
+
export function kxmSyncEventBytes(event: KxmSyncEvent): string {
|
|
375
|
+
return kxmCanonicalJson(event as unknown as JsonValue);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function kxmSyncEventHash(bytes: string): string {
|
|
379
|
+
return `sha256:${createHash("sha256").update(bytes, "utf8").digest("hex")}`;
|
|
380
|
+
}
|