create-principles-disciple 1.145.35 → 1.145.37

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 (81) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/console/dist/server/index.js +4 -0
  3. package/console/dist/server/models/ApprovalsConsoleModel.d.ts +22 -2
  4. package/console/dist/server/models/ApprovalsConsoleModel.js +56 -16
  5. package/console/dist/server/models/ApprovalsGroupedConsoleModel.d.ts +8 -0
  6. package/console/dist/server/models/ApprovalsGroupedConsoleModel.js +3 -2
  7. package/console/dist/server/models/approval-review-material.d.ts +15 -0
  8. package/console/dist/server/models/approval-review-material.js +32 -0
  9. package/console/dist/server/routes/approvals.js +32 -7
  10. package/console/dist/server/utils/response.js +8 -0
  11. package/console/dist/ui/api.d.ts +8 -2
  12. package/console/dist/ui/api.js +8 -4
  13. package/console/dist/ui/i18n/en.json +98 -7
  14. package/console/dist/ui/i18n/zh-CN.json +98 -7
  15. package/console/dist/ui/pages/focus/ApprovalSubjectReading.d.ts +24 -0
  16. package/console/dist/ui/pages/focus/ApprovalSubjectReading.js +133 -0
  17. package/console/dist/ui/pages/focus/FocusPage.d.ts +5 -26
  18. package/console/dist/ui/pages/focus/FocusPage.js +166 -459
  19. package/console/dist/ui/pages/focus/OwnerWorkbench.d.ts +19 -0
  20. package/console/dist/ui/pages/focus/OwnerWorkbench.js +75 -0
  21. package/console/dist/ui/pages/focus/RuleCandidateBindingPanel.d.ts +4 -1
  22. package/console/dist/ui/pages/focus/RuleCandidateBindingPanel.js +7 -3
  23. package/console/dist/ui/pages/focus/focus-validation.js +31 -0
  24. package/console/dist/ui/pages/principles/PrincipleDetailPage.js +110 -86
  25. package/console/dist/ui/pages/principles/PrinciplesPage.js +39 -30
  26. package/console/dist/ui/utils/approval-review.d.ts +30 -0
  27. package/console/dist/ui/utils/approval-review.js +50 -0
  28. package/console/dist/ui/utils/reading-layout.d.ts +1 -0
  29. package/console/dist/ui/utils/reading-layout.js +1 -1
  30. package/console/dist/ui/utils/validators.d.ts +4 -0
  31. package/console/dist/ui/utils/validators.js +10 -2
  32. package/console/dist/web/assets/app.css +119 -11
  33. package/console/dist/web/assets/app.js +40 -30
  34. package/core/dist/runtime-v2/activation/activation-types.d.ts +12 -2
  35. package/core/dist/runtime-v2/activation/activation-types.d.ts.map +1 -1
  36. package/core/dist/runtime-v2/activation/activation-types.js.map +1 -1
  37. package/core/dist/runtime-v2/activation/approval-queue.d.ts +3 -2
  38. package/core/dist/runtime-v2/activation/approval-queue.d.ts.map +1 -1
  39. package/core/dist/runtime-v2/activation/approval-queue.js +6 -4
  40. package/core/dist/runtime-v2/activation/approval-queue.js.map +1 -1
  41. package/core/dist/runtime-v2/activation/approval-review-guard.d.ts +26 -0
  42. package/core/dist/runtime-v2/activation/approval-review-guard.d.ts.map +1 -0
  43. package/core/dist/runtime-v2/activation/approval-review-guard.js +25 -0
  44. package/core/dist/runtime-v2/activation/approval-review-guard.js.map +1 -0
  45. package/core/dist/runtime-v2/activation/memory-approval-store.d.ts +6 -2
  46. package/core/dist/runtime-v2/activation/memory-approval-store.d.ts.map +1 -1
  47. package/core/dist/runtime-v2/activation/memory-approval-store.js +26 -2
  48. package/core/dist/runtime-v2/activation/memory-approval-store.js.map +1 -1
  49. package/core/dist/runtime-v2/activation/sqlite-approval-store.d.ts +6 -2
  50. package/core/dist/runtime-v2/activation/sqlite-approval-store.d.ts.map +1 -1
  51. package/core/dist/runtime-v2/activation/sqlite-approval-store.js +59 -2
  52. package/core/dist/runtime-v2/activation/sqlite-approval-store.js.map +1 -1
  53. package/core/dist/runtime-v2/diagnostician-association-shadow.d.ts +24 -0
  54. package/core/dist/runtime-v2/diagnostician-association-shadow.d.ts.map +1 -0
  55. package/core/dist/runtime-v2/diagnostician-association-shadow.js +84 -0
  56. package/core/dist/runtime-v2/diagnostician-association-shadow.js.map +1 -0
  57. package/core/dist/runtime-v2/diagnostician-association-validation.d.ts +18 -0
  58. package/core/dist/runtime-v2/diagnostician-association-validation.d.ts.map +1 -0
  59. package/core/dist/runtime-v2/diagnostician-association-validation.js +47 -0
  60. package/core/dist/runtime-v2/diagnostician-association-validation.js.map +1 -0
  61. package/core/dist/runtime-v2/diagnostician-output.d.ts +123 -0
  62. package/core/dist/runtime-v2/diagnostician-output.d.ts.map +1 -1
  63. package/core/dist/runtime-v2/diagnostician-output.js +41 -0
  64. package/core/dist/runtime-v2/diagnostician-output.js.map +1 -1
  65. package/core/dist/runtime-v2/index.d.ts +2 -0
  66. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  67. package/core/dist/runtime-v2/index.js +1 -0
  68. package/core/dist/runtime-v2/index.js.map +1 -1
  69. package/core/dist/runtime-v2/internalization/diag-router-runner.d.ts +12 -1
  70. package/core/dist/runtime-v2/internalization/diag-router-runner.d.ts.map +1 -1
  71. package/core/dist/runtime-v2/internalization/diag-router-runner.js +50 -2
  72. package/core/dist/runtime-v2/internalization/diag-router-runner.js.map +1 -1
  73. package/core/dist/runtime-v2/internalization/router-context-evidence.d.ts +45 -0
  74. package/core/dist/runtime-v2/internalization/router-context-evidence.d.ts.map +1 -0
  75. package/core/dist/runtime-v2/internalization/router-context-evidence.js +64 -0
  76. package/core/dist/runtime-v2/internalization/router-context-evidence.js.map +1 -0
  77. package/core/package.json +1 -1
  78. package/package.json +1 -1
  79. package/pd-cli/package.json +1 -1
  80. package/plugin/dist/bundle.js +395 -395
  81. package/release-manager/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "productVersion": "2.4.0",
4
- "sourceCommit": "6d080625ac692f02ad2dce386d3f61a769f16214"
3
+ "productVersion": "2.5.0",
4
+ "sourceCommit": "2d48e32a99471329ccb55c5a07e08769e999f3ee"
5
5
  }
@@ -208,6 +208,10 @@ function asyncHandler(fn, timeoutMs = REQUEST_TIMEOUT_MS) {
208
208
  return (innerReq, innerRes) => {
209
209
  const timeoutId = setTimeout(() => {
210
210
  if (!innerRes.headersSent) {
211
+ // The route keeps running after this point; record the real cause
212
+ // before any dropped late write, so the log never leads with a
213
+ // transport error (ERR_HTTP_HEADERS_SENT) instead of the timeout.
214
+ console.warn(`[pd-console] Request timed out after ${timeoutMs}ms: ${innerReq.method ?? 'GET'} ${innerReq.url ?? ''}`);
211
215
  sendJson(innerRes, 504, { success: false, error: 'Request timeout' });
212
216
  innerRes.end();
213
217
  }
@@ -1,4 +1,5 @@
1
- import type { ApprovalListFilter, ApprovalListResult, ApprovalDecisionResult, ApprovalRecord, ApprovalStatus } from '@principles/core/runtime-v2';
1
+ import { type ApprovalReviewMaterial } from './approval-review-material.js';
2
+ import type { ApprovalListFilter, ApprovalListResult, ApprovalDecisionResult, ApprovalRecord, ApprovalStatus, ApprovalReviewGuard } from '@principles/core/runtime-v2';
2
3
  import type { ApprovalWithContext, ActivationDecision } from '@principles/core/runtime-v2';
3
4
  import { type PromptInjectionTargetHost } from '@principles/host-runtime';
4
5
  type UnsupportedChannelResult = {
@@ -49,6 +50,12 @@ export type ApproveWithActivationResult = {
49
50
  } | {
50
51
  ok: false;
51
52
  error: 'not_found';
53
+ } | {
54
+ ok: false;
55
+ error: 'stale_review';
56
+ reason: string;
57
+ nextAction: string;
58
+ status?: never;
52
59
  } | {
53
60
  ok: false;
54
61
  error: 'unsupported_channel';
@@ -113,6 +120,11 @@ export declare class ApprovalsConsoleModel {
113
120
  listApprovals(filter?: ApprovalListFilter): Promise<ApprovalListResult>;
114
121
  getApprovalDetail(approvalId: string): Promise<(ApprovalWithContext & {
115
122
  isMvpProven: boolean;
123
+ writeGuardVersion: 'approval-review-v1';
124
+ review: {
125
+ expectedReview: ApprovalReviewGuard;
126
+ material: ApprovalReviewMaterial;
127
+ };
116
128
  }) | null>;
117
129
  approve(approvalId: string, decidedBy: string, options?: {
118
130
  note?: string;
@@ -120,6 +132,7 @@ export declare class ApprovalsConsoleModel {
120
132
  intentReviewed?: boolean;
121
133
  reviewedArtifactId?: string;
122
134
  retryActivation?: boolean;
135
+ expectedReview?: ApprovalReviewGuard;
123
136
  }): Promise<ApproveWithActivationResult>;
124
137
  /**
125
138
  * PD_PROMPT_CAPACITY_V1 R-B2: run the shared host-runtime deliverability
@@ -175,6 +188,7 @@ export declare class ApprovalsConsoleModel {
175
188
  editedBy: string;
176
189
  newArtifactId: string;
177
190
  editReason: string;
191
+ expectedReview?: ApprovalReviewGuard;
178
192
  }): Promise<{
179
193
  ok: true;
180
194
  record: ApprovalRecord;
@@ -183,9 +197,15 @@ export declare class ApprovalsConsoleModel {
183
197
  error: 'not_found' | 'already_decided' | 'artifact_not_found' | 'artifact_not_validated' | 'artifact_lineage_mismatch';
184
198
  status?: ApprovalStatus;
185
199
  reason?: string;
200
+ } | {
201
+ ok: false;
202
+ error: 'stale_review';
203
+ reason: string;
204
+ nextAction: string;
205
+ status?: never;
186
206
  }>;
187
207
  private dispatchActivationAfterApproval;
188
- reject(approvalId: string, decidedBy: string, reason: string): Promise<ChannelGuardedDecisionResult>;
208
+ reject(approvalId: string, decidedBy: string, reason: string, expectedReview?: ApprovalReviewGuard): Promise<ChannelGuardedDecisionResult>;
189
209
  /**
190
210
  * EP002-R4 follow-up: reopen a terminal approval so the Owner can decide
191
211
  * again in the Console. The concrete gap: an APPROVED activation was
@@ -1,6 +1,8 @@
1
+ import { Value } from '@sinclair/typebox/value';
2
+ import { approvalReviewMaterial } from './approval-review-material.js';
1
3
  import * as fs from 'node:fs';
2
4
  import * as path from 'node:path';
3
- import { SqliteConnection, SqliteApprovalQueueStore, SqliteActivationStateStore, SqlitePIArtifactStore, ActivationDispatcher, PromptWriter, DeferArchiveWriter, RuleHostWriter, createProductionGateDeps, ApprovalCompletionService, ApprovalQueue, mapConfidenceToLabel, isArtifactRevisionOf, detectPromptReplacementTarget, buildOwnerRevisionArtifact, PrincipleTreeLedgerAdapter, MVP_CHANNELS, } from '@principles/core/runtime-v2';
5
+ import { SqliteConnection, SqliteApprovalQueueStore, SqliteActivationStateStore, SqlitePIArtifactStore, ActivationDispatcher, PromptWriter, DeferArchiveWriter, RuleHostWriter, createProductionGateDeps, ApprovalCompletionService, ApprovalQueue, mapConfidenceToLabel, isArtifactRevisionOf, detectPromptReplacementTarget, buildOwnerRevisionArtifact, PrincipleTreeLedgerAdapter, MVP_CHANNELS, computeArtifactDigest, } from '@principles/core/runtime-v2';
4
6
  import { resolveLedgerPrincipleId } from './principle-id-resolution.js';
5
7
  import { createArtifactPrincipleResolutionDeps } from './artifact-principle-resolver.js';
6
8
  import { loadPdConfig, computeFlagsFromLoadResult } from '../config/pd-config-store.js';
@@ -94,17 +96,39 @@ export class ApprovalsConsoleModel {
94
96
  };
95
97
  }
96
98
  async getApprovalDetail(approvalId) {
97
- if (!stateDbExists(this.workspaceDir)) {
99
+ if (!stateDbExists(this.workspaceDir))
98
100
  return null;
101
+ const { queue, connection } = this.createReadContext();
102
+ const db = connection.getDb();
103
+ db.exec('BEGIN');
104
+ try {
105
+ const record = await queue.getById(approvalId);
106
+ if (!record) {
107
+ db.exec('COMMIT');
108
+ return null;
109
+ }
110
+ const artifact = await new SqlitePIArtifactStore(connection).getArtifactById(record.artifactId);
111
+ const common = { schemaVersion: 'approval-review-v1', approvalId, artifactId: record.artifactId, channel: record.channel };
112
+ const expectedReview = artifact
113
+ ? { ...common, artifactState: 'present', artifactDigest: computeArtifactDigest(artifact) }
114
+ : { ...common, artifactState: 'missing' };
115
+ const material = approvalReviewMaterial(record.artifactId, record.channel, artifact?.contentJson ?? null);
116
+ db.exec('COMMIT');
117
+ return { ...record, confidenceLabel: mapConfidenceToLabel(record.confidence), isMvpProven: MVP_PROVEN_CHANNELS.has(record.channel),
118
+ writeGuardVersion: 'approval-review-v1', review: { expectedReview, material } };
119
+ }
120
+ catch (error) {
121
+ try {
122
+ db.exec('ROLLBACK');
123
+ }
124
+ catch { /* Preserve the original read failure. */ }
125
+ if (isMissingTableError(error))
126
+ return null;
127
+ throw error;
128
+ }
129
+ finally {
130
+ connection.close();
99
131
  }
100
- const record = await this.readSafeGetById(approvalId);
101
- if (!record)
102
- return null;
103
- return {
104
- ...record,
105
- confidenceLabel: mapConfidenceToLabel(record.confidence),
106
- isMvpProven: MVP_PROVEN_CHANNELS.has(record.channel),
107
- };
108
132
  }
109
133
  async approve(approvalId, decidedBy, options) {
110
134
  const note = options?.note;
@@ -112,9 +136,12 @@ export class ApprovalsConsoleModel {
112
136
  if (!stateDbExists(this.workspaceDir)) {
113
137
  return { ok: false, error: 'not_found' };
114
138
  }
115
- const existing = await this.readSafeGetById(approvalId);
139
+ const reviewed = options?.expectedReview ? await this.getApprovalDetail(approvalId) : null;
140
+ const existing = options?.expectedReview ? reviewed : await this.readSafeGetById(approvalId);
116
141
  if (!existing)
117
142
  return { ok: false, error: 'not_found' };
143
+ if (options?.expectedReview && !Value.Equal(reviewed?.review.expectedReview, options?.expectedReview))
144
+ return { ok: false, error: 'stale_review', reason: 'The reviewed approval material changed.', nextAction: 'Reload this approval before deciding again.' };
118
145
  if (!MVP_PROVEN_CHANNELS.has(existing.channel)) {
119
146
  return { ok: false, error: 'unsupported_channel', channel: existing.channel };
120
147
  }
@@ -169,7 +196,7 @@ export class ApprovalsConsoleModel {
169
196
  try {
170
197
  approvalResult = existing.channel === 'prompt' && existing.status === 'approved' && options?.retryActivation === true
171
198
  ? { ok: true, record: existing }
172
- : await writeQueue.approve(approvalId, decidedBy, note);
199
+ : await writeQueue.approve(approvalId, decidedBy, note, options?.expectedReview);
173
200
  }
174
201
  finally {
175
202
  try {
@@ -178,6 +205,8 @@ export class ApprovalsConsoleModel {
178
205
  catch { /* best-effort */ }
179
206
  }
180
207
  if (!approvalResult.ok) {
208
+ if (approvalResult.error === 'stale_review')
209
+ return approvalResult;
181
210
  if (approvalResult.error === 'already_decided') {
182
211
  return { ok: false, error: 'already_decided', status: approvalResult.status };
183
212
  }
@@ -508,9 +537,12 @@ export class ApprovalsConsoleModel {
508
537
  if (!stateDbExists(this.workspaceDir)) {
509
538
  return { ok: false, error: 'not_found' };
510
539
  }
511
- const existing = await this.readSafeGetById(approvalId);
540
+ const reviewed = input.expectedReview ? await this.getApprovalDetail(approvalId) : null;
541
+ const existing = input.expectedReview ? reviewed : await this.readSafeGetById(approvalId);
512
542
  if (!existing)
513
543
  return { ok: false, error: 'not_found' };
544
+ if (input.expectedReview && !Value.Equal(reviewed?.review.expectedReview, input.expectedReview))
545
+ return { ok: false, error: 'stale_review', reason: 'The reviewed approval material changed.', nextAction: 'Reload this approval before deciding again.' };
514
546
  if (existing.status !== 'pending') {
515
547
  return { ok: false, error: 'already_decided', status: existing.status };
516
548
  }
@@ -543,6 +575,8 @@ export class ApprovalsConsoleModel {
543
575
  };
544
576
  }
545
577
  const editResult = await queue.edit({
578
+ expectedNewArtifactDigest: input.expectedReview ? computeArtifactDigest(newArtifact) : undefined,
579
+ expectedReview: input.expectedReview,
546
580
  approvalId,
547
581
  editedBy,
548
582
  newArtifactId,
@@ -550,6 +584,8 @@ export class ApprovalsConsoleModel {
550
584
  now: new Date().toISOString(),
551
585
  });
552
586
  if (!editResult.ok) {
587
+ if (editResult.error === 'stale_review')
588
+ return editResult;
553
589
  if (editResult.error === 'already_decided') {
554
590
  return { ok: false, error: 'already_decided', status: editResult.status };
555
591
  }
@@ -702,19 +738,23 @@ export class ApprovalsConsoleModel {
702
738
  catch { /* best-effort */ }
703
739
  }
704
740
  }
705
- async reject(approvalId, decidedBy, reason) {
741
+ // eslint-disable-next-line @typescript-eslint/max-params -- preserve the existing model contract with an additive optional review guard
742
+ async reject(approvalId, decidedBy, reason, expectedReview) {
706
743
  if (!stateDbExists(this.workspaceDir)) {
707
744
  return { ok: false, error: 'not_found' };
708
745
  }
709
- const existing = await this.readSafeGetById(approvalId);
746
+ const reviewed = expectedReview ? await this.getApprovalDetail(approvalId) : null;
747
+ const existing = expectedReview ? reviewed : await this.readSafeGetById(approvalId);
710
748
  if (!existing)
711
749
  return { ok: false, error: 'not_found' };
750
+ if (expectedReview && !Value.Equal(reviewed?.review.expectedReview, expectedReview))
751
+ return { ok: false, error: 'stale_review', reason: 'The reviewed approval material changed.', nextAction: 'Reload this approval before deciding again.' };
712
752
  if (!MVP_PROVEN_CHANNELS.has(existing.channel)) {
713
753
  return { ok: false, error: 'unsupported_channel', channel: existing.channel };
714
754
  }
715
755
  const { queue, connection } = this.createWriteContext();
716
756
  try {
717
- return await queue.reject(approvalId, decidedBy, reason);
757
+ return await queue.reject(approvalId, decidedBy, reason, expectedReview);
718
758
  }
719
759
  finally {
720
760
  try {
@@ -30,6 +30,8 @@ export interface ApprovalGroup {
30
30
  promptBudgetTierFit?: 'fits_all_tiers' | 'model_dependent' | 'exceeds_max_tier' | 'unconfirmed';
31
31
  revisionReviewUnavailable?: boolean;
32
32
  revisionReview?: {
33
+ artifactId: string;
34
+ artifactDigest: string;
33
35
  oldStatement: string;
34
36
  newStatement: string;
35
37
  intentFields: Record<string, string>;
@@ -48,6 +50,12 @@ export interface ApprovalGroup {
48
50
  artifactId: string;
49
51
  channel: string;
50
52
  createdAt: string;
53
+ /**
54
+ * This record's own revision review is incomplete (the group flag also
55
+ * covers the multi-pending ambiguity, so readers must not use it to block
56
+ * an intact sibling approval).
57
+ */
58
+ revisionReviewUnavailable?: boolean;
51
59
  }[];
52
60
  }
53
61
  export interface ApprovalsGroupedResponse {
@@ -1,4 +1,4 @@
1
- import { SqliteConnection, SqliteApprovalQueueStore, SqlitePIArtifactStore, ApprovalQueue, RUNTIME_V2_PRINCIPLE_BUDGET, detectPromptReplacementTarget, SqliteActivationStateStore, } from '@principles/core/runtime-v2';
1
+ import { SqliteConnection, SqliteApprovalQueueStore, SqlitePIArtifactStore, ApprovalQueue, RUNTIME_V2_PRINCIPLE_BUDGET, detectPromptReplacementTarget, computeArtifactDigest, SqliteActivationStateStore, } from '@principles/core/runtime-v2';
2
2
  import { checkPromptArtifactDeliverability, operatorDeclaredBudgetResolution, resolveLivePromptInjectionProjection, RUNTIME_BUDGET_OBSERVATION_STALE_REASON, runtimeBudgetObservationToResolution, selectConsoleRuntimeBudgetFacts, } from '@principles/host-runtime';
3
3
  import { PROMPT_BUDGET_HOST_KINDS } from '@principles/core/runtime-v2';
4
4
  import { loadLedger } from '@principles/core/principle-tree-ledger';
@@ -244,7 +244,7 @@ export class ApprovalsGroupedConsoleModel {
244
244
  intentFields[key] = value;
245
245
  }
246
246
  }
247
- revisionReviews.set(artifact.artifactId, { oldStatement, newStatement, intentFields });
247
+ revisionReviews.set(artifact.artifactId, { artifactId: artifact.artifactId, artifactDigest: computeArtifactDigest(artifact), oldStatement, newStatement, intentFields });
248
248
  break;
249
249
  }
250
250
  }
@@ -297,6 +297,7 @@ export class ApprovalsGroupedConsoleModel {
297
297
  status: approval.status === 'approved' || approval.status === 'rejected'
298
298
  ? approval.status
299
299
  : 'pending',
300
+ ...(revisionReviewUnavailable.has(approval.artifactId) ? { revisionReviewUnavailable: true } : {}),
300
301
  });
301
302
  }
302
303
  const groups = [];
@@ -0,0 +1,15 @@
1
+ export interface ApprovalReviewMaterial {
2
+ availability: 'available' | 'missing' | 'unreadable';
3
+ artifactId: string;
4
+ statement: string | null;
5
+ rationale: string | null;
6
+ scope: string[];
7
+ risks: string[] | null;
8
+ targetBehavior: string | null;
9
+ exactBody: {
10
+ kind: 'principle' | 'rule_code';
11
+ text: string;
12
+ } | null;
13
+ }
14
+ /** Whitelisted normative fields only; raw artifact JSON is never returned. */
15
+ export declare function approvalReviewMaterial(artifactId: string, channel: string, contentJson: string | null): ApprovalReviewMaterial;
@@ -0,0 +1,32 @@
1
+ function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
2
+ function text(value) { return typeof value === 'string' && value.trim() !== '' ? value : null; }
3
+ /** Whitelisted normative fields only; raw artifact JSON is never returned. */
4
+ export function approvalReviewMaterial(artifactId, channel, contentJson) {
5
+ const result = { artifactId, availability: contentJson === null ? 'missing' : 'unreadable', statement: null, rationale: null, scope: [], risks: null, targetBehavior: null, exactBody: null };
6
+ if (contentJson === null)
7
+ return result;
8
+ let parsed;
9
+ try {
10
+ parsed = JSON.parse(contentJson);
11
+ }
12
+ catch {
13
+ return result;
14
+ }
15
+ if (!record(parsed))
16
+ return result;
17
+ if (record(parsed.intentContract))
18
+ result.targetBehavior = text(parsed.intentContract.targetBehavior);
19
+ if (Array.isArray(parsed.risks) && parsed.risks.every(item => typeof item === 'string'))
20
+ result.risks = parsed.risks;
21
+ const draft = record(parsed.principleDraft) ? parsed.principleDraft : parsed;
22
+ result.statement = text(draft.statement);
23
+ result.rationale = text(draft.rationale);
24
+ if (Array.isArray(draft.applicability) && draft.applicability.every(item => typeof item === 'string'))
25
+ result.scope = draft.applicability;
26
+ const body = channel === 'code_tool_hook' ? text(parsed.implementationCode) : result.statement;
27
+ if (body !== null) {
28
+ result.exactBody = { kind: channel === 'code_tool_hook' ? 'rule_code' : 'principle', text: body };
29
+ result.availability = 'available';
30
+ }
31
+ return result;
32
+ }
@@ -1,4 +1,4 @@
1
- import { MVP_CHANNELS } from '@principles/core/runtime-v2';
1
+ import { isApprovalReviewGuard, MVP_CHANNELS } from '@principles/core/runtime-v2';
2
2
  import { ApprovalsConsoleModel } from '../models/ApprovalsConsoleModel.js';
3
3
  import { sendSuccess, sendError, sendNotFound, sendBadRequest } from '../utils/response.js';
4
4
  import { parseQuery, readBody } from '../utils/request.js';
@@ -124,6 +124,11 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
124
124
  const parsed = parseJsonBody(body, res);
125
125
  if (!parsed)
126
126
  return;
127
+ const expectedReview = Object.hasOwn(parsed, 'expectedReview') ? parsed.expectedReview : undefined;
128
+ if (Object.hasOwn(parsed, 'expectedReview') && (!isApprovalReviewGuard(expectedReview) || expectedReview.approvalId !== approvalId || expectedReview.artifactState !== 'present')) {
129
+ sendBadRequest(res, 'expectedReview must identify this approval and its exact reviewed artifact/version');
130
+ return;
131
+ }
127
132
  if (Object.hasOwn(parsed, 'note') && typeof parsed.note !== 'string') {
128
133
  sendBadRequest(res, 'note must be a string');
129
134
  return;
@@ -158,9 +163,13 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
158
163
  intentReviewed: parsed.intentReviewed === true,
159
164
  ...(typeof parsed.reviewedArtifactId === 'string' ? { reviewedArtifactId: parsed.reviewedArtifactId } : {}),
160
165
  retryActivation: parsed.retryActivation === true,
166
+ expectedReview: isApprovalReviewGuard(expectedReview) ? expectedReview : undefined,
161
167
  });
162
168
  if (!result.ok) {
163
- if (result.error === 'not_found') {
169
+ if (result.error === 'stale_review') {
170
+ sendError(res, 409, 'stale_review', result.reason, { reason: 'stale_review', nextAction: result.nextAction });
171
+ }
172
+ else if (result.error === 'not_found') {
164
173
  sendNotFound(res, 'Approval ' + approvalId + ' not found');
165
174
  }
166
175
  else if (result.error === 'unsupported_channel') {
@@ -170,7 +179,7 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
170
179
  sendError(res, 409, result.error, result.reason, { nextAction: result.nextAction });
171
180
  }
172
181
  else if (result.error === 'activation_failed') {
173
- sendError(res, 500, 'activation_failed', `Approval was ${result.approvalRolledBack ? 'rolled back to pending' : 'approved but activation failed'}. Reason: ${result.reason}`, { nextAction: result.nextAction ?? 'Inspect or regenerate the rule artifact, verify it is validated, then retry approval.' });
182
+ sendError(res, 500, 'activation_failed', `Approval was ${result.approvalRolledBack ? 'rolled back to pending' : 'approved but activation failed'}. Reason: ${result.reason}`, { reason: 'activation_failed', approvalRolledBack: result.approvalRolledBack, nextAction: result.nextAction ?? 'Inspect or regenerate the rule artifact, verify it is validated, then retry approval.' });
174
183
  }
175
184
  else if (result.error === 'prompt_capacity_refused') {
176
185
  // R-B2: refused BEFORE any governance write — the approval stays
@@ -224,6 +233,11 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
224
233
  const parsed = parseJsonBody(body, res);
225
234
  if (!parsed)
226
235
  return;
236
+ const expectedReview = Object.hasOwn(parsed, 'expectedReview') ? parsed.expectedReview : undefined;
237
+ if (Object.hasOwn(parsed, 'expectedReview') && (!isApprovalReviewGuard(expectedReview) || expectedReview.approvalId !== approvalId)) {
238
+ sendBadRequest(res, 'expectedReview must identify this approval and its exact reviewed artifact/version');
239
+ return;
240
+ }
227
241
  if (!Object.hasOwn(parsed, 'reason') || typeof parsed.reason !== 'string') {
228
242
  sendBadRequest(res, 'reason is required and must be a string');
229
243
  return;
@@ -232,9 +246,12 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
232
246
  sendBadRequest(res, 'reason must not be empty');
233
247
  return;
234
248
  }
235
- const result = await model.reject(approvalId, 'operator', parsed.reason);
249
+ const result = await model.reject(approvalId, 'operator', parsed.reason, isApprovalReviewGuard(expectedReview) ? expectedReview : undefined);
236
250
  if (!result.ok) {
237
- if (result.error === 'not_found') {
251
+ if (result.error === 'stale_review') {
252
+ sendError(res, 409, 'stale_review', result.reason, { reason: 'stale_review', nextAction: result.nextAction });
253
+ }
254
+ else if (result.error === 'not_found') {
238
255
  sendNotFound(res, 'Approval ' + approvalId + ' not found');
239
256
  }
240
257
  else if (result.error === 'unsupported_channel') {
@@ -319,6 +336,11 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
319
336
  const parsed = parseJsonBody(body, res);
320
337
  if (!parsed)
321
338
  return;
339
+ const expectedReview = Object.hasOwn(parsed, 'expectedReview') ? parsed.expectedReview : undefined;
340
+ if (Object.hasOwn(parsed, 'expectedReview') && (!isApprovalReviewGuard(expectedReview) || expectedReview.approvalId !== approvalId || expectedReview.artifactState !== 'present')) {
341
+ sendBadRequest(res, 'expectedReview must identify this approval and its exact reviewed artifact/version');
342
+ return;
343
+ }
322
344
  if (!Object.hasOwn(parsed, 'newArtifactId') || typeof parsed.newArtifactId !== 'string') {
323
345
  sendBadRequest(res, 'newArtifactId is required and must be a string');
324
346
  return;
@@ -331,9 +353,12 @@ export async function handleApprovalsRoute(req, res, workspaceDir, subPath) {
331
353
  sendBadRequest(res, 'editReason must not be empty');
332
354
  return;
333
355
  }
334
- const result = await model.editApproval({ approvalId, editedBy: 'operator', newArtifactId: parsed.newArtifactId, editReason: parsed.editReason });
356
+ const result = await model.editApproval({ expectedReview: isApprovalReviewGuard(expectedReview) ? expectedReview : undefined, approvalId, editedBy: 'operator', newArtifactId: parsed.newArtifactId, editReason: parsed.editReason });
335
357
  if (!result.ok) {
336
- if (result.error === 'not_found') {
358
+ if (result.error === 'stale_review') {
359
+ sendError(res, 409, 'stale_review', result.reason, { reason: 'stale_review', nextAction: result.nextAction });
360
+ }
361
+ else if (result.error === 'not_found') {
337
362
  sendNotFound(res, 'Approval ' + approvalId + ' not found');
338
363
  }
339
364
  else if (result.error === 'already_decided') {
@@ -1,4 +1,12 @@
1
1
  export function sendJson(res, statusCode, payload) {
2
+ // A response can already be committed by an earlier writer — most often the
3
+ // request-timeout path in server/index.ts, which answers 504 while the route
4
+ // is still running. Writing again throws ERR_HTTP_HEADERS_SENT, which hides
5
+ // the first (real) failure behind a transport error. Drop the late write.
6
+ if (res.headersSent || res.writableEnded) {
7
+ console.warn(`[pd-console] Dropped late response write (status ${statusCode}): headers already sent`);
8
+ return;
9
+ }
2
10
  const body = JSON.stringify(payload);
3
11
  res.writeHead(statusCode, {
4
12
  'Content-Type': 'application/json; charset=utf-8',
@@ -1,3 +1,4 @@
1
+ import { type ApprovalReviewGuard, type ApprovalReviewData } from './utils/approval-review.js';
1
2
  import type { ApiResponse } from "../types.js";
2
3
  import type { OwnerDecisionsData, OwnerResolutionResultData, OwnerDecisionInboxData, OwnerDecisionLibraryData } from "./utils/validators.js";
3
4
  import type { DesignGateAction, DesignGateDecision, DesignGateDeclaration, DesignGateScope } from "@principles/core/runtime-v2/internalization/design-gate-facts";
@@ -210,13 +211,18 @@ declare function fetchEvidenceAudit(selectorType: "principle" | "activation" | "
210
211
  evidenceId: string;
211
212
  };
212
213
  }): Promise<ApiResponse<EvidenceAuditData>>;
214
+ export declare function fetchApprovalReview(approvalId: string): Promise<ApiResponse<ApprovalReviewData>>;
213
215
  declare function approveApproval(approvalId: string, note?: string, options?: {
216
+ expectedReview?: ApprovalReviewGuard;
214
217
  targetHost?: 'openclaw' | 'codex' | 'workbuddy';
215
218
  intentReviewed?: boolean;
216
219
  reviewedArtifactId?: string;
217
220
  }): Promise<ApiResponse<ApprovalRecordData>>;
218
- declare function rejectApproval(approvalId: string, reason: string): Promise<ApiResponse<ApprovalRecordData>>;
219
- declare function editApproval(approvalId: string, newArtifactId: string, editReason: string): Promise<ApiResponse<ApprovalRecordData>>;
221
+ declare function rejectApproval(approvalId: string, reason: string, expectedReview?: ApprovalReviewGuard): Promise<ApiResponse<ApprovalRecordData>>;
222
+ declare function editApproval(approvalId: string, newArtifactId: string, editReason: string | {
223
+ reason: string;
224
+ expectedReview: ApprovalReviewGuard;
225
+ }): Promise<ApiResponse<ApprovalRecordData>>;
220
226
  declare function createFeedbackReport(input: unknown, diagnostics: unknown): Promise<ApiResponse<FeedbackReportData>>;
221
227
  declare function listFeedbackReports(): Promise<ApiResponse<{
222
228
  drafts: FeedbackDraftSummaryData[];
@@ -1,3 +1,4 @@
1
+ import { validateApprovalReview } from './utils/approval-review.js';
1
2
  import { clearStoredToken, loadStoredToken, storeToken } from "./utils/token-storage.js";
2
3
  import { validateBindingReview, validateBindingQueue, validateBindingMutation, validateBindingRevise } from './utils/rule-binding-validators.js';
3
4
  import { validateErrorResponse, validateHeaders, validateFeedbackReport, validateFeedbackDraftsList, validateFeedbackDraftEnvelope, validateFeedbackChannels, validateFeedbackSubmitResult, validateDeleteEnvelope, validateWorkspaceEntry, validateWorkspaceList, validateRemovedEnvelope, validateSyncResult, validateConfigSummary, validateConfigCatalog, validateAgentBindingUpdate, validateDefaultRuntimeUpdate, validateRuntimeProfileMutation, validateReadinessCheck, validateFeatureFlagUpdate, validateOutputLanguage, validateGovernanceQueue, validateRecoveryResult, validateFailedTaskDetail, validateOwnerDecisionsData, validateOwnerResolutionResult, validateActivations, validateDisableActivation, validateLifecycleMetrics, validateUpdateStatus, validateUpdateHistory, validateApplyUpdateResult, validateUpdateRecovery, validateUpdateTransaction, validateApprovalRecordDirect, validatePrinciplesList, validateCorePrinciples, validateApprovalsGrouped, validateEvidenceChain, validateTrajectoryData, validateIntentSummary, validateIntentDecisionList, validateIntentDecisionResult, validateIntentDecisionSummary, validateFollowUpResponse, validateIntentInitResult, validateIntentSaveResult, validateIntentRawContent, validateIntentVersions, validateOwnerGovernanceView, validateOwnerDecisionViewCore, validateOwnerDecisionInbox, validateOwnerDecisionLibrary, validateGovernanceExperienceSnapshot, validateRuleCodeOwnerReview, validateRuleCodeMutation, validateOwnerIdentityView, validateOwnerIdentityRegister, validateOwnerIdentityUnregister, } from "./utils/validators.js";
@@ -556,23 +557,26 @@ async function fetchEvidenceAudit(selectorType, id, cursor) {
556
557
  }
557
558
  return request(`/api/v1/receipts/evidence-audit?${query.toString()}`, undefined, validateEvidenceAudit);
558
559
  }
560
+ export function fetchApprovalReview(approvalId) {
561
+ return request('/api/v1/approvals/' + encodeURIComponent(approvalId), undefined, validateApprovalReview);
562
+ }
559
563
  // ── Approvals ─────────────────────────────────────────────────────────────────
560
564
  async function approveApproval(approvalId, note, options) {
561
565
  return request('/api/v1/approvals/' + encodeURIComponent(approvalId) + '/approve', {
562
566
  method: 'POST',
563
- body: JSON.stringify({ note, ...(options?.targetHost !== undefined ? { host: options.targetHost } : {}), ...(options?.intentReviewed !== undefined ? { intentReviewed: options.intentReviewed, reviewedArtifactId: options.reviewedArtifactId } : {}) }),
567
+ body: JSON.stringify({ note, ...(options?.expectedReview === undefined ? {} : { expectedReview: options.expectedReview }), ...(options?.targetHost !== undefined ? { host: options.targetHost } : {}), ...(options?.intentReviewed !== undefined ? { intentReviewed: options.intentReviewed, reviewedArtifactId: options.reviewedArtifactId } : {}) }),
564
568
  }, validateApprovalRecordDirect);
565
569
  }
566
- async function rejectApproval(approvalId, reason) {
570
+ async function rejectApproval(approvalId, reason, expectedReview) {
567
571
  return request('/api/v1/approvals/' + encodeURIComponent(approvalId) + '/reject', {
568
572
  method: 'POST',
569
- body: JSON.stringify({ reason }),
573
+ body: JSON.stringify({ reason, ...(expectedReview === undefined ? {} : { expectedReview }) }),
570
574
  }, validateApprovalRecordDirect);
571
575
  }
572
576
  async function editApproval(approvalId, newArtifactId, editReason) {
573
577
  return request('/api/v1/approvals/' + encodeURIComponent(approvalId) + '/edit', {
574
578
  method: 'POST',
575
- body: JSON.stringify({ newArtifactId, editReason }),
579
+ body: JSON.stringify({ newArtifactId, editReason: typeof editReason === 'string' ? editReason : editReason.reason, ...(typeof editReason === 'string' ? {} : { expectedReview: editReason.expectedReview }) }),
576
580
  }, validateApprovalRecordDirect);
577
581
  }
578
582
  // ── MVP seed feedback report drafts (PRI-285) ────────────────────────────────