@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.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.
@@ -372,12 +372,35 @@ export const createAndActivateBaseline = async ({
372
372
  }
373
373
  return manifest;
374
374
  } catch (error) {
375
+ let cleanupFailure;
375
376
  if (dataHandle) await dataHandle.close().catch(() => undefined);
376
- await chmod(buildDirectory, 0o700).catch(() => undefined);
377
- await rm(buildDirectory, { recursive: true, force: true });
377
+ await makeArtifactTreeWritable(buildDirectory).catch(() => undefined);
378
+ await rm(buildDirectory, { recursive: true, force: true }).catch(
379
+ (cleanupError) => {
380
+ cleanupFailure = cleanupError;
381
+ },
382
+ );
378
383
  if (finalCreated) {
379
- await chmod(finalDirectory, 0o700).catch(() => undefined);
380
- await rm(finalDirectory, { recursive: true, force: true });
384
+ await makeArtifactTreeWritable(finalDirectory).catch(() => undefined);
385
+ await rm(finalDirectory, { recursive: true, force: true }).catch(
386
+ (cleanupError) => {
387
+ cleanupFailure ??= cleanupError;
388
+ },
389
+ );
390
+ }
391
+ if (
392
+ cleanupFailure &&
393
+ error &&
394
+ (typeof error === "object" || typeof error === "function")
395
+ ) {
396
+ try {
397
+ Object.defineProperty(error, "cleanupError", {
398
+ value: cleanupFailure,
399
+ enumerable: false,
400
+ });
401
+ } catch {
402
+ // Preserve the preparation failure even when its Error is immutable.
403
+ }
381
404
  }
382
405
  throw error;
383
406
  }
@@ -22,6 +22,7 @@ const ACTIONS = new Set([
22
22
  const PSEUDONYM_FORMATS = new Set(["uuid", "email", "text", "integer"]);
23
23
  const DERIVATION_KINDS = new Set([
24
24
  "date-shift",
25
+ "path-map",
25
26
  "json-object",
26
27
  "json-array",
27
28
  "approved-owner",
@@ -29,6 +30,8 @@ const DERIVATION_KINDS = new Set([
29
30
  const KEY_BYTES = 32;
30
31
  const DEFAULT_JSON_MAXIMUM_BYTES = 65_536;
31
32
  const DEFAULT_JSON_MAXIMUM_DEPTH = 8;
33
+ const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
34
+ const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
32
35
  const MAXIMUM_POLICY_DEPTH = 16;
33
36
 
34
37
  const isObject = (value) =>
@@ -142,6 +145,8 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
142
145
  [
143
146
  "kind",
144
147
  "days",
148
+ "group",
149
+ "mapping",
145
150
  "fields",
146
151
  "items",
147
152
  "approved",
@@ -158,12 +163,32 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
158
163
  }
159
164
  if (recipe.kind === "date-shift") {
160
165
  const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
161
- if (Object.keys(recipe).some((key) => !["kind", "days"].includes(key))) {
166
+ if (
167
+ Object.keys(recipe).some(
168
+ (key) => !["kind", "days", "group"].includes(key),
169
+ )
170
+ ) {
162
171
  throw new Error(
163
172
  `${label}.recipe contains fields not used by date-shift.`,
164
173
  );
165
174
  }
166
- return Object.freeze({ kind: "date-shift", days });
175
+ if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
176
+ throw new Error(`${label}.recipe.group is invalid.`);
177
+ }
178
+ return Object.freeze({
179
+ kind: "date-shift",
180
+ days,
181
+ ...(recipe.group === undefined ? {} : { group: recipe.group }),
182
+ });
183
+ }
184
+ if (recipe.kind === "path-map") {
185
+ if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
186
+ throw new Error(`${label}.recipe contains fields not used by path-map.`);
187
+ }
188
+ if (!BINDING_NAME.test(recipe.mapping ?? "")) {
189
+ throw new Error(`${label}.recipe.mapping is invalid.`);
190
+ }
191
+ return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
167
192
  }
168
193
  if (recipe.kind === "approved-owner") {
169
194
  if (
@@ -280,7 +305,14 @@ export const validateExecutablePrivacyPolicy = (policy) => {
280
305
  if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
281
306
  knownKeys(
282
307
  policy,
283
- ["policyVersion", "draft", "migrationCutoff", "bindings", "tables"],
308
+ [
309
+ "policyVersion",
310
+ "draft",
311
+ "migrationCutoff",
312
+ "bindings",
313
+ "pathMappings",
314
+ "tables",
315
+ ],
284
316
  "policy",
285
317
  );
286
318
  if (policy.policyVersion !== 2) {
@@ -323,6 +355,60 @@ export const validateExecutablePrivacyPolicy = (policy) => {
323
355
  return [name, Object.freeze({ ...binding })];
324
356
  }),
325
357
  );
358
+ if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
359
+ throw new Error("policy.pathMappings must be an object.");
360
+ }
361
+ const pathMappings = Object.fromEntries(
362
+ Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
363
+ const label = `policy.pathMappings.${name}`;
364
+ if (!BINDING_NAME.test(name) || !isObject(mapping)) {
365
+ throw new Error("policy.pathMappings contains an invalid mapping.");
366
+ }
367
+ knownKeys(
368
+ mapping,
369
+ [
370
+ "binding",
371
+ "format",
372
+ "namespace",
373
+ "maxLength",
374
+ "maximumBytes",
375
+ "maximumSegments",
376
+ ],
377
+ label,
378
+ );
379
+ if (!Object.hasOwn(bindings, mapping.binding ?? "")) {
380
+ throw new Error(`${label}.binding references an unknown binding.`);
381
+ }
382
+ if (!["uuid", "text"].includes(mapping.format)) {
383
+ throw new Error(`${label}.format must be uuid or text.`);
384
+ }
385
+ const normalized = {
386
+ binding: mapping.binding,
387
+ format: mapping.format,
388
+ namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
389
+ maximumBytes: positiveInteger(
390
+ mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES,
391
+ `${label}.maximumBytes`,
392
+ 16_384,
393
+ ),
394
+ maximumSegments: positiveInteger(
395
+ mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS,
396
+ `${label}.maximumSegments`,
397
+ 256,
398
+ ),
399
+ };
400
+ if (mapping.format === "text") {
401
+ normalized.maxLength = positiveInteger(
402
+ mapping.maxLength,
403
+ `${label}.maxLength`,
404
+ 1_024,
405
+ );
406
+ } else if (mapping.maxLength !== undefined) {
407
+ throw new Error(`${label}.maxLength is supported only for text.`);
408
+ }
409
+ return [name, Object.freeze(normalized)];
410
+ }),
411
+ );
326
412
  const names = new Set();
327
413
  const tables = policy.tables.map((table, tableIndex) => {
328
414
  const label = `policy.tables[${tableIndex}]`;
@@ -423,12 +509,9 @@ export const validateExecutablePrivacyPolicy = (policy) => {
423
509
  throw new Error(`${label}.ownerBinding references an unknown column.`);
424
510
  }
425
511
  const ownerColumn = columns.find((entry) => entry.name === column);
426
- if (
427
- ownerColumn.action !== "PSEUDONYMIZE" ||
428
- ownerColumn.identity !== "YES"
429
- ) {
512
+ if (ownerColumn.action !== "PSEUDONYMIZE") {
430
513
  throw new Error(
431
- `${label}.ownerBinding column must be an explicitly pseudonymized identity.`,
514
+ `${label}.ownerBinding column must be explicitly pseudonymized.`,
432
515
  );
433
516
  }
434
517
  ownerBinding = Object.freeze({ binding, column });
@@ -442,6 +525,31 @@ export const validateExecutablePrivacyPolicy = (policy) => {
442
525
  if (columns.some(usesApprovedOwner) && !ownerBinding) {
443
526
  throw new Error(`${label} uses approved-owner without ownerBinding.`);
444
527
  }
528
+ const visitRecipes = (declaration, visit) => {
529
+ visit(declaration.recipe);
530
+ if (declaration.recipe?.kind === "approved-owner") {
531
+ visitRecipes(declaration.recipe.approved, visit);
532
+ visitRecipes(declaration.recipe.otherwise, visit);
533
+ } else if (declaration.recipe?.kind === "json-object") {
534
+ Object.values(declaration.recipe.fields).forEach((nested) =>
535
+ visitRecipes(nested, visit),
536
+ );
537
+ } else if (declaration.recipe?.kind === "json-array") {
538
+ visitRecipes(declaration.recipe.items, visit);
539
+ }
540
+ };
541
+ for (const column of columns) {
542
+ visitRecipes(column, (recipe) => {
543
+ if (
544
+ recipe?.kind === "path-map" &&
545
+ !Object.hasOwn(pathMappings, recipe.mapping)
546
+ ) {
547
+ throw new Error(
548
+ `${label} references unknown path mapping ${recipe.mapping}.`,
549
+ );
550
+ }
551
+ });
552
+ }
445
553
  return Object.freeze({
446
554
  schema,
447
555
  name,
@@ -450,10 +558,33 @@ export const validateExecutablePrivacyPolicy = (policy) => {
450
558
  columns: Object.freeze(columns),
451
559
  });
452
560
  });
561
+ const dateShiftGroups = new Map();
562
+ const visitDateShiftGroups = (declaration) => {
563
+ const recipe = declaration.recipe;
564
+ if (recipe?.kind === "date-shift" && recipe.group) {
565
+ const existing = dateShiftGroups.get(recipe.group);
566
+ if (existing !== undefined && existing !== recipe.days) {
567
+ throw new Error(
568
+ `Privacy date-shift group ${recipe.group} must use one days value.`,
569
+ );
570
+ }
571
+ dateShiftGroups.set(recipe.group, recipe.days);
572
+ }
573
+ if (recipe?.kind === "approved-owner") {
574
+ visitDateShiftGroups(recipe.approved);
575
+ visitDateShiftGroups(recipe.otherwise);
576
+ } else if (recipe?.kind === "json-object") {
577
+ Object.values(recipe.fields).forEach(visitDateShiftGroups);
578
+ } else if (recipe?.kind === "json-array") {
579
+ visitDateShiftGroups(recipe.items);
580
+ }
581
+ };
582
+ tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
453
583
  return Object.freeze({
454
584
  policyVersion: 2,
455
585
  migrationCutoff: policy.migrationCutoff,
456
586
  bindings: Object.freeze(bindings),
587
+ pathMappings: Object.freeze(pathMappings),
457
588
  tables: Object.freeze(tables),
458
589
  });
459
590
  };
@@ -485,6 +616,47 @@ const pseudonym = (key, recipe, value) => {
485
616
  return token.slice(0, recipe.maxLength);
486
617
  };
487
618
 
619
+ const remapPath = ({ key, mapping, bindingValue, value, label }) => {
620
+ if (typeof value !== "string") {
621
+ throw new Error(`${label} must be a Storage-style path string.`);
622
+ }
623
+ if (Buffer.byteLength(value) > mapping.maximumBytes) {
624
+ throw new Error(`${label} exceeds its reviewed byte limit.`);
625
+ }
626
+ const segments = value.split("/");
627
+ if (
628
+ !value ||
629
+ value.startsWith("/") ||
630
+ value.endsWith("/") ||
631
+ segments.length > mapping.maximumSegments ||
632
+ segments.some((segment) => !segment || segment === "." || segment === "..")
633
+ ) {
634
+ throw new Error(`${label} is not a safe bounded Storage path.`);
635
+ }
636
+ if (
637
+ typeof bindingValue !== "string" ||
638
+ !bindingValue ||
639
+ bindingValue.includes("/") ||
640
+ bindingValue === "." ||
641
+ bindingValue === ".."
642
+ ) {
643
+ throw new Error(`${label} has an invalid reviewed identity binding.`);
644
+ }
645
+ if (segments[0] !== bindingValue) {
646
+ throw new Error(
647
+ `${label} does not begin with its exact reviewed identity.`,
648
+ );
649
+ }
650
+ const destination = [
651
+ String(pseudonym(key, mapping, bindingValue)),
652
+ ...segments.slice(1),
653
+ ].join("/");
654
+ if (Buffer.byteLength(destination) > mapping.maximumBytes) {
655
+ throw new Error(`${label} destination exceeds its reviewed byte limit.`);
656
+ }
657
+ return destination;
658
+ };
659
+
488
660
  const assertJsonBounds = (value, recipe, path) => {
489
661
  let bytes = 0;
490
662
  const jsonStringBytes = (string) => {
@@ -545,6 +717,8 @@ const executeDeclaration = ({
545
717
  key,
546
718
  path,
547
719
  ownerApproved,
720
+ pathMappings,
721
+ bindingValues,
548
722
  }) => {
549
723
  if (declaration.action === "KEEP") return value;
550
724
  if (declaration.action === "EXCLUDE") return undefined;
@@ -558,10 +732,26 @@ const executeDeclaration = ({
558
732
  const date = new Date(value);
559
733
  if (!Number.isFinite(date.valueOf()))
560
734
  throw new Error(`${path} is not a valid date.`);
561
- const direction = digest(key, path, value)[0] % 2 === 0 ? -1 : 1;
735
+ const direction = declaration.recipe.group
736
+ ? digest(key, "date-shift-group", declaration.recipe.group)[0] % 2 === 0
737
+ ? -1
738
+ : 1
739
+ : digest(key, path, value)[0] % 2 === 0
740
+ ? -1
741
+ : 1;
562
742
  date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
563
743
  return date.toISOString();
564
744
  }
745
+ if (declaration.recipe.kind === "path-map") {
746
+ const mapping = pathMappings[declaration.recipe.mapping];
747
+ return remapPath({
748
+ key,
749
+ mapping,
750
+ bindingValue: bindingValues.get(mapping.binding),
751
+ value,
752
+ label: path,
753
+ });
754
+ }
565
755
  if (declaration.recipe.kind === "approved-owner") {
566
756
  if (ownerApproved === undefined) {
567
757
  throw new Error(`${path} has no reviewed owner condition.`);
@@ -574,6 +764,8 @@ const executeDeclaration = ({
574
764
  key,
575
765
  path,
576
766
  ownerApproved,
767
+ pathMappings,
768
+ bindingValues,
577
769
  });
578
770
  }
579
771
  if (value === null && declaration.recipe.allowNull) return null;
@@ -587,6 +779,8 @@ const executeDeclaration = ({
587
779
  key,
588
780
  path: `${path}[${index}]`,
589
781
  ownerApproved,
782
+ pathMappings,
783
+ bindingValues,
590
784
  });
591
785
  return transformed === undefined ? [] : [transformed];
592
786
  });
@@ -611,6 +805,8 @@ const executeDeclaration = ({
611
805
  key,
612
806
  path: `${path}.${field}`,
613
807
  ownerApproved,
808
+ pathMappings,
809
+ bindingValues,
614
810
  });
615
811
  return transformed === undefined ? [] : [[field, transformed]];
616
812
  }),
@@ -652,6 +848,19 @@ export const createPrivacyEngine = ({
652
848
  );
653
849
  return Object.freeze({
654
850
  keyFingerprint: createHash("sha256").update(secret).digest("hex"),
851
+ remapPath({ mapping: mappingName, value }) {
852
+ const mapping = normalized.pathMappings[mappingName];
853
+ if (!mapping) {
854
+ throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
855
+ }
856
+ return remapPath({
857
+ key: secret,
858
+ mapping,
859
+ bindingValue: bindingValues.get(mapping.binding),
860
+ value,
861
+ label: `Privacy path mapping ${mappingName}`,
862
+ });
863
+ },
655
864
  sanitize(record) {
656
865
  if (
657
866
  !isObject(record) ||
@@ -692,6 +901,8 @@ export const createPrivacyEngine = ({
692
901
  key: secret,
693
902
  path: `${relation}.${column.name}`,
694
903
  ownerApproved,
904
+ pathMappings: normalized.pathMappings,
905
+ bindingValues,
695
906
  });
696
907
  return transformed === undefined ? [] : [[column.name, transformed]];
697
908
  }),
@@ -4,6 +4,7 @@ export const parseArguments = (arguments_) => {
4
4
  const flags = {
5
5
  json: false,
6
6
  help: false,
7
+ version: false,
7
8
  verbosity: "normal",
8
9
  dryRun: false,
9
10
  write: false,
@@ -30,6 +31,8 @@ export const parseArguments = (arguments_) => {
30
31
  for (const argument of arguments_) {
31
32
  if (argument === "--json") flags.json = true;
32
33
  else if (argument === "--help" || argument === "-h") flags.help = true;
34
+ else if (argument === "--version" || argument === "-V")
35
+ flags.version = true;
33
36
  else if (argument === "--verbose") flags.verbosity = "verbose";
34
37
  else if (argument === "--debug") flags.verbosity = "debug";
35
38
  else if (argument === "--dry-run") flags.dryRun = true;
@@ -31,6 +31,7 @@ import {
31
31
  } from "../baseline/policy_review.mjs";
32
32
  import { formatCount } from "../shared/human_output.mjs";
33
33
  import { redactDiagnosticValue } from "../shared/diagnostics.mjs";
34
+ import { REHEARSAL_NODE_LABEL } from "../shared/node_runtime.mjs";
34
35
  import {
35
36
  renderBaselineInputInspection,
36
37
  renderBaselinePreparation,
@@ -336,7 +337,7 @@ const guidedOptions = (state, hasLastDetails) => {
336
337
  const options = [];
337
338
  if (!state.node.supported) {
338
339
  options.push({
339
- label: "Show Node.js 24 setup instructions",
340
+ label: "Show supported Node.js versions",
340
341
  hint: "Required before Rehearsal can change this project",
341
342
  command: "node-help",
342
343
  });
@@ -427,7 +428,7 @@ const showGuidedStatus = ({ state, options, flags }) => {
427
428
  const marker = (complete) =>
428
429
  complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
429
430
  const statusLines = [
430
- `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : " (Node.js 24 required)"}`,
431
+ `${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : ` (${REHEARSAL_NODE_LABEL} required)`}`,
431
432
  `${marker(state.target === "supabase" ? state.detected.hasSupabaseConfig : state.detected.hasPostgresqlMigrations)} ${state.target === "supabase" ? "Supabase" : "PostgreSQL"} project detected`,
432
433
  `${marker(state.target === "supabase" ? state.detected.hasMigrations : state.detected.hasPostgresqlMigrations)} Migration history detected`,
433
434
  `${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
@@ -685,12 +686,13 @@ export const createGuidedHome =
685
686
  console.log(
686
687
  [
687
688
  "",
688
- terminalStyle(flags, "1", "NODE.JS 24 REQUIRED"),
689
+ terminalStyle(flags, "1", "SUPPORTED NODE.JS REQUIRED"),
689
690
  `This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
691
+ `Supported releases: ${REHEARSAL_NODE_LABEL}.`,
690
692
  "",
691
- "With nvm:",
692
- " nvm install 24",
693
- " nvm use 24",
693
+ "Recommended with nvm:",
694
+ " nvm install --lts",
695
+ " nvm use --lts",
694
696
  "",
695
697
  "Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
696
698
  ].join("\n"),
@@ -15,6 +15,7 @@ import * as prompts from "@clack/prompts";
15
15
  import {
16
16
  createRehearsalResult,
17
17
  normalizeRehearsalError,
18
+ REHEARSAL_VERSION,
18
19
  renderHumanError,
19
20
  serializeRehearsalError,
20
21
  } from "../shared/diagnostics.mjs";
@@ -311,6 +312,7 @@ const loadIdentityClaimPlan = async ({ flags, planOptions }) => {
311
312
  const usage = () => `Usage: rehearsal <command> [options]
312
313
 
313
314
  Commands:
315
+ version Print the installed Rehearsal version
314
316
  guide Open the interactive, state-aware home screen
315
317
  setup [--target=] [--write] Preview or create safe first-run scaffolding
316
318
  init [--write] Preview or explicitly write safe starter config
@@ -343,7 +345,8 @@ Commands:
343
345
  Preview or apply conservative disk cleanup
344
346
  verify Verify the current local Rehearsal runtime
345
347
 
346
- Options: --json --verbose --debug --plain --config=<path> --target=supabase|postgresql
348
+ Options: --help, -h --version, -V --json --verbose --debug --plain
349
+ --config=<path> --target=supabase|postgresql
347
350
  --confirm-source-access=<digest> --confirm-source-retirement=<digest>
348
351
  --confirm-refresh=<digest>`;
349
352
 
@@ -362,6 +365,18 @@ const runGuidedHome = createGuidedHome({
362
365
  });
363
366
 
364
367
  const executeCommand = async ({ command, flags, planOptions, guided }) => {
368
+ if (command === "version" || flags.version) {
369
+ if (flags.json) {
370
+ console.log(
371
+ JSON.stringify(
372
+ { name: "@rehearsal-db/core", version: REHEARSAL_VERSION },
373
+ null,
374
+ 2,
375
+ ),
376
+ );
377
+ } else console.log(REHEARSAL_VERSION);
378
+ return;
379
+ }
365
380
  if (command === "help" || flags.help) {
366
381
  console.log(usage());
367
382
  return;
@@ -454,7 +469,10 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
454
469
  ? renderSourceAccessPlan(result.plan)
455
470
  : [
456
471
  "SOURCE ACCESS — READY",
457
- `Temporary reader expires: ${result.receipt.expiresAt}`,
472
+ `${result.receipt.accessMode === "external" ? "External" : "Temporary"} reader expires: ${result.receipt.expiresAt}`,
473
+ result.receipt.accessMode === "external"
474
+ ? "Rehearsal made no source-side changes."
475
+ : "Rehearsal created only the reviewed temporary source access.",
458
476
  `Local credential: ${result.credentialFile}`,
459
477
  "",
460
478
  "Next: run rehearsal baseline refresh, then retire source access.",
@@ -507,7 +507,10 @@ export const renderSourceAccessPlan = (plan) =>
507
507
  "SOURCE ACCESS — PREVIEW",
508
508
  "",
509
509
  `Target fingerprint: ${plan.review.targetFingerprint}`,
510
- `Temporary reader: ${plan.review.reader.role} (${plan.review.reader.validForMinutes} minutes)`,
510
+ plan.review.reader.mode === "managed"
511
+ ? `Managed temporary reader: ${plan.review.reader.role} (${plan.review.reader.validForMinutes} minutes)`
512
+ : `Externally managed reader: ${plan.review.reader.role} (maximum ${plan.review.reader.maximumValidForMinutes} minutes remaining)`,
513
+ `Source changes by Rehearsal: ${plan.review.reader.sourceChanges ? "export views and temporary roles" : "none"}`,
511
514
  `Export schema: ${plan.review.exportSchema}`,
512
515
  ...plan.review.relations.map(
513
516
  (relation) =>
@@ -516,7 +519,10 @@ export const renderSourceAccessPlan = (plan) =>
516
519
  ...(plan.review.assets.length
517
520
  ? plan.review.assets.map(
518
521
  (asset) =>
519
- ` → Storage ${asset.bucket}/${asset.prefix} (${asset.rights})`,
522
+ ` → Storage ${asset.bucket}/${
523
+ asset.prefix ??
524
+ `[private prefix from ${asset.prefixEnvironmentVariable}]`
525
+ } (${asset.rights})`,
520
526
  )
521
527
  : [" ○ No source assets declared"]),
522
528
  "",
@@ -574,7 +580,12 @@ export const renderSourceRetirement = ({ mode, plan }) =>
574
580
  `Target fingerprint: ${plan.review.targetFingerprint}`,
575
581
  `Reader: ${plan.review.readerRole}`,
576
582
  `Export schema: ${plan.review.exportSchema}`,
577
- ...plan.review.views.map((view) => ` → remove view ${view}`),
583
+ ...(plan.review.accessMode === "managed"
584
+ ? plan.review.views.map((view) => ` → remove view ${view}`)
585
+ : [
586
+ " → remove only Rehearsal's local credential and receipt",
587
+ " ✓ preserve the provider-owned reader and export views",
588
+ ]),
578
589
  "",
579
590
  ...(mode === "preview"
580
591
  ? [
@@ -582,7 +593,9 @@ export const renderSourceRetirement = ({ mode, plan }) =>
582
593
  `Exact confirmation: --confirm-source-retirement=${plan.digest}`,
583
594
  ]
584
595
  : [
585
- "The exact reader, export views, and local credential files were retired.",
596
+ plan.review.accessMode === "managed"
597
+ ? "The exact reader, export views, and local credential files were retired."
598
+ : "Rehearsal's local credential and receipt were removed. Provider-owned database resources were preserved.",
586
599
  ]),
587
600
  ].join("\n");
588
601
 
@@ -211,6 +211,82 @@ const projectCommandEnvironment = (additional = {}) => ({
211
211
  ...additional,
212
212
  });
213
213
 
214
+ const RUNTIME_STARTUP_FAILURE_PATTERN =
215
+ /container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu;
216
+
217
+ export const classifyManagerFailure = ({
218
+ action,
219
+ output,
220
+ inferredCategory,
221
+ }) => {
222
+ if (RUNTIME_STARTUP_FAILURE_PATTERN.test(output)) {
223
+ return "runtime_dependency_failure";
224
+ }
225
+ if (
226
+ action === "migrate" ||
227
+ (action === "run" &&
228
+ /migrateRuntime|candidate migration|migration up/iu.test(output))
229
+ ) {
230
+ return "migration_candidate_failure";
231
+ }
232
+ return inferredCategory;
233
+ };
234
+
235
+ export const parseDockerCapacity = (output) => {
236
+ const [cpuText, memoryText, runningText, totalText] = output
237
+ .trim()
238
+ .split(/\s+/u);
239
+ const values = [cpuText, memoryText, runningText, totalText].map(Number);
240
+ if (!values.every(Number.isFinite)) return null;
241
+ const [cpus, memoryBytes, runningContainers, containers] = values;
242
+ return { cpus, memoryBytes, runningContainers, containers };
243
+ };
244
+
245
+ export const parseColimaMemory = (output) => {
246
+ const line = output
247
+ .split("\n")
248
+ .map((entry) => entry.trim())
249
+ .find((entry) => entry.startsWith("Mem:"));
250
+ if (!line) return null;
251
+ const columns = line.split(/\s+/u);
252
+ const totalBytes = Number(columns[1]);
253
+ const availableBytes = Number(columns[6]);
254
+ if (![totalBytes, availableBytes].every(Number.isFinite)) return null;
255
+ return { totalBytes, availableBytes };
256
+ };
257
+
258
+ const diagnosticCommand = (command, args) =>
259
+ spawnSync(command, args, {
260
+ encoding: "utf8",
261
+ env: projectCommandEnvironment(),
262
+ stdio: ["ignore", "pipe", "ignore"],
263
+ timeout: 5_000,
264
+ });
265
+
266
+ const inspectContainerCapacity = () => {
267
+ const docker = diagnosticCommand("docker", [
268
+ "info",
269
+ "--format",
270
+ "{{.NCPU}} {{.MemTotal}} {{.ContainersRunning}} {{.Containers}}",
271
+ ]);
272
+ const capacity =
273
+ docker.status === 0 ? parseDockerCapacity(docker.stdout) : null;
274
+ if (!capacity) return undefined;
275
+
276
+ const context = diagnosticCommand("docker", ["context", "show"]);
277
+ if (context.status !== 0 || context.stdout.trim() !== "colima") {
278
+ return { engine: "docker-compatible", ...capacity };
279
+ }
280
+ const memory = diagnosticCommand("colima", ["ssh", "--", "free", "-b"]);
281
+ return {
282
+ engine: "colima",
283
+ ...capacity,
284
+ ...(memory.status === 0
285
+ ? { runtimeMemory: parseColimaMemory(memory.stdout) ?? undefined }
286
+ : {}),
287
+ };
288
+ };
289
+
214
290
  export const topologyCommandEnvironment = (topology) =>
215
291
  Object.fromEntries(
216
292
  topology.targets.map((target) => [
@@ -344,14 +420,15 @@ export const createRuntimeCommands = ({
344
420
  const inferred = normalizeRehearsalError(
345
421
  new Error(completeOutput || `Rehearsal ${action} failed.`),
346
422
  );
347
- const category =
348
- action === "migrate" ||
349
- (action === "run" &&
350
- /migrateRuntime|candidate migration|migration up/iu.test(
351
- completeOutput,
352
- ))
353
- ? "migration_candidate_failure"
354
- : inferred.category;
423
+ const category = classifyManagerFailure({
424
+ action,
425
+ output: completeOutput,
426
+ inferredCategory: inferred.category,
427
+ });
428
+ const startupFailure = category === "runtime_dependency_failure";
429
+ const engineCapacity = startupFailure
430
+ ? inspectContainerCapacity()
431
+ : undefined;
355
432
  const conciseOutput = completeOutput
356
433
  .replaceAll(String.fromCodePoint(27), "")
357
434
  .split("\n")
@@ -366,10 +443,16 @@ export const createRuntimeCommands = ({
366
443
  message: `The local Rehearsal ${action} operation did not complete.`,
367
444
  expected: "the isolated runtime operation to finish and verify",
368
445
  actual: conciseOutput || `exit ${result.status}`,
369
- context: { action },
446
+ context: { action, ...(engineCapacity ? { engineCapacity } : {}) },
370
447
  refused: "The runtime was not reported as trusted.",
371
448
  suggestions: [
372
449
  "Review the concise failure above, then rerun rehearsal doctor before retrying.",
450
+ ...(startupFailure
451
+ ? [
452
+ "Stop unrelated local stacks or increase Docker/Colima capacity, then retry. Rehearsal will not change engine settings or bypass service health checks.",
453
+ "Run docker stats --no-stream to identify active containers using the shared engine.",
454
+ ]
455
+ : []),
373
456
  "Use --debug only when the safe diagnostic detail is needed.",
374
457
  ],
375
458
  cause: new Error(completeOutput || `exit ${result.status}`),