gsd-pi 2.79.0-dev.579e14e9b → 2.79.0-dev.bbb2f88ce

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 (65) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +6 -1
  3. package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
  5. package/dist/resources/extensions/gsd/auto-start.js +3 -2
  6. package/dist/resources/extensions/gsd/paths.js +5 -1
  7. package/dist/resources/extensions/gsd/uok/audit.js +23 -9
  8. package/dist/resources/extensions/gsd/uok/contracts.js +69 -1
  9. package/dist/resources/extensions/gsd/uok/dispatch-envelope.js +3 -0
  10. package/dist/resources/extensions/gsd/uok/loop-adapter.js +48 -33
  11. package/dist/resources/extensions/gsd/uok/timeline.js +125 -0
  12. package/dist/resources/extensions/shared/gsd-phase-state.js +45 -3
  13. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  14. package/dist/web/standalone/.next/BUILD_ID +1 -1
  15. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  16. package/dist/web/standalone/.next/build-manifest.json +2 -2
  17. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  18. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.html +1 -1
  35. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  42. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  43. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  44. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  45. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  46. package/package.json +1 -1
  47. package/src/resources/extensions/gsd/auto/phases.ts +6 -1
  48. package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -2
  49. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
  50. package/src/resources/extensions/gsd/auto-start.ts +3 -2
  51. package/src/resources/extensions/gsd/paths.ts +6 -1
  52. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +40 -0
  53. package/src/resources/extensions/gsd/tests/deep-planning-mode-dispatch.test.ts +42 -0
  54. package/src/resources/extensions/gsd/tests/deep-project-auto-loop.test.ts +63 -2
  55. package/src/resources/extensions/gsd/tests/uok-contracts.test.ts +109 -1
  56. package/src/resources/extensions/gsd/tests/uok-loop-adapter-writer.test.ts +98 -0
  57. package/src/resources/extensions/gsd/uok/audit.ts +25 -9
  58. package/src/resources/extensions/gsd/uok/contracts.ts +105 -0
  59. package/src/resources/extensions/gsd/uok/dispatch-envelope.ts +4 -0
  60. package/src/resources/extensions/gsd/uok/loop-adapter.ts +60 -45
  61. package/src/resources/extensions/gsd/uok/timeline.ts +158 -0
  62. package/src/resources/extensions/shared/gsd-phase-state.ts +56 -3
  63. package/src/resources/extensions/shared/tests/gsd-phase-state.test.ts +43 -1
  64. /package/dist/web/standalone/.next/static/{X6D0ObmOxuQCMG5piZpbE → 3HYkAopiKls15zp5a8I9n}/_buildManifest.js +0 -0
  65. /package/dist/web/standalone/.next/static/{X6D0ObmOxuQCMG5piZpbE → 3HYkAopiKls15zp5a8I9n}/_ssgManifest.js +0 -0
@@ -1,5 +1,10 @@
1
+ // GSD2 UOK Contract Versioning and DB Authority Tests
2
+
1
3
  import test from "node:test";
2
4
  import assert from "node:assert/strict";
5
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
3
8
 
4
9
  import type {
5
10
  AuditEventEnvelope,
@@ -11,8 +16,17 @@ import type {
11
16
  WriteRecord,
12
17
  WriterToken,
13
18
  } from "../uok/contracts.ts";
14
- import { buildAuditEnvelope } from "../uok/audit.ts";
19
+ import {
20
+ CURRENT_UOK_CONTRACT_VERSION,
21
+ normalizeAuditEvent,
22
+ validateAuditEvent,
23
+ validateDispatchEnvelope,
24
+ validateTurnResult,
25
+ } from "../uok/contracts.ts";
26
+ import { buildAuditEnvelope, emitUokAuditEvent } from "../uok/audit.ts";
15
27
  import { buildDispatchEnvelope, explainDispatch } from "../uok/dispatch-envelope.ts";
28
+ import { buildTurnTimeline } from "../uok/timeline.ts";
29
+ import { _getAdapter, closeDatabase, openDatabase } from "../gsd-db.ts";
16
30
 
17
31
  test("uok contracts serialize/deserialize turn envelopes", () => {
18
32
  const contract: TurnContract = {
@@ -37,6 +51,7 @@ test("uok contracts serialize/deserialize turn envelopes", () => {
37
51
  };
38
52
 
39
53
  const result: TurnResult = {
54
+ version: CURRENT_UOK_CONTRACT_VERSION,
40
55
  traceId: contract.traceId,
41
56
  turnId: contract.turnId,
42
57
  iteration: contract.iteration,
@@ -56,8 +71,10 @@ test("uok contracts serialize/deserialize turn envelopes", () => {
56
71
 
57
72
  const roundTrip = JSON.parse(JSON.stringify(result)) as TurnResult;
58
73
  assert.equal(roundTrip.turnId, "turn-1");
74
+ assert.equal(roundTrip.version, CURRENT_UOK_CONTRACT_VERSION);
59
75
  assert.equal(roundTrip.gateResults?.[0]?.gateId, "Q3");
60
76
  assert.equal(roundTrip.phaseResults.length, 3);
77
+ assert.equal(validateTurnResult(roundTrip).ok, true);
61
78
  });
62
79
 
63
80
  test("uok contracts include required DAG node kinds", () => {
@@ -84,9 +101,11 @@ test("uok audit envelope includes trace/turn/causality fields", () => {
84
101
  });
85
102
 
86
103
  assert.equal(event.traceId, "trace-xyz");
104
+ assert.equal(event.version, CURRENT_UOK_CONTRACT_VERSION);
87
105
  assert.equal(event.turnId, "turn-xyz");
88
106
  assert.equal(event.causedBy, "turn-start");
89
107
  assert.equal(event.payload.status, "completed");
108
+ assert.equal(validateAuditEvent(event).ok, true);
90
109
  });
91
110
 
92
111
  test("uok dispatch envelope carries scheduler reason and constraints", () => {
@@ -107,9 +126,98 @@ test("uok dispatch envelope carries scheduler reason and constraints", () => {
107
126
  });
108
127
 
109
128
  assert.equal(envelope.nodeKind, "unit");
129
+ assert.equal(envelope.version, CURRENT_UOK_CONTRACT_VERSION);
110
130
  assert.equal(envelope.reason.reasonCode, "dependency");
111
131
  assert.deepEqual(envelope.constraints?.dependsOn, ["plan-gate"]);
112
132
  assert.ok(explainDispatch(envelope).includes("execute-task M001/S01/T01"));
133
+ assert.equal(validateDispatchEnvelope(envelope).ok, true);
134
+ });
135
+
136
+ test("uok contracts normalize legacy records without losing payload fields", () => {
137
+ const legacy = {
138
+ eventId: "event-legacy",
139
+ traceId: "trace-legacy",
140
+ category: "orchestration",
141
+ type: "turn-result",
142
+ ts: new Date().toISOString(),
143
+ payload: { status: "completed", extra: "preserved" },
144
+ } as AuditEventEnvelope;
145
+
146
+ const normalized = normalizeAuditEvent(legacy);
147
+ assert.equal(normalized.version, "0");
148
+ assert.equal(normalized.payload.extra, "preserved");
149
+ assert.equal(validateAuditEvent(legacy).ok, true);
150
+ });
151
+
152
+ test("uok audit emission writes DB as authoritative before jsonl projection", (t) => {
153
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-db-audit-"));
154
+ mkdirSync(join(basePath, ".gsd"), { recursive: true });
155
+ t.after(() => {
156
+ closeDatabase();
157
+ rmSync(basePath, { recursive: true, force: true });
158
+ });
159
+
160
+ assert.equal(openDatabase(join(basePath, ".gsd", "gsd.db")), true);
161
+ emitUokAuditEvent(
162
+ basePath,
163
+ buildAuditEnvelope({
164
+ traceId: "trace-db",
165
+ turnId: "turn-db",
166
+ category: "orchestration",
167
+ type: "turn-start",
168
+ payload: { unitType: "execute-task" },
169
+ }),
170
+ );
171
+
172
+ const row = _getAdapter()!.prepare(
173
+ "SELECT payload_json FROM audit_events WHERE trace_id = 'trace-db' AND turn_id = 'turn-db'",
174
+ ).get() as { payload_json: string } | undefined;
175
+ assert.ok(row, "DB audit row should be written");
176
+ assert.equal(JSON.parse(row.payload_json).contractVersion, CURRENT_UOK_CONTRACT_VERSION);
177
+
178
+ const projection = readFileSync(join(basePath, ".gsd", "audit", "events.jsonl"), "utf-8");
179
+ assert.ok(projection.includes("trace-db"), "jsonl projection should still be written");
180
+ });
181
+
182
+ test("uok timeline prefers DB records over jsonl projection when DB is available", (t) => {
183
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-timeline-"));
184
+ const auditDir = join(basePath, ".gsd", "audit");
185
+ mkdirSync(auditDir, { recursive: true });
186
+ writeFileSync(
187
+ join(auditDir, "events.jsonl"),
188
+ `${JSON.stringify({
189
+ version: CURRENT_UOK_CONTRACT_VERSION,
190
+ eventId: "jsonl-only",
191
+ traceId: "trace-timeline",
192
+ turnId: "turn-timeline",
193
+ category: "orchestration",
194
+ type: "jsonl-projection",
195
+ ts: "2026-01-01T00:00:00.000Z",
196
+ payload: {},
197
+ })}\n`,
198
+ );
199
+ t.after(() => {
200
+ closeDatabase();
201
+ rmSync(basePath, { recursive: true, force: true });
202
+ });
203
+
204
+ assert.equal(openDatabase(join(basePath, ".gsd", "gsd.db")), true);
205
+ emitUokAuditEvent(
206
+ basePath,
207
+ buildAuditEnvelope({
208
+ traceId: "trace-timeline",
209
+ turnId: "turn-timeline",
210
+ category: "orchestration",
211
+ type: "db-authoritative",
212
+ payload: {},
213
+ }),
214
+ );
215
+
216
+ const timeline = buildTurnTimeline(basePath, { traceId: "trace-timeline", turnId: "turn-timeline" });
217
+ assert.equal(timeline.authoritative, "db");
218
+ assert.equal(timeline.degraded, false);
219
+ assert.ok(timeline.entries.some((entry) => entry.type === "db-authoritative"));
220
+ assert.equal(timeline.entries.some((entry) => entry.type === "jsonl-projection"), false);
113
221
  });
114
222
 
115
223
  test("uok writer records serialize sequence metadata", () => {
@@ -63,3 +63,101 @@ test("uok turn observer adds writer sequence metadata to audit events", (t) => {
63
63
  assert.equal(payloads[1]?.writeSequence, 2);
64
64
  assert.equal(typeof payloads[0]?.writerTokenId, "string");
65
65
  });
66
+
67
+ test("uok turn observer releases writer token when validation throws", (t) => {
68
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-loop-writer-throw-"));
69
+ resetWriterTokensForTests();
70
+ t.after(() => {
71
+ resetWriterTokensForTests();
72
+ rmSync(basePath, { recursive: true, force: true });
73
+ });
74
+
75
+ const observer = createTurnObserver({
76
+ basePath,
77
+ gitAction: "status-only",
78
+ gitPush: false,
79
+ enableAudit: false,
80
+ enableGitops: false,
81
+ });
82
+
83
+ observer.onTurnStart({
84
+ basePath,
85
+ traceId: "trace-throw",
86
+ turnId: "turn-throw",
87
+ iteration: 1,
88
+ unitType: "execute-task",
89
+ unitId: "M001/S01/T01",
90
+ startedAt: new Date().toISOString(),
91
+ });
92
+ assert.equal(hasActiveWriterToken(basePath, "turn-throw"), true);
93
+
94
+ // Invalid payload (missing required fields like status/finishedAt) should
95
+ // trigger validateTurnResult to fail and throw.
96
+ assert.throws(() => {
97
+ observer.onTurnResult({
98
+ traceId: "trace-throw",
99
+ turnId: "turn-throw",
100
+ // @ts-expect-error intentionally invalid for test
101
+ iteration: "not-a-number",
102
+ unitType: "execute-task",
103
+ unitId: "M001/S01/T01",
104
+ status: "completed",
105
+ failureClass: "none",
106
+ phaseResults: [],
107
+ startedAt: new Date().toISOString(),
108
+ finishedAt: new Date().toISOString(),
109
+ });
110
+ }, /Invalid UOK turn result/);
111
+
112
+ // Cleanup must run in finally — token released, no leaked state.
113
+ assert.equal(hasActiveWriterToken(basePath, "turn-throw"), false);
114
+ });
115
+
116
+ test("uok turn observer falls back to cached phaseResults when result.phaseResults is missing", (t) => {
117
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-loop-writer-missing-"));
118
+ resetWriterTokensForTests();
119
+ t.after(() => {
120
+ resetWriterTokensForTests();
121
+ rmSync(basePath, { recursive: true, force: true });
122
+ });
123
+
124
+ const observer = createTurnObserver({
125
+ basePath,
126
+ gitAction: "status-only",
127
+ gitPush: false,
128
+ enableAudit: false,
129
+ enableGitops: false,
130
+ });
131
+
132
+ observer.onTurnStart({
133
+ basePath,
134
+ traceId: "trace-missing",
135
+ turnId: "turn-missing",
136
+ iteration: 1,
137
+ unitType: "execute-task",
138
+ unitId: "M001/S01/T01",
139
+ startedAt: new Date().toISOString(),
140
+ });
141
+
142
+ // Without the Array.isArray guard, accessing result.phaseResults.length on a
143
+ // payload where phaseResults is undefined would throw TypeError before
144
+ // validateTurnResult could surface a structured error. The guard must defer
145
+ // to the cached phaseResults fallback so the turn completes cleanly.
146
+ assert.doesNotThrow(() => {
147
+ observer.onTurnResult({
148
+ traceId: "trace-missing",
149
+ turnId: "turn-missing",
150
+ iteration: 1,
151
+ unitType: "execute-task",
152
+ unitId: "M001/S01/T01",
153
+ status: "completed",
154
+ failureClass: "none",
155
+ // @ts-expect-error intentionally missing for test
156
+ phaseResults: undefined,
157
+ startedAt: new Date().toISOString(),
158
+ finishedAt: new Date().toISOString(),
159
+ });
160
+ });
161
+
162
+ assert.equal(hasActiveWriterToken(basePath, "turn-missing"), false);
163
+ });
@@ -1,3 +1,5 @@
1
+ // GSD2 UOK Audit Events and DB-First Projection Writes
2
+
1
3
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync } from "node:fs";
2
4
  import { join } from "node:path";
3
5
  import { randomUUID } from "node:crypto";
@@ -6,7 +8,7 @@ import { isStaleWrite } from "../auto/turn-epoch.js";
6
8
  import { withFileLockSync } from "../file-lock.js";
7
9
  import { gsdRoot } from "../paths.js";
8
10
  import { isDbAvailable, insertAuditEvent } from "../gsd-db.js";
9
- import type { AuditEventEnvelope } from "./contracts.js";
11
+ import { CURRENT_UOK_CONTRACT_VERSION, validateAuditEvent, type AuditEventEnvelope } from "./contracts.js";
10
12
 
11
13
  function auditLogPath(basePath: string): string {
12
14
  return join(gsdRoot(basePath), "audit", "events.jsonl");
@@ -25,6 +27,7 @@ export function buildAuditEnvelope(args: {
25
27
  payload?: Record<string, unknown>;
26
28
  }): AuditEventEnvelope {
27
29
  return {
30
+ version: CURRENT_UOK_CONTRACT_VERSION,
28
31
  eventId: randomUUID(),
29
32
  traceId: args.traceId,
30
33
  turnId: args.turnId,
@@ -39,6 +42,26 @@ export function buildAuditEnvelope(args: {
39
42
  export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope): void {
40
43
  // Drop writes from a turn superseded by timeout recovery / cancellation.
41
44
  if (isStaleWrite("uok-audit")) return;
45
+ const validation = validateAuditEvent(event);
46
+ if (!validation.ok) {
47
+ throw new Error(`Invalid UOK audit event: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
48
+ }
49
+ const canonical = validation.value;
50
+
51
+ if (isDbAvailable()) {
52
+ try {
53
+ insertAuditEvent({
54
+ ...canonical,
55
+ payload: {
56
+ ...canonical.payload,
57
+ contractVersion: canonical.version ?? CURRENT_UOK_CONTRACT_VERSION,
58
+ },
59
+ });
60
+ } catch (err) {
61
+ throw new Error(`DB authoritative audit write failed: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ }
64
+
42
65
  try {
43
66
  ensureAuditDir(basePath);
44
67
  const path = auditLogPath(basePath);
@@ -52,18 +75,11 @@ export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope):
52
75
  withFileLockSync(
53
76
  path,
54
77
  () => {
55
- appendFileSync(path, `${JSON.stringify(event)}\n`, "utf-8");
78
+ appendFileSync(path, `${JSON.stringify(canonical)}\n`, "utf-8");
56
79
  },
57
80
  { onLocked: "skip" },
58
81
  );
59
82
  } catch {
60
83
  // Best-effort: audit writes must never break orchestration.
61
84
  }
62
-
63
- if (!isDbAvailable()) return;
64
- try {
65
- insertAuditEvent(event);
66
- } catch {
67
- // Projection failures are non-fatal while legacy readers are still active.
68
- }
69
85
  }
@@ -1,3 +1,9 @@
1
+ // GSD2 UOK Contract Types and Versioning
2
+
3
+ export const CURRENT_UOK_CONTRACT_VERSION = "1" as const;
4
+
5
+ export type UokContractVersion = "0" | typeof CURRENT_UOK_CONTRACT_VERSION;
6
+
1
7
  export type FailureClass =
2
8
  | "none"
3
9
  | "policy"
@@ -77,6 +83,7 @@ export interface TurnCloseoutRecord {
77
83
  }
78
84
 
79
85
  export interface TurnResult {
86
+ version?: UokContractVersion;
80
87
  traceId: string;
81
88
  turnId: string;
82
89
  iteration: number;
@@ -109,6 +116,7 @@ export interface DispatchExplanation {
109
116
  }
110
117
 
111
118
  export interface UokDispatchEnvelope {
119
+ version?: UokContractVersion;
112
120
  action: "dispatch" | "stop" | "skip";
113
121
  nodeKind?: UokNodeKind;
114
122
  unitType?: string;
@@ -130,6 +138,7 @@ export interface UokDispatchEnvelope {
130
138
  }
131
139
 
132
140
  export interface AuditEventEnvelope {
141
+ version?: UokContractVersion;
133
142
  eventId: string;
134
143
  traceId: string;
135
144
  turnId?: string;
@@ -199,3 +208,99 @@ export interface UokTurnObserver {
199
208
  ): void;
200
209
  onTurnResult(result: TurnResult): void;
201
210
  }
211
+
212
+ export interface ContractValidationIssue {
213
+ path: string;
214
+ message: string;
215
+ }
216
+
217
+ export interface ContractValidationResult<T> {
218
+ ok: boolean;
219
+ value: T;
220
+ issues: ContractValidationIssue[];
221
+ }
222
+
223
+ function isRecord(value: unknown): value is Record<string, unknown> {
224
+ return typeof value === "object" && value !== null && !Array.isArray(value);
225
+ }
226
+
227
+ function normalizeVersion(value: unknown): UokContractVersion {
228
+ return value === CURRENT_UOK_CONTRACT_VERSION ? CURRENT_UOK_CONTRACT_VERSION : "0";
229
+ }
230
+
231
+ function requireString(
232
+ value: Record<string, unknown>,
233
+ key: string,
234
+ issues: ContractValidationIssue[],
235
+ ): void {
236
+ if (typeof value[key] !== "string" || value[key] === "") {
237
+ issues.push({ path: key, message: `${key} must be a non-empty string` });
238
+ }
239
+ }
240
+
241
+ function requireRecord(
242
+ value: Record<string, unknown>,
243
+ key: string,
244
+ issues: ContractValidationIssue[],
245
+ ): void {
246
+ if (!isRecord(value[key])) {
247
+ issues.push({ path: key, message: `${key} must be an object` });
248
+ }
249
+ }
250
+
251
+ export function normalizeTurnResult(value: TurnResult): TurnResult {
252
+ return { ...value, version: normalizeVersion(value.version) };
253
+ }
254
+
255
+ export function normalizeDispatchEnvelope(value: UokDispatchEnvelope): UokDispatchEnvelope {
256
+ return { ...value, version: normalizeVersion(value.version) };
257
+ }
258
+
259
+ export function normalizeAuditEvent(value: AuditEventEnvelope): AuditEventEnvelope {
260
+ return { ...value, version: normalizeVersion(value.version) };
261
+ }
262
+
263
+ export function validateTurnResult(value: TurnResult): ContractValidationResult<TurnResult> {
264
+ const normalized = normalizeTurnResult(value);
265
+ const record = normalized as unknown as Record<string, unknown>;
266
+ const issues: ContractValidationIssue[] = [];
267
+ requireString(record, "traceId", issues);
268
+ requireString(record, "turnId", issues);
269
+ if (!Number.isInteger(record.iteration)) {
270
+ issues.push({ path: "iteration", message: "iteration must be an integer" });
271
+ }
272
+ requireString(record, "status", issues);
273
+ requireString(record, "failureClass", issues);
274
+ if (!Array.isArray(record.phaseResults)) {
275
+ issues.push({ path: "phaseResults", message: "phaseResults must be an array" });
276
+ }
277
+ requireString(record, "startedAt", issues);
278
+ requireString(record, "finishedAt", issues);
279
+ return { ok: issues.length === 0, value: normalized, issues };
280
+ }
281
+
282
+ export function validateDispatchEnvelope(value: UokDispatchEnvelope): ContractValidationResult<UokDispatchEnvelope> {
283
+ const normalized = normalizeDispatchEnvelope(value);
284
+ const record = normalized as unknown as Record<string, unknown>;
285
+ const issues: ContractValidationIssue[] = [];
286
+ requireString(record, "action", issues);
287
+ requireRecord(record, "reason", issues);
288
+ if (isRecord(record.reason)) {
289
+ requireString(record.reason, "reasonCode", issues);
290
+ requireString(record.reason, "summary", issues);
291
+ }
292
+ return { ok: issues.length === 0, value: normalized, issues };
293
+ }
294
+
295
+ export function validateAuditEvent(value: AuditEventEnvelope): ContractValidationResult<AuditEventEnvelope> {
296
+ const normalized = normalizeAuditEvent(value);
297
+ const record = normalized as unknown as Record<string, unknown>;
298
+ const issues: ContractValidationIssue[] = [];
299
+ requireString(record, "eventId", issues);
300
+ requireString(record, "traceId", issues);
301
+ requireString(record, "category", issues);
302
+ requireString(record, "type", issues);
303
+ requireString(record, "ts", issues);
304
+ requireRecord(record, "payload", issues);
305
+ return { ok: issues.length === 0, value: normalized, issues };
306
+ }
@@ -1,3 +1,5 @@
1
+ // GSD2 UOK Dispatch Envelope Builder
2
+
1
3
  import type {
2
4
  DispatchExplanation,
3
5
  DispatchReasonCode,
@@ -5,6 +7,7 @@ import type {
5
7
  UokDispatchEnvelope,
6
8
  UokGraphNode,
7
9
  } from "./contracts.js";
10
+ import { CURRENT_UOK_CONTRACT_VERSION } from "./contracts.js";
8
11
 
9
12
  export interface BuildDispatchEnvelopeInput {
10
13
  action: UokDispatchEnvelope["action"];
@@ -22,6 +25,7 @@ export interface BuildDispatchEnvelopeInput {
22
25
 
23
26
  export function buildDispatchEnvelope(input: BuildDispatchEnvelopeInput): UokDispatchEnvelope {
24
27
  return {
28
+ version: CURRENT_UOK_CONTRACT_VERSION,
25
29
  action: input.action,
26
30
  nodeKind: input.node?.kind,
27
31
  unitType: input.unitType,
@@ -1,9 +1,12 @@
1
+ // GSD2 UOK Turn Observer and DB-Backed Lifecycle Emission
2
+
1
3
  import type {
2
4
  TurnCloseoutRecord,
3
5
  TurnContract,
4
6
  TurnResult,
5
7
  UokTurnObserver,
6
8
  } from "./contracts.js";
9
+ import { CURRENT_UOK_CONTRACT_VERSION, validateTurnResult } from "./contracts.js";
7
10
  import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
8
11
  import { writeTurnCloseoutGitRecord, writeTurnGitTransaction } from "./gitops.js";
9
12
  import { acquireWriterToken, nextWriteRecord, releaseWriterToken } from "./writer.js";
@@ -142,56 +145,68 @@ export function createTurnObserver(options: CreateTurnObserverOptions): UokTurnO
142
145
  },
143
146
 
144
147
  onTurnResult(result): void {
145
- const merged: TurnResult = {
146
- ...result,
147
- phaseResults: result.phaseResults.length > 0 ? result.phaseResults : [...phaseResults],
148
+ const cleanup = (): void => {
149
+ if (writerToken) {
150
+ releaseWriterToken(options.basePath, writerToken);
151
+ }
152
+ writerToken = null;
153
+ current = null;
154
+ phaseResults.length = 0;
148
155
  };
149
156
 
150
- if (options.enableAudit) {
151
- emitUokAuditEvent(
152
- options.basePath,
153
- buildAuditEnvelope({
154
- traceId: merged.traceId,
155
- turnId: merged.turnId,
156
- category: "orchestration",
157
- type: "turn-result",
158
- payload: nextSequenceMetadata("audit", "append", {
159
- unitType: merged.unitType,
160
- unitId: merged.unitId,
161
- status: merged.status,
162
- failureClass: merged.failureClass,
163
- error: merged.error,
164
- phaseCount: merged.phaseResults.length,
165
- }),
166
- }),
167
- );
168
- }
169
-
170
- if (options.enableGitops) {
171
- const closeout: TurnCloseoutRecord = merged.closeout ?? {
172
- traceId: merged.traceId,
173
- turnId: merged.turnId,
174
- unitType: merged.unitType,
175
- unitId: merged.unitId,
176
- status: merged.status,
177
- failureClass: merged.failureClass,
178
- gitAction: options.gitAction,
179
- gitPushed: options.gitPush,
180
- finishedAt: merged.finishedAt,
157
+ try {
158
+ const merged: TurnResult = {
159
+ ...result,
160
+ version: CURRENT_UOK_CONTRACT_VERSION,
161
+ phaseResults: Array.isArray(result.phaseResults) && result.phaseResults.length > 0 ? result.phaseResults : [...phaseResults],
181
162
  };
182
- writeTurnCloseoutGitRecord(
183
- options.basePath,
184
- closeout,
185
- nextSequenceMetadata("gitops", "update", { action: "record" }),
186
- );
187
- }
163
+ const validation = validateTurnResult(merged);
164
+ if (!validation.ok) {
165
+ throw new Error(`Invalid UOK turn result: ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
166
+ }
188
167
 
189
- if (writerToken) {
190
- releaseWriterToken(options.basePath, writerToken);
168
+ if (options.enableAudit) {
169
+ emitUokAuditEvent(
170
+ options.basePath,
171
+ buildAuditEnvelope({
172
+ traceId: validation.value.traceId,
173
+ turnId: validation.value.turnId,
174
+ category: "orchestration",
175
+ type: "turn-result",
176
+ payload: nextSequenceMetadata("audit", "append", {
177
+ contractVersion: validation.value.version,
178
+ unitType: validation.value.unitType,
179
+ unitId: validation.value.unitId,
180
+ status: validation.value.status,
181
+ failureClass: validation.value.failureClass,
182
+ error: validation.value.error,
183
+ phaseCount: validation.value.phaseResults.length,
184
+ }),
185
+ }),
186
+ );
187
+ }
188
+
189
+ if (options.enableGitops) {
190
+ const closeout: TurnCloseoutRecord = merged.closeout ?? {
191
+ traceId: merged.traceId,
192
+ turnId: merged.turnId,
193
+ unitType: merged.unitType,
194
+ unitId: merged.unitId,
195
+ status: merged.status,
196
+ failureClass: merged.failureClass,
197
+ gitAction: options.gitAction,
198
+ gitPushed: options.gitPush,
199
+ finishedAt: merged.finishedAt,
200
+ };
201
+ writeTurnCloseoutGitRecord(
202
+ options.basePath,
203
+ closeout,
204
+ nextSequenceMetadata("gitops", "update", { action: "record" }),
205
+ );
206
+ }
207
+ } finally {
208
+ cleanup();
191
209
  }
192
- writerToken = null;
193
- current = null;
194
- phaseResults.length = 0;
195
210
  },
196
211
  };
197
212
  }