create-principles-disciple 1.133.13 → 1.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/console/dist/server/routes/update.js +38 -7
  2. package/console/dist/server/update/legacy-mutation-journal.d.ts +118 -0
  3. package/console/dist/server/update/legacy-mutation-journal.js +220 -0
  4. package/console/dist/web/assets/app.js +81 -11
  5. package/console/package.json +2 -2
  6. package/core/dist/runtime-v2/__tests__/recovery-sweep-service.test.js +130 -0
  7. package/core/dist/runtime-v2/__tests__/recovery-sweep-service.test.js.map +1 -1
  8. package/core/dist/runtime-v2/recovery-sweep-service.d.ts +9 -0
  9. package/core/dist/runtime-v2/recovery-sweep-service.d.ts.map +1 -1
  10. package/core/dist/runtime-v2/recovery-sweep-service.js +7 -2
  11. package/core/dist/runtime-v2/recovery-sweep-service.js.map +1 -1
  12. package/core/package.json +1 -1
  13. package/dist/installer.d.ts +48 -0
  14. package/dist/installer.d.ts.map +1 -1
  15. package/dist/installer.js +115 -13
  16. package/dist/installer.js.map +1 -1
  17. package/dist/uninstaller.d.ts.map +1 -1
  18. package/dist/uninstaller.js +7 -3
  19. package/dist/uninstaller.js.map +1 -1
  20. package/dist/update/install-layout.d.ts +39 -2
  21. package/dist/update/install-layout.d.ts.map +1 -1
  22. package/dist/update/install-layout.js +69 -3
  23. package/dist/update/install-layout.js.map +1 -1
  24. package/dist/update/release-manager-authority.d.ts +8 -0
  25. package/dist/update/release-manager-authority.d.ts.map +1 -1
  26. package/dist/update/release-manager-authority.js +26 -4
  27. package/dist/update/release-manager-authority.js.map +1 -1
  28. package/dist/update/release-metadata-source.d.ts +85 -0
  29. package/dist/update/release-metadata-source.d.ts.map +1 -0
  30. package/dist/update/release-metadata-source.js +102 -0
  31. package/dist/update/release-metadata-source.js.map +1 -0
  32. package/package.json +2 -2
  33. package/pd-cli/dist/commands/runtime-recovery-failed-tasks.d.ts.map +1 -1
  34. package/pd-cli/dist/commands/runtime-recovery-failed-tasks.js +82 -6
  35. package/pd-cli/dist/commands/runtime-recovery-failed-tasks.js.map +1 -1
  36. package/pd-cli/dist/index.js +6 -3
  37. package/pd-cli/dist/index.js.map +1 -1
  38. package/pd-cli/package.json +1 -1
  39. package/plugin/dist/bundle.js +3 -3
  40. package/plugin/dist/governance-audit.js +139 -139
  41. package/plugin/dist/rulehost-evidence.js +144 -144
  42. package/release-manager/dist/installer.d.ts +48 -0
  43. package/release-manager/dist/installer.d.ts.map +1 -1
  44. package/release-manager/dist/installer.js +115 -13
  45. package/release-manager/dist/installer.js.map +1 -1
  46. package/release-manager/dist/uninstaller.d.ts.map +1 -1
  47. package/release-manager/dist/uninstaller.js +7 -3
  48. package/release-manager/dist/uninstaller.js.map +1 -1
  49. package/release-manager/dist/update/install-layout.d.ts +39 -2
  50. package/release-manager/dist/update/install-layout.d.ts.map +1 -1
  51. package/release-manager/dist/update/install-layout.js +69 -3
  52. package/release-manager/dist/update/install-layout.js.map +1 -1
  53. package/release-manager/dist/update/release-manager-authority.d.ts +8 -0
  54. package/release-manager/dist/update/release-manager-authority.d.ts.map +1 -1
  55. package/release-manager/dist/update/release-manager-authority.js +26 -4
  56. package/release-manager/dist/update/release-manager-authority.js.map +1 -1
  57. package/release-manager/dist/update/release-metadata-source.d.ts +85 -0
  58. package/release-manager/dist/update/release-metadata-source.d.ts.map +1 -0
  59. package/release-manager/dist/update/release-metadata-source.js +102 -0
  60. package/release-manager/dist/update/release-metadata-source.js.map +1 -0
  61. package/release-manager/package.json +2 -2
@@ -11,6 +11,7 @@ import { resolveExtensionsDir, resolveUpdateLayout, resolvePluginDir, resolveCan
11
11
  import { ActivationCompatibilityReadModel, isFeatureEnabled } from '@principles/core/runtime-v2';
12
12
  import { collectFileDepLinkSpecs } from '../utils/update-links.js';
13
13
  import { updateMutationController, LEGACY_MUTATION_AUTHORITY, RELEASE_MANAGER_AUTHORITY, MUTATION_KINDS, } from '../update/mutation-controller.js';
14
+ import { runLegacyJournaledMutation } from '../update/legacy-mutation-journal.js';
14
15
  import { loadPdConfig, computeFlagsFromLoadResult } from '../config/pd-config-store.js';
15
16
  /**
16
17
  * Legacy rule contract preflight (2026-08-19): refuse to swap the runtime
@@ -1540,7 +1541,26 @@ function legacyCheckMutation(req, res, ctx) {
1540
1541
  }
1541
1542
  })();
1542
1543
  }
1544
+ /**
1545
+ * PRI-709 P0-3 (ADR-0024 D-2): the shared installation root that owns the
1546
+ * transaction journal. Legacy console mutations journal to the SAME place as
1547
+ * the installer and the ReleaseManager — never a console-private ledger.
1548
+ */
1549
+ function resolvePdHome() {
1550
+ return path.join(os.homedir(), '.pd');
1551
+ }
1552
+ /**
1553
+ * A runtime mutation that ran without journal evidence is a governance gap,
1554
+ * not a mutation failure: it is reported loud (rc-9) and the mutation result
1555
+ * is still served. Blocking the Owner's update here would be worse than an
1556
+ * unaudited one — but the gap must never be silent.
1557
+ */
1558
+ function logLegacyJournalGap(kind, reason) {
1559
+ console.warn(`[update] Runtime mutation "${kind}" completed WITHOUT journal evidence (${reason}). `
1560
+ + 'ADR-0024 D-2 requires every runtime mutation to be auditable; this is usually a missing create-principles-disciple install surface.');
1561
+ }
1543
1562
  function legacyApplyMutation(req, res, ctx) {
1563
+ const pdHome = resolvePdHome();
1544
1564
  return (async () => {
1545
1565
  const pluginDir = resolvePluginDir(ctx.workspaceDir);
1546
1566
  if (req.method !== 'POST') {
@@ -1578,11 +1598,12 @@ function legacyApplyMutation(req, res, ctx) {
1578
1598
  sendBadRequest(res, 'targetDir must be within workspace or extensions directory');
1579
1599
  return;
1580
1600
  }
1581
- const result = await doApplyUpdate({
1582
- targetDir,
1583
- mergeStrategy,
1584
- createBackup,
1585
- }, ctx.workspaceDir);
1601
+ // ADR-0024 D-2 (PRI-709 P0-3): every runtime mutation is journaled.
1602
+ // `planned` lands after all request validation, so a rejected request
1603
+ // leaves no transaction behind.
1604
+ const { result, journal } = await runLegacyJournaledMutation({ kind: 'apply', pdHome, pluginDir }, () => doApplyUpdate({ targetDir, mergeStrategy, createBackup }, ctx.workspaceDir));
1605
+ if (!journal.journaled)
1606
+ logLegacyJournalGap('apply', journal.reason);
1586
1607
  sendSuccess(res, result);
1587
1608
  }
1588
1609
  catch (err) {
@@ -1595,6 +1616,7 @@ function legacyApplyMutation(req, res, ctx) {
1595
1616
  })();
1596
1617
  }
1597
1618
  function legacyRollbackMutation(req, res, ctx) {
1619
+ const pdHome = resolvePdHome();
1598
1620
  return (async () => {
1599
1621
  const pluginDir = resolvePluginDir(ctx.workspaceDir);
1600
1622
  if (req.method !== 'POST') {
@@ -1626,7 +1648,12 @@ function legacyRollbackMutation(req, res, ctx) {
1626
1648
  sendBadRequest(res, 'backupDir must be within the workspace, extensions directory, or PD backups directory');
1627
1649
  return;
1628
1650
  }
1629
- const result = await doRollbackUpdate({ targetDir, backupDir }, ctx.workspaceDir);
1651
+ // ADR-0024 D-2 (PRI-709 P0-3): a rollback is a forward transition to a
1652
+ // known-good deployment, not an undo of this transaction — that is why
1653
+ // the terminal state is `confirmed`, not `rolled_back`.
1654
+ const { result, journal } = await runLegacyJournaledMutation({ kind: 'rollback', pdHome, pluginDir }, () => doRollbackUpdate({ targetDir, backupDir }, ctx.workspaceDir));
1655
+ if (!journal.journaled)
1656
+ logLegacyJournalGap('rollback', journal.reason);
1630
1657
  sendSuccess(res, result);
1631
1658
  }
1632
1659
  catch (err) {
@@ -1639,13 +1666,17 @@ function legacyRollbackMutation(req, res, ctx) {
1639
1666
  })();
1640
1667
  }
1641
1668
  function legacyApplyFullMutation(req, res, ctx) {
1669
+ const pdHome = resolvePdHome();
1642
1670
  return (async () => {
1643
1671
  if (req.method !== 'POST') {
1644
1672
  sendMethodNotAllowed(res);
1645
1673
  return;
1646
1674
  }
1675
+ const pluginDir = resolvePluginDir(ctx.workspaceDir);
1647
1676
  try {
1648
- const result = await doInlineFullUpdate(ctx.workspaceDir);
1677
+ const { result, journal } = await runLegacyJournaledMutation({ kind: 'apply-full', pdHome, pluginDir }, () => doInlineFullUpdate(ctx.workspaceDir));
1678
+ if (!journal.journaled)
1679
+ logLegacyJournalGap('apply-full', journal.reason);
1649
1680
  sendSuccess(res, result);
1650
1681
  }
1651
1682
  catch (err) {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * PRI-709 P0-3 — journal coverage for the legacy console updater (ADR-0024 D-2).
3
+ *
4
+ * PRI-698 Phase 0 Audit finding F-3: the console updater performed runtime
5
+ * mutations with **zero** journal writes. Under ADR-0024 D-2 an unjournaled
6
+ * runtime mutation is the one thing that must never happen, so the legacy
7
+ * updater had to be brought under the SAME journal as the installer and the
8
+ * ReleaseManager before the legacy path can be retired.
9
+ *
10
+ * Design rules (all load-bearing):
11
+ *
12
+ * - **No new journal implementation.** This module reuses
13
+ * `create-principles-disciple`'s `transaction-journal` — one JSONL file per
14
+ * transaction under `~/.pd/transactions/`, append + fsync, strict reader.
15
+ * Nothing here parses, formats or rotates journals.
16
+ * - **The ReleaseManager path is never double-journaled.** ReleaseManager
17
+ * `apply-full` orchestrates the installer, which journals the whole
18
+ * lifecycle; the console dispatch for that path never reaches the legacy
19
+ * handlers this module wraps. When the ReleaseManager explicitly falls back
20
+ * to legacy, the legacy handler is the ONLY writer — one transaction per
21
+ * mutation, whichever authority served it.
22
+ * - **Explicit degradation, never a blocked mutation.** The journal module is
23
+ * loaded dynamically: the console runs in installations where the
24
+ * create-principles-disciple dist may be absent (the same delivery-surface
25
+ * gap the ReleaseManager authority loader already handles). A missing module
26
+ * is reported and the mutation proceeds unjournaled — refusing the Owner's
27
+ * update would be worse than an unaudited one, and the gap is observable.
28
+ * - **No fabricated digests.** The legacy updater does not verify signed
29
+ * release metadata, so its digest provenance is `fallback`: a synthetic
30
+ * sha256 over a literal marker, readable but explicitly NOT verifiable. It
31
+ * never claims `manifest` / `signed_channel` it cannot back.
32
+ *
33
+ * Transition strategy for legacy kinds:
34
+ *
35
+ * apply / apply-full / rollback: `planned` → `confirmed` | `failed`
36
+ *
37
+ * `planned` is appended immediately before the mutation (after all request
38
+ * validation, so rejected requests leave no transaction); `confirmed` or
39
+ * `failed` after it. `rolled_back` is NOT used for the rollback kind — a
40
+ * rollback restores a previous deployment and is itself a forward transition
41
+ * to a known-good state, whereas `rolled_back` means "this transaction was
42
+ * undone".
43
+ *
44
+ * Two documented trade-offs (PRI-709 review, deliberate):
45
+ *
46
+ * - **`generation` stays at the standalone default `1`.** Legacy console
47
+ * transactions do not participate in the dual-slot generation lineage — they
48
+ * never move the active record — so recording a real `active.json.generation
49
+ * + 1` would CLAIM a lineage the console does not actually advance. Recovery
50
+ * is unaffected either way: `recoverUnfinishedTransaction` keys on
51
+ * `activeRecord.transactionId`, which a console transaction never matches, so
52
+ * it resolves to "the previously confirmed release stands".
53
+ * - **The legacy path leaves `active.json` untouched.** The legacy updater
54
+ * replaces the runtime without the installer's dual-slot swap, so after a
55
+ * legacy `apply-full` the active record describes the PREVIOUS deployment.
56
+ * Making the console a second writer of the deployment identity would be a
57
+ * worse violation (one source of truth); the honest fix is retiring the
58
+ * legacy path (ADR-0024 D-1), which is exactly what this journal coverage
59
+ * unblocks.
60
+ */
61
+ import type { ReleaseMetadataDigestSource, TransactionState } from 'create-principles-disciple/dist/update/transaction-journal.js';
62
+ /** Journal kinds the legacy console updater performs. */
63
+ export type LegacyMutationKind = 'apply' | 'apply-full' | 'rollback';
64
+ export interface LegacyMutationJournal {
65
+ readonly transactionId: string;
66
+ readonly journalPath: string;
67
+ readonly releaseId: string;
68
+ readonly productVersion: string;
69
+ readonly releaseMetadataDigest: string;
70
+ }
71
+ export type LegacyJournalStatus = {
72
+ readonly journaled: true;
73
+ readonly journal: LegacyMutationJournal;
74
+ } | {
75
+ readonly journaled: false;
76
+ readonly reason: string;
77
+ };
78
+ /** Journal port — the real implementation is the shared transaction journal. */
79
+ export interface LegacyJournalPort {
80
+ appendJournalTransition(journalPath: string, transition: {
81
+ readonly at: string;
82
+ readonly from: TransactionState | null;
83
+ readonly to: TransactionState;
84
+ readonly transactionId: string;
85
+ readonly releaseId: string;
86
+ readonly productVersion: string;
87
+ readonly releaseMetadataDigest: string;
88
+ readonly releaseMetadataDigestSource: ReleaseMetadataDigestSource;
89
+ readonly generation: number;
90
+ readonly detail?: string;
91
+ }): void;
92
+ }
93
+ export interface LegacyJournalOptions {
94
+ /** `~/.pd` — the shared installation root (ADR-0023). */
95
+ readonly pdHome: string;
96
+ /** Installed runtime plugin dir; identity source of what is deployed. */
97
+ readonly pluginDir: string;
98
+ readonly kind: LegacyMutationKind;
99
+ readonly now?: () => Date;
100
+ /** Test seam; defaults to the dynamically imported shared journal. */
101
+ readonly journal?: LegacyJournalPort;
102
+ }
103
+ /**
104
+ * Open one legacy transaction and append `planned` before the mutation runs.
105
+ */
106
+ export declare function openLegacyMutationJournal(options: LegacyJournalOptions): Promise<LegacyJournalStatus>;
107
+ /**
108
+ * Runs a legacy mutation under one journal transaction.
109
+ *
110
+ * `planned` is written before `run()`; `confirmed` / `failed` after — decided
111
+ * by BOTH the thrown/not-thrown boundary and the updater's `success` result
112
+ * shape (see `toLegacyMutationOutcome`). A journal failure never changes the
113
+ * mutation's outcome — it is reported alongside it.
114
+ */
115
+ export declare function runLegacyJournaledMutation<T>(options: LegacyJournalOptions, run: () => Promise<T>): Promise<{
116
+ readonly result: T;
117
+ readonly journal: LegacyJournalStatus;
118
+ }>;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * PRI-709 P0-3 — journal coverage for the legacy console updater (ADR-0024 D-2).
3
+ *
4
+ * PRI-698 Phase 0 Audit finding F-3: the console updater performed runtime
5
+ * mutations with **zero** journal writes. Under ADR-0024 D-2 an unjournaled
6
+ * runtime mutation is the one thing that must never happen, so the legacy
7
+ * updater had to be brought under the SAME journal as the installer and the
8
+ * ReleaseManager before the legacy path can be retired.
9
+ *
10
+ * Design rules (all load-bearing):
11
+ *
12
+ * - **No new journal implementation.** This module reuses
13
+ * `create-principles-disciple`'s `transaction-journal` — one JSONL file per
14
+ * transaction under `~/.pd/transactions/`, append + fsync, strict reader.
15
+ * Nothing here parses, formats or rotates journals.
16
+ * - **The ReleaseManager path is never double-journaled.** ReleaseManager
17
+ * `apply-full` orchestrates the installer, which journals the whole
18
+ * lifecycle; the console dispatch for that path never reaches the legacy
19
+ * handlers this module wraps. When the ReleaseManager explicitly falls back
20
+ * to legacy, the legacy handler is the ONLY writer — one transaction per
21
+ * mutation, whichever authority served it.
22
+ * - **Explicit degradation, never a blocked mutation.** The journal module is
23
+ * loaded dynamically: the console runs in installations where the
24
+ * create-principles-disciple dist may be absent (the same delivery-surface
25
+ * gap the ReleaseManager authority loader already handles). A missing module
26
+ * is reported and the mutation proceeds unjournaled — refusing the Owner's
27
+ * update would be worse than an unaudited one, and the gap is observable.
28
+ * - **No fabricated digests.** The legacy updater does not verify signed
29
+ * release metadata, so its digest provenance is `fallback`: a synthetic
30
+ * sha256 over a literal marker, readable but explicitly NOT verifiable. It
31
+ * never claims `manifest` / `signed_channel` it cannot back.
32
+ *
33
+ * Transition strategy for legacy kinds:
34
+ *
35
+ * apply / apply-full / rollback: `planned` → `confirmed` | `failed`
36
+ *
37
+ * `planned` is appended immediately before the mutation (after all request
38
+ * validation, so rejected requests leave no transaction); `confirmed` or
39
+ * `failed` after it. `rolled_back` is NOT used for the rollback kind — a
40
+ * rollback restores a previous deployment and is itself a forward transition
41
+ * to a known-good state, whereas `rolled_back` means "this transaction was
42
+ * undone".
43
+ *
44
+ * Two documented trade-offs (PRI-709 review, deliberate):
45
+ *
46
+ * - **`generation` stays at the standalone default `1`.** Legacy console
47
+ * transactions do not participate in the dual-slot generation lineage — they
48
+ * never move the active record — so recording a real `active.json.generation
49
+ * + 1` would CLAIM a lineage the console does not actually advance. Recovery
50
+ * is unaffected either way: `recoverUnfinishedTransaction` keys on
51
+ * `activeRecord.transactionId`, which a console transaction never matches, so
52
+ * it resolves to "the previously confirmed release stands".
53
+ * - **The legacy path leaves `active.json` untouched.** The legacy updater
54
+ * replaces the runtime without the installer's dual-slot swap, so after a
55
+ * legacy `apply-full` the active record describes the PREVIOUS deployment.
56
+ * Making the console a second writer of the deployment identity would be a
57
+ * worse violation (one source of truth); the honest fix is retiring the
58
+ * legacy path (ADR-0024 D-1), which is exactly what this journal coverage
59
+ * unblocks.
60
+ */
61
+ import { createHash, randomUUID } from 'node:crypto';
62
+ import * as fs from 'node:fs';
63
+ import * as path from 'node:path';
64
+ /** Load the shared journal; `null` when the delivery surface lacks the module. */
65
+ async function loadJournalPort() {
66
+ try {
67
+ const module = await import('create-principles-disciple/dist/update/transaction-journal.js');
68
+ return { appendJournalTransition: module.appendJournalTransition };
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /**
75
+ * Product version of the currently installed runtime.
76
+ *
77
+ * Same rule as the installer (PRI-709 P0-2): the PRODUCT manifest is
78
+ * `pluginDir/package.json`; `pd-cli` versions independently and is only a
79
+ * fallback.
80
+ */
81
+ function resolveInstalledProductVersion(pluginDir) {
82
+ for (const candidate of [path.join(pluginDir, 'package.json'), path.join(pluginDir, 'pd-cli', 'package.json')]) {
83
+ try {
84
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
85
+ if (typeof parsed.version === 'string' && parsed.version.length > 0)
86
+ return parsed.version;
87
+ }
88
+ catch {
89
+ // Identity falls back; an unjournaled-able version must not block a mutation.
90
+ }
91
+ }
92
+ return 'unknown';
93
+ }
94
+ /**
95
+ * Open one legacy transaction and append `planned` before the mutation runs.
96
+ */
97
+ export async function openLegacyMutationJournal(options) {
98
+ const port = options.journal ?? await loadJournalPort();
99
+ if (port === null) {
100
+ return { journaled: false, reason: 'journal_module_unavailable' };
101
+ }
102
+ const productVersion = resolveInstalledProductVersion(options.pluginDir);
103
+ // Unverifiable by construction: the legacy updater never sees signed release
104
+ // metadata, so the digest is a marker hash labelled `fallback`.
105
+ const releaseMetadataDigest = createHash('sha256')
106
+ .update(`legacy-console-updater-unverified:${options.kind}`)
107
+ .digest('hex');
108
+ const transactionId = `console-${options.kind}-${Date.now()}-${randomUUID().slice(0, 8)}`;
109
+ const releaseId = `console-${options.kind}-${productVersion}-${releaseMetadataDigest.slice(0, 12)}`;
110
+ const journal = {
111
+ transactionId,
112
+ journalPath: path.join(options.pdHome, 'transactions', `${transactionId}.jsonl`),
113
+ releaseId,
114
+ productVersion,
115
+ releaseMetadataDigest,
116
+ };
117
+ try {
118
+ port.appendJournalTransition(journal.journalPath, {
119
+ at: (options.now ?? (() => new Date))().toISOString(),
120
+ from: null,
121
+ to: 'planned',
122
+ transactionId,
123
+ releaseId,
124
+ productVersion,
125
+ releaseMetadataDigest,
126
+ releaseMetadataDigestSource: 'fallback',
127
+ generation: 1,
128
+ detail: `actor=console-updater kind=${options.kind}`,
129
+ });
130
+ }
131
+ catch (error) {
132
+ return {
133
+ journaled: false,
134
+ reason: `journal_write_failed: ${error instanceof Error ? error.message : String(error)}`,
135
+ };
136
+ }
137
+ return { journaled: true, journal };
138
+ }
139
+ /**
140
+ * Maps a mutation's RESULT to its journal terminal state.
141
+ *
142
+ * PRI-709 review: the legacy updater reports failure as a VALUE —
143
+ * `doApplyUpdate` / `doRollbackUpdate` / `doInlineFullUpdate` return
144
+ * `{ success: false, message }` for ~30 distinct failure modes and only throw
145
+ * for infrastructure errors. Terminal state decided on "did it throw" alone
146
+ * would journal confirmed for most real failures. So: `success === true` is
147
+ * `confirmed`, `success === false` is `failed` (with the reported message as
148
+ * detail), and a non-object result (no contract) is treated as completed.
149
+ * The HTTP response contract is untouched either way — this only decides what
150
+ * the journal records.
151
+ */
152
+ function toLegacyMutationOutcome(result) {
153
+ if (typeof result !== 'object' || result === null) {
154
+ return { to: 'confirmed', detail: 'completed' };
155
+ }
156
+ const record = result;
157
+ if (!Object.hasOwn(record, 'success')) {
158
+ return { to: 'confirmed', detail: 'completed' };
159
+ }
160
+ const detailParts = [record.message, record.reason]
161
+ .filter((part) => typeof part === 'string' && part.length > 0);
162
+ const detail = detailParts.length > 0 ? detailParts.join('; ') : 'no detail reported';
163
+ return { to: record.success === true ? 'confirmed' : 'failed', detail };
164
+ }
165
+ /**
166
+ * Writes the terminal transition for an opened legacy transaction and reports
167
+ * loud when it cannot land: the journal would then hold an unfinished
168
+ * transaction, which is a governance gap (rc-9) even though the mutation's
169
+ * real outcome must not be masked.
170
+ */
171
+ async function appendLegacyOutcome(options, status, outcome) {
172
+ if (!status.journaled)
173
+ return;
174
+ const port = options.journal ?? await loadJournalPort();
175
+ if (port === null)
176
+ return;
177
+ try {
178
+ port.appendJournalTransition(status.journal.journalPath, {
179
+ at: (options.now ?? (() => new Date))().toISOString(),
180
+ from: 'planned',
181
+ to: outcome.to,
182
+ transactionId: status.journal.transactionId,
183
+ releaseId: status.journal.releaseId,
184
+ productVersion: status.journal.productVersion,
185
+ releaseMetadataDigest: status.journal.releaseMetadataDigest,
186
+ releaseMetadataDigestSource: 'fallback',
187
+ generation: 1,
188
+ detail: `actor=console-updater kind=${options.kind} ${outcome.detail}`,
189
+ });
190
+ }
191
+ catch (error) {
192
+ console.warn(`[update] Legacy mutation "${options.kind}" finished as "${outcome.to}" but its terminal journal transition `
193
+ + `could not be written (${error instanceof Error ? error.message : String(error)}). `
194
+ + `Transaction ${status.journal.transactionId} stays unfinished and must be reconciled.`);
195
+ }
196
+ }
197
+ /**
198
+ * Runs a legacy mutation under one journal transaction.
199
+ *
200
+ * `planned` is written before `run()`; `confirmed` / `failed` after — decided
201
+ * by BOTH the thrown/not-thrown boundary and the updater's `success` result
202
+ * shape (see `toLegacyMutationOutcome`). A journal failure never changes the
203
+ * mutation's outcome — it is reported alongside it.
204
+ */
205
+ export async function runLegacyJournaledMutation(options, run) {
206
+ const opened = await openLegacyMutationJournal(options);
207
+ try {
208
+ const result = await run();
209
+ const outcome = toLegacyMutationOutcome(result);
210
+ await appendLegacyOutcome(options, opened, { to: outcome.to, detail: `${options.kind}: ${outcome.detail}` });
211
+ return { result, journal: opened };
212
+ }
213
+ catch (error) {
214
+ await appendLegacyOutcome(options, opened, {
215
+ to: 'failed',
216
+ detail: `${options.kind}: ${error instanceof Error ? error.message : String(error)}`,
217
+ });
218
+ throw error;
219
+ }
220
+ }