@haaaiawd/second-nature 0.2.12 → 0.2.13

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 (59) hide show
  1. package/index.js +96 -6
  2. package/openclaw.plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/runtime/cli/commands/index.js +85 -11
  5. package/runtime/cli/host-capability/host-discovery-port.d.ts +85 -0
  6. package/runtime/cli/host-capability/host-discovery-port.js +137 -0
  7. package/runtime/cli/ops/heartbeat-surface.d.ts +3 -3
  8. package/runtime/cli/ops/heartbeat-surface.js +6 -5
  9. package/runtime/cli/ops/ops-router.d.ts +6 -2
  10. package/runtime/cli/ops/ops-router.js +1273 -1145
  11. package/runtime/connectors/base/normalized-evidence-content.d.ts +4 -0
  12. package/runtime/connectors/base/normalized-evidence-content.js +21 -2
  13. package/runtime/connectors/evidence-normalizer.js +32 -1
  14. package/runtime/core/second-nature/action/action-closure-recorder.d.ts +2 -0
  15. package/runtime/core/second-nature/action/action-closure-recorder.js +49 -34
  16. package/runtime/core/second-nature/action/action-proposal-builder.js +3 -2
  17. package/runtime/core/second-nature/action/policy-bound-dispatch.d.ts +2 -0
  18. package/runtime/core/second-nature/action/policy-bound-dispatch.js +7 -3
  19. package/runtime/core/second-nature/control-plane/cycle-finalizer.d.ts +82 -0
  20. package/runtime/core/second-nature/control-plane/cycle-finalizer.js +187 -0
  21. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.js +13 -9
  22. package/runtime/core/second-nature/control-plane/real-runtime-spine.js +1 -1
  23. package/runtime/core/second-nature/guidance/guidance-proposal-consumer.d.ts +2 -1
  24. package/runtime/core/second-nature/guidance/guidance-proposal-consumer.js +4 -2
  25. package/runtime/core/second-nature/perception/judgment-engine.js +8 -4
  26. package/runtime/core/second-nature/perception/perception-builder.js +14 -2
  27. package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.js +30 -3
  28. package/runtime/core/second-nature/quiet-dream/dream-consolidation-runner.d.ts +5 -1
  29. package/runtime/core/second-nature/quiet-dream/dream-consolidation-runner.js +68 -29
  30. package/runtime/core/second-nature/quiet-dream/dream-scheduler.js +2 -1
  31. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.js +2 -1
  32. package/runtime/core/second-nature/quiet-dream/quiet-daily-review-builder.d.ts +1 -0
  33. package/runtime/core/second-nature/quiet-dream/quiet-daily-review-builder.js +33 -0
  34. package/runtime/observability/causal-loop-health.d.ts +2 -1
  35. package/runtime/observability/causal-loop-health.js +7 -0
  36. package/runtime/observability/loop-stage-event-sink.js +6 -1
  37. package/runtime/observability/loop-status.d.ts +2 -0
  38. package/runtime/observability/loop-status.js +14 -1
  39. package/runtime/observability/services/heartbeat-digest-assembler.d.ts +3 -0
  40. package/runtime/observability/services/heartbeat-digest-assembler.js +9 -0
  41. package/runtime/shared/degraded-status-classifier.d.ts +16 -0
  42. package/runtime/shared/degraded-status-classifier.js +68 -0
  43. package/runtime/shared/evidence-level-classifier.d.ts +61 -0
  44. package/runtime/shared/evidence-level-classifier.js +116 -0
  45. package/runtime/shared/provenance-tier.d.ts +37 -0
  46. package/runtime/shared/provenance-tier.js +97 -0
  47. package/runtime/shared/setup-ack.d.ts +54 -0
  48. package/runtime/shared/setup-ack.js +108 -0
  49. package/runtime/shared/source-ref-compat.js +5 -2
  50. package/runtime/shared/types/v8-contracts.d.ts +13 -2
  51. package/runtime/storage/db/index.js +71 -28
  52. package/runtime/storage/db/migrations/v8-005-single-status-schema.js +2 -2
  53. package/runtime/storage/db/migrations/v8-006-loop-stage-event-proof-trace-columns.d.ts +9 -0
  54. package/runtime/storage/db/migrations/v8-006-loop-stage-event-proof-trace-columns.js +15 -0
  55. package/runtime/storage/db/schema/v8-entities.d.ts +76 -0
  56. package/runtime/storage/db/schema/v8-entities.js +4 -0
  57. package/runtime/storage/services/write-validation-gate.js +1 -1
  58. package/runtime/storage/v8-state-stores.d.ts +7 -2
  59. package/runtime/storage/v8-state-stores.js +37 -19
@@ -23,6 +23,8 @@
23
23
  export declare const NORMALIZED_EVIDENCE_SCHEMA_VERSION = 1;
24
24
  export type EvidenceSourceKind = "post" | "comment" | "profile" | "task" | "event" | "game_state" | "notification" | "document" | "unknown";
25
25
  export type SummaryProducer = "connector_rules" | "model_assist" | "operator_supplied";
26
+ export type EvidenceContentStatus = "content_present" | "content_missing" | "content_redacted";
27
+ export type EvidenceContentMissingReason = "id_only" | "empty_payload" | "unsupported_shape" | "redacted_private";
26
28
  export interface EvidenceActor {
27
29
  id?: string;
28
30
  displayName?: string;
@@ -36,6 +38,8 @@ export interface NormalizedEvidenceContent {
36
38
  externalId?: string;
37
39
  title?: string;
38
40
  summary: string;
41
+ contentStatus: EvidenceContentStatus;
42
+ contentMissingReason?: EvidenceContentMissingReason;
39
43
  excerpt?: string;
40
44
  canonicalText?: string;
41
45
  actor?: EvidenceActor;
@@ -226,8 +226,25 @@ function normalizeSingleItem(item, options) {
226
226
  const tags = extractTags(item);
227
227
  const entities = extractEntities(item);
228
228
  const metrics = extractMetrics(item);
229
- const rawText = bodyText ?? title ?? "[no readable content]";
230
- const summary = truncate(rawText, 160);
229
+ const hasReadableContent = isNonEmptyString(bodyText) || isNonEmptyString(title);
230
+ const hasContentMarkers = isNonEmptyString(url) ||
231
+ (actor?.displayName && actor.displayName.length > 0) ||
232
+ tags.length > 0 ||
233
+ entities.length > 0 ||
234
+ (metrics && Object.keys(metrics).length > 0);
235
+ let contentStatus = "content_present";
236
+ let contentMissingReason;
237
+ let summary;
238
+ if (!hasReadableContent && !hasContentMarkers) {
239
+ contentStatus = "content_missing";
240
+ contentMissingReason = externalId ? "id_only" : "empty_payload";
241
+ summary = `Content missing: ${contentMissingReason === "id_only" ? "id-only evidence" : "empty payload"}`;
242
+ }
243
+ else {
244
+ const rawText = bodyText ?? title ?? "[no readable content]";
245
+ summary = truncate(rawText, 160);
246
+ }
247
+ const rawText = bodyText ?? title ?? "";
231
248
  const excerpt = rawText.length > summary.length ? truncate(rawText, options.excerptMaxChars ?? 240) : undefined;
232
249
  const canonicalText = truncate(rawText, options.canonicalTextMaxChars ?? 2000);
233
250
  return {
@@ -238,6 +255,8 @@ function normalizeSingleItem(item, options) {
238
255
  externalId,
239
256
  title,
240
257
  summary,
258
+ contentStatus,
259
+ contentMissingReason,
241
260
  excerpt,
242
261
  canonicalText,
243
262
  actor,
@@ -31,6 +31,11 @@ import { extractNormalizedEvidenceItems, computeEvidenceContentHashSync, } from
31
31
  function computeLegacyContentHash(content) {
32
32
  return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
33
33
  }
34
+ function truncate(text, maxChars) {
35
+ if (text.length <= maxChars)
36
+ return text;
37
+ return `${text.slice(0, maxChars)}…`;
38
+ }
34
39
  function buildSourceRef(platformId, capabilityId, itemId, observedAt) {
35
40
  return {
36
41
  uri: `sn://connector_result/${platformId}/${capabilityId}/${itemId}`,
@@ -191,6 +196,32 @@ async function normalizeLegacyConnectorEvidence(db, result, now) {
191
196
  const observedAt = result.observedAt ?? now;
192
197
  const sourceRef = buildSourceRef(result.platformId, result.capabilityId, itemId, observedAt);
193
198
  const sensitivityHint = mergeSensitivityHint(inferSensitivityHint(item.content), item.sensitivityHint);
199
+ const trimmedContent = item.content.trim();
200
+ const looksLikeIdOnly = /^[a-z0-9_-]+$/i.test(trimmedContent) && trimmedContent.length < 64;
201
+ const contentStatus = looksLikeIdOnly || trimmedContent.length === 0
202
+ ? "content_missing"
203
+ : "content_present";
204
+ const contentMissingReason = contentStatus === "content_missing"
205
+ ? trimmedContent.length === 0
206
+ ? "empty_payload"
207
+ : "id_only"
208
+ : undefined;
209
+ const normalized = {
210
+ schemaVersion: 1,
211
+ sourceKind: "unknown",
212
+ platformId: result.platformId,
213
+ capabilityId: result.capabilityId,
214
+ externalId: item.id,
215
+ summary: contentStatus === "content_missing"
216
+ ? `Content missing: ${contentMissingReason === "id_only" ? "id-only evidence" : "empty payload"}`
217
+ : truncate(trimmedContent, 160),
218
+ contentStatus,
219
+ contentMissingReason,
220
+ excerpt: contentStatus === "content_present" ? truncate(trimmedContent, 240) : undefined,
221
+ canonicalText: contentStatus === "content_present" ? truncate(trimmedContent, 2000) : undefined,
222
+ observedAt,
223
+ summaryProducer: "connector_rules",
224
+ };
194
225
  const writeResult = await writeEvidenceItem(db, {
195
226
  id: `ev_${result.platformId}_${itemId}_${observedAt.replace(/[:.]/g, "")}`,
196
227
  createdAt: now,
@@ -201,7 +232,7 @@ async function normalizeLegacyConnectorEvidence(db, result, now) {
201
232
  sourceRefs: [sourceRef],
202
233
  redactionClass: sensitivityHint === "sensitive" ? "blocked" : "none",
203
234
  lifecycleStatus: "pending",
204
- payloadJson: item.metadata ? JSON.stringify(item.metadata) : null,
235
+ payloadJson: JSON.stringify(normalized),
205
236
  });
206
237
  if ("id" in writeResult) {
207
238
  evidenceIds.push(writeResult.id);
@@ -38,6 +38,8 @@ export interface ActionClosureRecord {
38
38
  nextState: string;
39
39
  reason: V8ReasonCode;
40
40
  sourceRefs: SourceRef[];
41
+ proofRefs?: SourceRef[];
42
+ traceRefs?: SourceRef[];
41
43
  memoryReviewCandidate?: MemoryReviewCandidateClosure;
42
44
  closedAt: string;
43
45
  }
@@ -21,6 +21,7 @@
21
21
  * Test coverage: tests/unit/action/action-closure-recorder.test.ts
22
22
  */
23
23
  import { writeActionClosureRecord, readActionClosuresByCycle, } from "../../../storage/v8-state-stores.js";
24
+ import { buildClosureProvenance, cycleTraceRef, closureTraceRef, decisionProofRef, } from "../../../shared/provenance-tier.js";
24
25
  // ───────────────────────────────────────────────────────────────
25
26
  // Helpers
26
27
  // ───────────────────────────────────────────────────────────────
@@ -50,7 +51,8 @@ export async function recordNoActionClosure(db, cycleId, noActionReason, options
50
51
  status: "no_action",
51
52
  reason: noActionReason,
52
53
  nextState: "await_next_cycle",
53
- sourceRefs: [
54
+ sourceRefs: [cycleTraceRef(cycleId)],
55
+ proofRefs: [
54
56
  {
55
57
  uri: `sn://closure/no_action/${cycleId}`,
56
58
  family: "action_closure",
@@ -59,8 +61,9 @@ export async function recordNoActionClosure(db, cycleId, noActionReason, options
59
61
  resolveStatus: "resolvable",
60
62
  },
61
63
  ],
64
+ traceRefs: [cycleTraceRef(cycleId)],
62
65
  redactionClass: "none",
63
- payloadJson: JSON.stringify({ dispatchAttempt: 0, inputSummary: "no-action" }),
66
+ payload: { dispatchAttempt: 0, inputSummary: "no-action" },
64
67
  });
65
68
  if ("reason" in result) {
66
69
  return result;
@@ -73,6 +76,10 @@ export async function recordNoActionClosure(db, cycleId, noActionReason, options
73
76
  export async function recordRememberClosure(db, cycleId, memoryReviewCandidate, options) {
74
77
  const now = options?.now ?? new Date().toISOString();
75
78
  const closureId = `cls_remember_${cycleId}_${now.replace(/[:.]/g, "")}`;
79
+ const provenance = buildClosureProvenance({
80
+ sourceRefs: memoryReviewCandidate.sourceRefs,
81
+ traceRefs: [cycleTraceRef(cycleId)],
82
+ });
76
83
  const result = await writeActionClosureRecord(db, {
77
84
  id: closureId,
78
85
  createdAt: now,
@@ -82,13 +89,15 @@ export async function recordRememberClosure(db, cycleId, memoryReviewCandidate,
82
89
  status: "completed",
83
90
  reason: "remember_for_review",
84
91
  nextState: "pending_daily_review",
85
- sourceRefs: memoryReviewCandidate.sourceRefs,
92
+ sourceRefs: provenance.sourceRefs,
93
+ proofRefs: provenance.proofRefs,
94
+ traceRefs: provenance.traceRefs,
86
95
  redactionClass: "none",
87
- payloadJson: JSON.stringify({
96
+ payload: {
88
97
  memoryReviewCandidate,
89
98
  dispatchAttempt: 1,
90
99
  inputSummary: `remember_for_review topic=${memoryReviewCandidate.topicKey}`,
91
- }),
100
+ },
92
101
  });
93
102
  if ("reason" in result) {
94
103
  return result;
@@ -101,24 +110,21 @@ export async function recordRememberClosure(db, cycleId, memoryReviewCandidate,
101
110
  export async function recordPolicyOutcomeClosure(db, cycleId, closureStatus, reason, params, options) {
102
111
  const now = options?.now ?? new Date().toISOString();
103
112
  const closureId = `cls_${closureStatus}_${cycleId}_${now.replace(/[:.]/g, "")}`;
104
- const sourceRefs = [
105
- {
106
- uri: `sn://closure/${closureStatus}/${cycleId}`,
107
- family: "action_closure",
108
- id: cycleId,
109
- redactionClass: "none",
110
- resolveStatus: "resolvable",
111
- },
113
+ const sourceRefs = params.proposalId
114
+ ? [
115
+ {
116
+ uri: `sn://proposal/${params.proposalId}`,
117
+ family: "action_closure",
118
+ id: params.proposalId,
119
+ redactionClass: "none",
120
+ resolveStatus: "resolvable",
121
+ },
122
+ ]
123
+ : [];
124
+ const proofRefs = [
125
+ closureTraceRef(closureId),
126
+ ...(params.decisionId ? [decisionProofRef(params.decisionId)] : []),
112
127
  ];
113
- if (params.decisionId) {
114
- sourceRefs.push({
115
- uri: `sn://decision/${params.decisionId}`,
116
- family: "action_closure",
117
- id: params.decisionId,
118
- redactionClass: "none",
119
- resolveStatus: "resolvable",
120
- });
121
- }
122
128
  const result = await writeActionClosureRecord(db, {
123
129
  id: closureId,
124
130
  createdAt: now,
@@ -131,13 +137,15 @@ export async function recordPolicyOutcomeClosure(db, cycleId, closureStatus, rea
131
137
  reason,
132
138
  nextState: params.nextState ?? "await_next_cycle",
133
139
  sourceRefs,
140
+ proofRefs,
141
+ traceRefs: [cycleTraceRef(cycleId)],
134
142
  redactionClass: "none",
135
- payloadJson: JSON.stringify({
143
+ payload: {
136
144
  dispatchAttempt: 1,
137
145
  inputSummary: buildInputSummary(params.proposalId, params.decisionId),
138
146
  postProcessing: params.postProcessing ?? [],
139
147
  downgradedActionKind: params.downgradedActionKind,
140
- }),
148
+ },
141
149
  });
142
150
  if ("reason" in result) {
143
151
  return result;
@@ -150,14 +158,19 @@ export async function recordPolicyOutcomeClosure(db, cycleId, closureStatus, rea
150
158
  export async function recordExecutionClosure(db, cycleId, closureStatus, reason, params, options) {
151
159
  const now = options?.now ?? new Date().toISOString();
152
160
  const closureId = `cls_exec_${closureStatus}_${cycleId}_${now.replace(/[:.]/g, "")}`;
153
- const sourceRefs = [
154
- {
155
- uri: `sn://closure/${closureStatus}/${cycleId}`,
156
- family: "action_closure",
157
- id: cycleId,
158
- redactionClass: "none",
159
- resolveStatus: "resolvable",
160
- },
161
+ const sourceRefs = params.executionResultRef
162
+ ? [
163
+ {
164
+ uri: params.executionResultRef,
165
+ family: "connector_result",
166
+ id: params.executionResultRef,
167
+ redactionClass: "none",
168
+ resolveStatus: "resolvable",
169
+ },
170
+ ]
171
+ : [];
172
+ const proofRefs = [
173
+ closureTraceRef(closureId),
161
174
  ];
162
175
  const result = await writeActionClosureRecord(db, {
163
176
  id: closureId,
@@ -171,14 +184,16 @@ export async function recordExecutionClosure(db, cycleId, closureStatus, reason,
171
184
  reason,
172
185
  nextState: params.nextState ?? (closureStatus === "completed" ? "await_next_cycle" : "retryable"),
173
186
  sourceRefs,
187
+ proofRefs,
188
+ traceRefs: [cycleTraceRef(cycleId)],
174
189
  redactionClass: "none",
175
- payloadJson: JSON.stringify({
190
+ payload: {
176
191
  dispatchAttempt: 1,
177
192
  executionResultRef: params.executionResultRef,
178
193
  outputSummary: params.outputSummary,
179
194
  inputSummary: buildInputSummary(params.proposalId, params.decisionId),
180
195
  retryable: params.retryable ?? closureStatus === "failed",
181
- }),
196
+ },
182
197
  });
183
198
  if ("reason" in result) {
184
199
  return result;
@@ -25,6 +25,7 @@
25
25
  import { readJudgmentVerdictById, } from "../../../storage/v8-state-stores.js";
26
26
  import { parseSourceRefs } from "../../../shared/serialization.js";
27
27
  import { ACTION_KIND_REGISTRY } from "../../../shared/types/v8-contracts.js";
28
+ import { classifyDegradedStatus } from "../../../shared/degraded-status-classifier.js";
28
29
  // ───────────────────────────────────────────────────────────────
29
30
  // Helpers
30
31
  // ───────────────────────────────────────────────────────────────
@@ -70,7 +71,7 @@ export async function buildActionProposal(db, judgmentVerdictId, options) {
70
71
  const verdict = readResult.row;
71
72
  if (!verdict) {
72
73
  return {
73
- status: "degraded",
74
+ status: classifyDegradedStatus("state_unreadable"),
74
75
  reason: "state_unreadable",
75
76
  ownerStage: "policy",
76
77
  sourceRefs: [],
@@ -174,7 +175,7 @@ export async function buildActionProposals(db, judgmentVerdictIds, options) {
174
175
  else if ("status" in result && result.status === "remember_for_review") {
175
176
  rememberForReviews.push(result);
176
177
  }
177
- else if ("status" in result && result.status === "degraded") {
178
+ else if ("operatorNextAction" in result) {
178
179
  failed.push({
179
180
  judgmentVerdictId,
180
181
  degraded: result,
@@ -33,6 +33,7 @@ export interface ConnectorDispatchRequest {
33
33
  decision: string;
34
34
  };
35
35
  sourceRefs: string;
36
+ proofRefs: string;
36
37
  }
37
38
  export interface GuidanceDispatchRequest {
38
39
  type: "guidance";
@@ -43,6 +44,7 @@ export interface GuidanceDispatchRequest {
43
44
  decision: string;
44
45
  };
45
46
  sourceRefs: string;
47
+ proofRefs: string;
46
48
  }
47
49
  export interface NoDispatchResult {
48
50
  type: "none";
@@ -21,6 +21,7 @@
21
21
  * Test coverage: tests/unit/action/policy-bound-dispatch.test.ts
22
22
  */
23
23
  import { serializeSourceRefs } from "../../../shared/serialization.js";
24
+ import { classifyDegradedStatus } from "../../../shared/degraded-status-classifier.js";
24
25
  // ───────────────────────────────────────────────────────────────
25
26
  // Helpers
26
27
  // ───────────────────────────────────────────────────────────────
@@ -61,7 +62,8 @@ export function dispatchAllowedAction(proposal, decision, options) {
61
62
  actionKind: target,
62
63
  draftType,
63
64
  policyProof: { decisionId: decision.id, decision: decision.decision },
64
- sourceRefs: serializeSourceRefs(decision.proofRefs),
65
+ sourceRefs: serializeSourceRefs(proposal.sourceRefs),
66
+ proofRefs: serializeSourceRefs(decision.proofRefs),
65
67
  },
66
68
  };
67
69
  }
@@ -77,6 +79,7 @@ export function dispatchAllowedAction(proposal, decision, options) {
77
79
  idempotencyKey: proposal.idempotencyKey,
78
80
  policyProof: { decisionId: decision.id, decision: decision.decision },
79
81
  sourceRefs: serializeSourceRefs(proposal.sourceRefs),
82
+ proofRefs: serializeSourceRefs(decision.proofRefs),
80
83
  },
81
84
  };
82
85
  }
@@ -93,6 +96,7 @@ export function dispatchAllowedAction(proposal, decision, options) {
93
96
  draftType,
94
97
  policyProof: { decisionId: decision.id, decision: decision.decision },
95
98
  sourceRefs: serializeSourceRefs(proposal.sourceRefs),
99
+ proofRefs: serializeSourceRefs(decision.proofRefs),
96
100
  },
97
101
  };
98
102
  }
@@ -102,10 +106,10 @@ export function dispatchAllowedAction(proposal, decision, options) {
102
106
  return {
103
107
  type: "degraded",
104
108
  degraded: {
105
- status: "degraded",
109
+ status: classifyDegradedStatus("closure_failed"),
106
110
  reason: "closure_failed",
107
111
  ownerStage: "execution",
108
- sourceRefs: decision.proofRefs,
112
+ sourceRefs: proposal.sourceRefs,
109
113
  operatorNextAction: "Unexpected policy decision shape",
110
114
  retryable: false,
111
115
  },
@@ -0,0 +1,82 @@
1
+ /**
2
+ * CycleFinalizer — v8 exactly-one closure invariant (T-AC.R.2, T-AC.R.3)
3
+ *
4
+ * Core logic: provide a single boundary that records exactly one
5
+ * ActionClosureRecord or no-action closure per heartbeat cycle.
6
+ * Enforces idempotency key = cycleId, write order (closure row first),
7
+ * and restart reconcile for orphaned closure/event rows.
8
+ *
9
+ * Design authority:
10
+ * - `.anws/v8/04_SYSTEM_DESIGN/action-closure-policy-system.md §6.1a`
11
+ * - `.anws/v8/04_SYSTEM_DESIGN/action-closure-policy-system.detail.md §3.4`
12
+ * - `.anws/v8/04_SYSTEM_DESIGN/control-plane-system.md §3.4`
13
+ *
14
+ * Dependencies:
15
+ * - `src/core/second-nature/action/action-closure-recorder.js`
16
+ * - `src/storage/v8-state-stores.js`
17
+ *
18
+ * Boundary:
19
+ * - One cycle → one closure row.
20
+ * - Duplicate terminal closure for same cycleId → `unsafe` idempotency conflict.
21
+ * - On partial failure, returns degraded diagnostic; caller records stage event.
22
+ * - Reconcile detects orphaned closure (event missing) or orphaned event (closure missing).
23
+ *
24
+ * Test coverage: tests/unit/control-plane/cycle-finalizer.test.ts
25
+ */
26
+ import type { StateDatabase } from "../../../storage/db/index.js";
27
+ import type { DegradedOperationResult, V8ReasonCode, SourceRef } from "../../../shared/types/v8-contracts.js";
28
+ export interface CycleFinalizerResult {
29
+ closureRef?: SourceRef;
30
+ noActionReason?: V8ReasonCode;
31
+ degraded?: DegradedOperationResult;
32
+ }
33
+ export type ClosureKind = {
34
+ kind: "no_action";
35
+ reason: V8ReasonCode;
36
+ } | {
37
+ kind: "policy";
38
+ closureStatus: "completed" | "denied" | "deferred" | "downgraded";
39
+ reason: V8ReasonCode;
40
+ proposalId: string;
41
+ decisionId: string;
42
+ platformId?: string;
43
+ capabilityId?: string;
44
+ downgradedActionKind?: string;
45
+ } | {
46
+ kind: "execution";
47
+ closureStatus: "completed" | "failed";
48
+ reason: V8ReasonCode;
49
+ proposalId: string;
50
+ decisionId: string;
51
+ platformId?: string;
52
+ capabilityId?: string;
53
+ executionResultRef?: string;
54
+ outputSummary?: string;
55
+ };
56
+ export declare function finalizeCycle(db: StateDatabase, cycleId: string, closure: ClosureKind, options?: {
57
+ now?: string;
58
+ }): Promise<CycleFinalizerResult>;
59
+ export interface ReconcileResult {
60
+ /** Closure row exists but no closure stage event — event should be replayed. */
61
+ orphanedClosure?: {
62
+ closureId: string;
63
+ cycleId: string;
64
+ };
65
+ /** Stage event exists but no closure row — closure is missing, do not fabricate. */
66
+ orphanedEvent?: {
67
+ cycleId: string;
68
+ stage: string;
69
+ };
70
+ /** No orphan detected — cycle is consistent. */
71
+ consistent: boolean;
72
+ degraded?: DegradedOperationResult;
73
+ }
74
+ /**
75
+ * Reconcile a cycle's closure and stage event rows.
76
+ * Called at cycle start or by `loop_status` to detect partial-failure leftovers.
77
+ *
78
+ * Per design §6.1a:
79
+ * - closure row written, event missing → replay event with traceRefs
80
+ * - event written, closure row missing → report closure_unavailable / unsafe
81
+ */
82
+ export declare function reconcileCycleClosure(db: StateDatabase, cycleId: string): Promise<ReconcileResult>;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * CycleFinalizer — v8 exactly-one closure invariant (T-AC.R.2, T-AC.R.3)
3
+ *
4
+ * Core logic: provide a single boundary that records exactly one
5
+ * ActionClosureRecord or no-action closure per heartbeat cycle.
6
+ * Enforces idempotency key = cycleId, write order (closure row first),
7
+ * and restart reconcile for orphaned closure/event rows.
8
+ *
9
+ * Design authority:
10
+ * - `.anws/v8/04_SYSTEM_DESIGN/action-closure-policy-system.md §6.1a`
11
+ * - `.anws/v8/04_SYSTEM_DESIGN/action-closure-policy-system.detail.md §3.4`
12
+ * - `.anws/v8/04_SYSTEM_DESIGN/control-plane-system.md §3.4`
13
+ *
14
+ * Dependencies:
15
+ * - `src/core/second-nature/action/action-closure-recorder.js`
16
+ * - `src/storage/v8-state-stores.js`
17
+ *
18
+ * Boundary:
19
+ * - One cycle → one closure row.
20
+ * - Duplicate terminal closure for same cycleId → `unsafe` idempotency conflict.
21
+ * - On partial failure, returns degraded diagnostic; caller records stage event.
22
+ * - Reconcile detects orphaned closure (event missing) or orphaned event (closure missing).
23
+ *
24
+ * Test coverage: tests/unit/control-plane/cycle-finalizer.test.ts
25
+ */
26
+ import { recordNoActionClosure, recordPolicyOutcomeClosure, recordExecutionClosure, } from "../action/action-closure-recorder.js";
27
+ import { readActionClosuresByCycle, readLoopStageEventsByCycle, } from "../../../storage/v8-state-stores.js";
28
+ import { classifyDegradedStatus } from "../../../shared/degraded-status-classifier.js";
29
+ // Terminal closure statuses — a cycle with any of these is considered finalized.
30
+ const TERMINAL_CLOSURE_STATUSES = new Set([
31
+ "completed",
32
+ "no_action",
33
+ "denied",
34
+ "deferred",
35
+ "downgraded",
36
+ "failed",
37
+ ]);
38
+ /**
39
+ * Check whether a cycle already has a terminal closure row.
40
+ * This is the idempotency gate for `finalizeCycle`.
41
+ */
42
+ async function checkExistingTerminalClosure(db, cycleId) {
43
+ const existing = await readActionClosuresByCycle(db, cycleId);
44
+ if (existing.degraded)
45
+ return { hasTerminal: false };
46
+ const terminal = existing.rows.find((r) => TERMINAL_CLOSURE_STATUSES.has(r.status));
47
+ if (terminal)
48
+ return { hasTerminal: true, existingClosureId: terminal.id };
49
+ return { hasTerminal: false };
50
+ }
51
+ export async function finalizeCycle(db, cycleId, closure, options) {
52
+ const now = options?.now ?? new Date().toISOString();
53
+ // Idempotency gate: check if this cycle already has a terminal closure.
54
+ // recordNoActionClosure has its own idempotency check, but policy/execution do not.
55
+ // We check uniformly here to enforce the exactly-one invariant.
56
+ if (closure.kind !== "no_action") {
57
+ const existing = await checkExistingTerminalClosure(db, cycleId);
58
+ if (existing.hasTerminal) {
59
+ return {
60
+ degraded: {
61
+ status: "unsafe",
62
+ reason: "closure_idempotency_conflict",
63
+ ownerStage: "closure",
64
+ sourceRefs: [],
65
+ operatorNextAction: `Cycle ${cycleId} already has terminal closure ${existing.existingClosureId}; duplicate finalize blocked`,
66
+ retryable: false,
67
+ },
68
+ };
69
+ }
70
+ }
71
+ switch (closure.kind) {
72
+ case "no_action": {
73
+ const result = await recordNoActionClosure(db, cycleId, closure.reason, { now });
74
+ if ("closureId" in result) {
75
+ return {
76
+ closureRef: {
77
+ uri: `sn://closure/${result.closureId}`,
78
+ family: "action_closure",
79
+ id: result.closureId,
80
+ redactionClass: "none",
81
+ resolveStatus: "resolvable",
82
+ },
83
+ noActionReason: closure.reason,
84
+ };
85
+ }
86
+ return {
87
+ noActionReason: closure.reason,
88
+ degraded: result,
89
+ };
90
+ }
91
+ case "policy": {
92
+ const result = await recordPolicyOutcomeClosure(db, cycleId, closure.closureStatus, closure.reason, {
93
+ proposalId: closure.proposalId,
94
+ decisionId: closure.decisionId,
95
+ platformId: closure.platformId,
96
+ capabilityId: closure.capabilityId,
97
+ downgradedActionKind: closure.downgradedActionKind,
98
+ }, { now });
99
+ if ("closureId" in result) {
100
+ return {
101
+ closureRef: {
102
+ uri: `sn://closure/${result.closureId}`,
103
+ family: "action_closure",
104
+ id: result.closureId,
105
+ redactionClass: "none",
106
+ resolveStatus: "resolvable",
107
+ },
108
+ };
109
+ }
110
+ return { degraded: result };
111
+ }
112
+ case "execution": {
113
+ const result = await recordExecutionClosure(db, cycleId, closure.closureStatus, closure.reason, {
114
+ proposalId: closure.proposalId,
115
+ decisionId: closure.decisionId,
116
+ platformId: closure.platformId,
117
+ capabilityId: closure.capabilityId,
118
+ executionResultRef: closure.executionResultRef,
119
+ outputSummary: closure.outputSummary,
120
+ }, { now });
121
+ if ("closureId" in result) {
122
+ return {
123
+ closureRef: {
124
+ uri: `sn://closure/${result.closureId}`,
125
+ family: "action_closure",
126
+ id: result.closureId,
127
+ redactionClass: "none",
128
+ resolveStatus: "resolvable",
129
+ },
130
+ };
131
+ }
132
+ return { degraded: result };
133
+ }
134
+ default: {
135
+ const exhaustive = closure;
136
+ return {
137
+ degraded: {
138
+ status: classifyDegradedStatus("closure_failed"),
139
+ reason: "closure_failed",
140
+ ownerStage: "closure",
141
+ sourceRefs: [],
142
+ operatorNextAction: `Unknown closure kind: ${JSON.stringify(exhaustive)}`,
143
+ retryable: false,
144
+ },
145
+ };
146
+ }
147
+ }
148
+ }
149
+ /**
150
+ * Reconcile a cycle's closure and stage event rows.
151
+ * Called at cycle start or by `loop_status` to detect partial-failure leftovers.
152
+ *
153
+ * Per design §6.1a:
154
+ * - closure row written, event missing → replay event with traceRefs
155
+ * - event written, closure row missing → report closure_unavailable / unsafe
156
+ */
157
+ export async function reconcileCycleClosure(db, cycleId) {
158
+ const closures = await readActionClosuresByCycle(db, cycleId);
159
+ if (closures.degraded) {
160
+ return {
161
+ consistent: false,
162
+ degraded: closures.degraded,
163
+ };
164
+ }
165
+ const events = await readLoopStageEventsByCycle(db, cycleId);
166
+ if (events.degraded) {
167
+ return {
168
+ consistent: false,
169
+ degraded: events.degraded,
170
+ };
171
+ }
172
+ const terminalClosure = closures.rows.find((r) => TERMINAL_CLOSURE_STATUSES.has(r.status));
173
+ const closureEvent = events.rows.find((r) => r.stage === "closure" || r.stage === "action_closure");
174
+ if (terminalClosure && !closureEvent) {
175
+ return {
176
+ orphanedClosure: { closureId: terminalClosure.id, cycleId },
177
+ consistent: false,
178
+ };
179
+ }
180
+ if (!terminalClosure && closureEvent) {
181
+ return {
182
+ orphanedEvent: { cycleId, stage: closureEvent.stage },
183
+ consistent: false,
184
+ };
185
+ }
186
+ return { consistent: true };
187
+ }