@everystack/cli 0.4.69 → 0.4.71

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.
@@ -50,7 +50,8 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
50
50
  import { readStageStateTwice } from '../stage-read-consistency';
51
51
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
52
52
  import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
53
- import { verifyDescent } from '../git-descent';
53
+ import { proveDescent } from '../git-descent';
54
+ import { readDescentLedger } from '../apply-execute';
54
55
  import { planBackfills, readBackfillLog } from '../backfill';
55
56
  import { readSqlDirIfPresent } from './db-sync';
56
57
  import { currentGitRef } from '../state-apply';
@@ -277,25 +278,35 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
277
278
  // The fast-forward rule, warn-only at mint time (db:apply enforces it):
278
279
  // a stale checkout mints a legal-looking plan whose edge REVERTS merged
279
280
  // work — say so on the review surface, where a human still reads it.
281
+ //
282
+ // Ledger-first, SYMMETRIC with db:apply — it must warn about exactly what apply will do.
283
+ // The old whole-history recompile warned "rebase first, will refuse" for
284
+ // the very strand apply now PROVES via the ledger and applies, telling the operator the
285
+ // opposite of what happens. proveDescent over the same ledger apply reads keeps them agreed.
280
286
  if (plan.statements.length > 0) {
281
- const verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
282
- // A switch, not an `if (diverged || drift)`. That form silently ignored every verdict
283
- // it did not name, so `indeterminate` would have minted a plan with no warning at all
284
- // and then been refused at apply time with no hint of why. Exhaustive here means a new
285
- // verdict cannot be forgotten.
287
+ const ledger = await readDescentLedger(session);
288
+ const verdict = await proveDescent(modelsPath, { snapshot, contract }, ledger, {});
289
+ // A switch, not an `if (revert || drift)`. That form silently ignored every verdict it
290
+ // did not name, so a new one would mint a plan with no warning and then be refused at
291
+ // apply time with no hint of why. Exhaustive here means a new verdict cannot be forgotten.
286
292
  switch (verdict.status) {
287
- case 'ok':
293
+ case 'proven':
288
294
  case 'fresh-target':
289
295
  case 'no-git':
290
296
  break;
291
- case 'diverged':
297
+ case 'revert':
292
298
  case 'drift':
293
299
  warn(`descent: ${verdict.reason}`);
294
300
  warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
295
301
  break;
296
- case 'indeterminate':
302
+ case 'unknown-commit':
303
+ // NOT "rebase first" — the checkout is fine, it is just missing the deploying commit.
304
+ warn(`descent: ${verdict.reason}`);
305
+ warn('db:apply from this checkout will refuse this plan until you fetch that commit — fetch, then re-mint.');
306
+ break;
307
+ case 'unverifiable':
297
308
  // NOT "rebase first" — nothing is wrong with the checkout or the database. The
298
- // search failed, so the rule is unproven either way.
309
+ // search could not read an ancestor state, so the rule is unproven either way.
299
310
  warn(`descent: ${verdict.reason}`);
300
311
  warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
301
312
  break;
@@ -647,6 +647,8 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
647
647
  info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply credential-free over the direct lane: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
648
648
  } else if (/timed out|timeout|task timed out/i.test(String(result.error))) {
649
649
  info('A large derived rebuild (dozens of objects) can exceed the ops-Lambda 900-second clock. Re-run credential-free with an unbounded clock: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct.');
650
+ } else if (/must be owner of/i.test(String(result.error))) {
651
+ info('This is a brownfield object owned by the master (the old tool created it), which the least-privilege operator cannot replace. Run `everystack db:provision --stage ' + (flags.stage ?? '<stage>') + '` first: it ADOPTS pre-existing objects to their declared owners (--confirm for SECURITY DEFINER), so the operator can then manage them. No hand-written ALTER … OWNER.');
650
652
  }
651
653
  process.exit(1);
652
654
  }
@@ -40,12 +40,11 @@ import { rewriteStatementLine, opensCopyData, closesCopyData } from '../schema-r
40
40
  import { resolveOperatorUrlViaStage } from '../direct-venue';
41
41
  import { withMutationLease, MutationLeaseError } from '../mutation-lease';
42
42
  import { resolveConfig, opsFunction, type CliConfig } from '../config';
43
- import { invokeAction, presignGet, createRdsSnapshot, describeRdsSnapshots } from '../aws';
44
- import { keyForArtifactId, metaKey, utcStamp } from '../backup';
45
- import { rdsSnapshotIdentifier } from '../rds-snapshot';
46
- import { pollTaskUntilStopped } from '../task-poll';
47
- import { decideSnapshotMode, confirmPhysicalSnapshot, interpretBackupPoll, type SnapshotModeRequest } from '../swap-snapshot';
48
- import { runStageBackup, stageBackupDeps } from '../stage-backup';
43
+ import { invokeAction, presignGet } from '../aws';
44
+ import { keyForArtifactId, metaKey } from '../backup';
45
+ import { decideSnapshotMode, type SnapshotModeRequest } from '../swap-snapshot';
46
+ import { confirmRollbackPoint } from '../rollback-point';
47
+ import { stageConfirmDeps } from '../stage-rollback-deps';
49
48
  import { pgEnvFromUrl, pgKeepaliveConninfo } from './db';
50
49
  import { step, success, fail, warn, info } from '../output';
51
50
 
@@ -448,58 +447,32 @@ async function takePreSwapSnapshot(
448
447
  plan: Exclude<ReturnType<typeof decideSnapshotMode>, { mode: 'refuse' }>,
449
448
  ctx: { stage?: string; region?: string; opsFn?: string },
450
449
  ): Promise<void> {
451
- if (plan.mode === 'attested') {
452
- info(`pre-swap rollback point: ${plan.ref} (attested via --snapshot-ref — no new snapshot taken).`);
453
- return;
454
- }
455
-
456
- if (plan.mode === 'none') {
457
- warn('NO pre-swap snapshot (--snapshot none). If this swap lands bad data there is no rollback point — the retiring schema is dropped once verify passes.');
458
- return;
459
- }
460
-
461
- if (plan.mode === 'physical') {
462
- step(`Snapshotting the instance before the swap (RDS physical snapshot of ${plan.instanceId})...`);
463
- const snapshotId = rdsSnapshotIdentifier(`${ctx.stage ?? 'swap'}-swap`, utcStamp(new Date()));
464
- const region = ctx.region!;
465
- const { id } = await confirmPhysicalSnapshot({
466
- create: (sid) => createRdsSnapshot(region, plan.instanceId, sid),
467
- describe: () => describeRdsSnapshots(region, plan.instanceId),
450
+ // The confirm ceremony is now shared with db:apply (rollback-point.ts) — one definition of
451
+ // "no destructive statement before a durable rollback point", two verbs. Swap keeps only its
452
+ // OWN wording: the restore-from advice, and the "swap was NOT applied, live is untouched"
453
+ // framing on a refusal.
454
+ const result = await confirmRollbackPoint(plan, {
455
+ ...stageConfirmDeps({
456
+ region: ctx.region!,
457
+ instanceId: plan.mode === 'physical' ? plan.instanceId : undefined,
458
+ opsFn: ctx.opsFn!,
459
+ stage: ctx.stage,
460
+ verb: 'swap',
468
461
  log: (m) => info(m),
469
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
470
- now: () => Date.now(),
471
- }, { instanceId: plan.instanceId, snapshotId });
472
- info(`rollback point CONFIRMED: RDS snapshot ${id} is available — restore the instance from it if this swap goes wrong.`);
473
- return;
474
- }
475
-
476
- // Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
477
- // conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
478
- step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
479
- // The shared sandwich. A rollback point taken across a concurrent migration holds a schema that
480
- // matches neither endpoint — restoring from it would not return the stage to where it was, which
481
- // is the one thing a pre-swap snapshot has to guarantee. An unproven artifact fails the swap.
482
- const backup = await runStageBackup({
483
- ...stageBackupDeps({ region: ctx.region! }, ctx.opsFn!, ctx.stage, {
484
- onStep: step,
485
- onDispatch: (d: { id: string; runId: string }) => info(`backup ${d.id} dispatched (run ${d.runId}) — waiting for the task to stop before the restore starts.`),
462
+ step,
486
463
  }),
487
- // Swap keeps its OWN reading of a finished poll: it is the only caller that can tell the
488
- // operator to re-run with `--snapshot-ref <id>` once the dump lands, and each outcome needs
489
- // different advice. The shared helper takes injected deps for exactly this.
490
- awaitTask: async (ids) => {
491
- const poll = await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId: ids.runId, taskArn: ids.taskArn });
492
- const verdict = interpretBackupPoll(poll, { runId: ids.runId, id: ids.id });
493
- if (!verdict.ok) return verdict;
494
- // Carry the stamp through, or this caller silently opts out of the image-skew check.
495
- return { ok: true, stampedFingerprint: (poll as any).status?.result?.fingerprint as string | undefined };
496
- },
464
+ log: (m) => info(m),
465
+ warn: (m) => warn(m),
497
466
  });
498
- if (!backup.ok) {
499
- throw new Error(`the pre-swap backup is not a usable rollback point, so the swap was NOT applied and live is untouched: ${backup.reason}`);
467
+
468
+ if (!result.ok) {
469
+ throw new Error(`the pre-swap rollback point could not be confirmed, so the swap was NOT applied and live is untouched: ${result.reason}`);
500
470
  }
501
- if (backup.warning) warn(backup.warning);
502
- info(`rollback point CONFIRMED: backup ${backup.id} complete — restore with db:restore --from ${backup.id} --confirm.`);
471
+ // Swap-specific restore advice on top of the shared confirmation.
472
+ const p = result.point;
473
+ if (p.mode === 'physical') info(`restore the instance from RDS snapshot ${p.ref} if this swap goes wrong.`);
474
+ else if (p.mode === 'logical') info(`restore with db:restore --from ${p.ref} --confirm if this swap goes wrong.`);
475
+ else if (p.mode === 'none') warn('the retiring schema is dropped once verify passes — there is no rollback point.');
503
476
  }
504
477
 
505
478
  export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
@@ -304,14 +304,35 @@ export function provisionPreservedIsRecoverable(args: {
304
304
  * same seam as pipeline-loader). */
305
305
  export interface ServerProvision {
306
306
  runProvision(
307
- payload: { authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean },
307
+ payload: {
308
+ authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean;
309
+ declaredOwners?: Array<{ owner: string; schema: string }>;
310
+ declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
311
+ confirm?: boolean;
312
+ },
308
313
  deps: {
309
314
  execute: (sql: string) => Promise<unknown>;
310
315
  connection?: { host: string; port?: number | string; database: string } | null;
311
316
  connectProbe?: (url: string) => Promise<void>;
312
317
  roleHasLogin?: (role: string) => Promise<boolean>;
318
+ query?: (sql: string) => Promise<any[]>;
319
+ master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
313
320
  },
314
321
  ): Promise<any>;
322
+ /** ENACT + ADOPT, run SEPARATELY and AFTER the secret is written (the lockout-safe ordering). */
323
+ convergeOwnership(
324
+ payload: {
325
+ declaredOwners?: Array<{ owner: string; schema: string }>;
326
+ declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
327
+ confirm?: boolean;
328
+ adminRole?: string;
329
+ },
330
+ deps: {
331
+ execute: (sql: string) => Promise<unknown>;
332
+ query?: (sql: string) => Promise<any[]>;
333
+ master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
334
+ },
335
+ ): Promise<{ ownerEnactment?: { enacted: number; refusals: string[] }; ownerAdoption?: { reassigned: number; manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>; findings: Array<{ severity: string; identity: string; detail: string }>; refusals: string[] } }>;
315
336
  }
316
337
 
317
338
  /**
@@ -450,6 +471,39 @@ export function declaredSearchPath(schemas: string[]): string[] {
450
471
  return nonPublic.length ? [...nonPublic, 'public'] : [];
451
472
  }
452
473
 
474
+ /** Kinds ADOPT can reassign — triggers have no owner, 'sql' escape-hatch objects are opaque. */
475
+ const ADOPTABLE_KINDS = new Set(['function', 'view', 'materialized view']);
476
+
477
+ /**
478
+ * Read the declared ownership out of the compiled derived source: the unique (owner, schema) pairs
479
+ * to ENACT, and the function/view/matview objects to ADOPT. `secdef` is read from the object's own
480
+ * CREATE (the descriptor compiler emits SECURITY DEFINER into it), so the confirm gate sees which
481
+ * reassignments change runs-as. Objects without a declared owner are left alone (reconcile creates
482
+ * them owned by the operator, no convergence needed).
483
+ */
484
+ export function extractDeclaredOwnership(objects: Array<{ kind: string; schema: string; name: string; identity: string; sql: string; owner?: string }>): {
485
+ declaredOwners: Array<{ owner: string; schema: string }>;
486
+ declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
487
+ } {
488
+ const seen = new Set<string>();
489
+ const declaredOwners: Array<{ owner: string; schema: string }> = [];
490
+ const declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
491
+ for (const o of objects) {
492
+ if (!o.owner) continue;
493
+ const key = `${o.owner}\t${o.schema}`;
494
+ if (!seen.has(key)) { seen.add(key); declaredOwners.push({ owner: o.owner, schema: o.schema }); }
495
+ if (ADOPTABLE_KINDS.has(o.kind)) {
496
+ declaredObjects.push({
497
+ identity: o.identity, schema: o.schema, name: o.name,
498
+ kind: o.kind as 'function' | 'view' | 'materialized view',
499
+ secdef: /SECURITY\s+DEFINER/i.test(o.sql),
500
+ currentOwner: '', declaredOwner: o.owner,
501
+ });
502
+ }
503
+ }
504
+ return { declaredOwners, declaredObjects };
505
+ }
506
+
453
507
  /**
454
508
  * Every schema the API connection must reach — the union of the TABLE schemas (the models
455
509
  * barrel) and the DERIVED-object schemas (views/matviews declared via
@@ -838,6 +892,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
838
892
  // authenticator flip doesn't go dark on bare-ref matview reads. Best-effort: a modelless or
839
893
  // all-public app sets no path (unchanged behavior).
840
894
  let declaredSchemas: string[] = [];
895
+ // Declared ownership (brownfield convergence) — the (owner, schema) pairs to ENACT and the
896
+ // function/view/matview objects to ADOPT, read from the same barrel as the schema set.
897
+ let declaredOwners: Array<{ owner: string; schema: string }> = [];
898
+ let declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
841
899
  try {
842
900
  const { resolveModelsPath } = await import('../models-path');
843
901
  const { loadModels } = await import('./db-generate');
@@ -846,7 +904,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
846
904
  try {
847
905
  const { loadDeclaredDerived } = await import('../declared-derived');
848
906
  const declared = await loadDeclaredDerived(flags.models);
849
- if (declared) derivedObjects = declared.objects;
907
+ if (declared) {
908
+ derivedObjects = declared.objects;
909
+ ({ declaredOwners, declaredObjects } = extractDeclaredOwnership(declared.objects));
910
+ }
850
911
  } catch {
851
912
  // No derived layer resolvable — the table schemas still resolve correctly.
852
913
  }
@@ -856,6 +917,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
856
917
  }
857
918
  const searchPath = declaredSearchPath(declaredSchemas);
858
919
  if (searchPath.length) info(` Declared search_path (set as the role default via ALTER ROLE): ${searchPath.join(', ')}`);
920
+ const confirm = !!flags.confirm;
921
+ const ownershipPayload = { declaredOwners, declaredObjects, confirm };
859
922
 
860
923
  // Generate BOTH login passwords and keep them in memory only — they are written straight
861
924
  // into the secret store and are NEVER printed, logged, or returned to a human.
@@ -870,6 +933,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
870
933
  const { createUrlRunner } = await import('../db-source');
871
934
  const { runner, end } = await createUrlRunner(url);
872
935
  try {
936
+ // Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
873
937
  return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
874
938
  execute: async (statement) => runner(statement),
875
939
  connection: parseUrlConnection(url),
@@ -950,6 +1014,61 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
950
1014
  success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
951
1015
  }
952
1016
 
1017
+ // Ownership convergence runs AFTER the secret write (below) — never before. Declared here so the
1018
+ // final exit status can see it.
1019
+ let ownershipIncomplete = false;
1020
+
1021
+ // ENACT + ADOPT, factored out because it must run on BOTH exit paths: the normal one (after the
1022
+ // secret write) AND the additive-only NO-OP path (credentials unchanged, so no secret is written —
1023
+ // but the owners in the model may have changed and still need converging). Running it only on the
1024
+ // normal path made the whole feature a no-op on a re-provision, the common brownfield case: the
1025
+ // stage is already provisioned, the operator adds `owner:` declarations (or re-runs the
1026
+ // review-then-`--confirm` flow), and nothing converged. Credentials are already saved on both
1027
+ // paths by the time this runs, so it can strand nothing.
1028
+ const runConvergence = async (): Promise<void> => {
1029
+ if (!declaredOwners.length && !declaredObjects.length) return;
1030
+ const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole };
1031
+ let owned: any = {};
1032
+ try {
1033
+ if (directUrl) {
1034
+ // The direct venue is ONE privileged connection = operator read AND master; ADOPT's live
1035
+ // master_can_act probe gates it (an under-privileged direct connection refuses precisely).
1036
+ const { convergeOwnership } = await loadServerProvision();
1037
+ const { createUrlRunner } = await import('../db-source');
1038
+ const { runner, end } = await createUrlRunner(directUrl);
1039
+ try {
1040
+ owned = await convergeOwnership(ownerPayload, {
1041
+ execute: async (s) => runner(s),
1042
+ query: async (s) => (await runner(s)) as any[],
1043
+ master: { execute: async (s) => runner(s), query: async (s) => (await runner(s)) as any[] },
1044
+ });
1045
+ } finally {
1046
+ await end?.();
1047
+ }
1048
+ } else {
1049
+ owned = await invokeAction(config.region, opsFunction(config), 'db:converge-ownership', ownerPayload);
1050
+ }
1051
+ } catch (err: any) {
1052
+ warn(`Ownership convergence failed (credentials are already saved — nothing stranded): ${err.message}`);
1053
+ ownershipIncomplete = true;
1054
+ }
1055
+ const enact = owned.ownerEnactment;
1056
+ if (enact) {
1057
+ if (enact.enacted) success(`Owner enactment: ${enact.enacted} grant(s) applied.`);
1058
+ for (const r of enact.refusals) { warn(`Owner enactment: ${r}`); ownershipIncomplete = true; }
1059
+ }
1060
+ const adopt = owned.ownerAdoption;
1061
+ if (adopt) {
1062
+ if (adopt.manifest?.length) {
1063
+ info('Ownership adoption — objects reassigned:');
1064
+ for (const m of adopt.manifest) info(` ${m.identity} [${m.kind}${m.secdef ? ', SECURITY DEFINER' : ''}] ${m.from} → ${m.to}`);
1065
+ }
1066
+ for (const f of adopt.findings ?? []) warn(` audit (${f.severity}): ${f.identity} — ${f.detail}`);
1067
+ if (adopt.reassigned) success(`Ownership adoption: ${adopt.reassigned} object(s) reassigned (one transaction).`);
1068
+ for (const r of adopt.refusals ?? []) { warn(`Ownership adoption: ${r}`); ownershipIncomplete = true; }
1069
+ }
1070
+ };
1071
+
953
1072
  // Additive-only: if the login roles already existed as live logins, provision changed no
954
1073
  // credential — so there is nothing to write and no host to resolve. Never touch the secret
955
1074
  // store on a no-op re-provision (the footgun that reset a live password then locked the stage out).
@@ -966,6 +1085,13 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
966
1085
  });
967
1086
  if (recoverable.ok) {
968
1087
  success(`Roles already provisioned — passwords and secrets left unchanged. Verify the live credential with \`everystack db:doctor --stage ${flags.stage}\`; run \`everystack db:provision --rotate\` to change credentials.`);
1088
+ // Credentials are a no-op, but declared ownership may still need converging — this is the
1089
+ // re-provision path (add owners, or the review-then---confirm retry). Run it, then exit.
1090
+ await runConvergence();
1091
+ if (ownershipIncomplete) {
1092
+ warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
1093
+ process.exit(1);
1094
+ }
969
1095
  return;
970
1096
  }
971
1097
  fail(recoverable.reason);
@@ -1028,6 +1154,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1028
1154
  success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
1029
1155
  for (const note of plan.notes) info(` ${note}`);
1030
1156
  for (const warning of plan.warnings) info(` ⚠ ${warning}`);
1157
+
1158
+ // OWNERSHIP CONVERGENCE — strictly AFTER the secret write. The credential is now safely in the
1159
+ // store, so a stall or a hard kill in ENACT/ADOPT can strand nothing: the worst case is an
1160
+ // un-converged owner, exactly the state db:reconcile refuses on (and we exit non-zero for it).
1161
+ await runConvergence();
1162
+
1031
1163
  console.log('');
1032
1164
  info('Finish the wiring in sst.config.ts (one-time):');
1033
1165
  info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
@@ -1044,6 +1176,14 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1044
1176
  info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
1045
1177
  }
1046
1178
  info(`Verify: everystack db:doctor --stage ${flags.stage}`);
1179
+
1180
+ // Credentials are done; ownership was left un-converged. Exit non-zero so CI and the operator act
1181
+ // on the refusals above (run the named SQL as master, or re-run with --confirm). db:reconcile
1182
+ // would refuse on exactly this state, so this is a real, actionable failure — not a warning to skip.
1183
+ if (ownershipIncomplete) {
1184
+ warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
1185
+ process.exit(1);
1186
+ }
1047
1187
  }
1048
1188
 
1049
1189
  /** Render a doctor report — identical output whichever venue produced it. */
@@ -59,6 +59,15 @@ export const ENSURE_RECONCILER_SQL: string[] = [
59
59
  duration_ms integer,
60
60
  outcome text NOT NULL
61
61
  )`,
62
+ // The descent witness columns (apply-legibility Phase 1). Idempotent — legacy databases
63
+ // upgrade in place; their old rows read fp_version = NULL (descent skips them) and
64
+ // dirty = false (harmless, since a NULL fp_version already skips them).
65
+ // - fp_version: the FINGERPRINT_VERSION a row's to_fingerprint was computed under, so the
66
+ // fast-forward proof can recompute the live side under the ROW'S canon across a bump.
67
+ // - dirty: the apply ran from a worktree whose models differed from HEAD, so its git_ref
68
+ // does not declare what was applied — descent must ignore the row (like git_ref = NULL).
69
+ `ALTER TABLE everystack.schema_log ADD COLUMN IF NOT EXISTS fp_version integer`,
70
+ `ALTER TABLE everystack.schema_log ADD COLUMN IF NOT EXISTS dirty boolean NOT NULL DEFAULT false`,
62
71
  ];
63
72
 
64
73
  export interface RenderedReconcile {
@@ -399,7 +408,10 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
399
408
  if (!missing.size && !unassumable.size && !noCreate.length) return null;
400
409
 
401
410
  const lines: string[] = [
402
- `declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied:`,
411
+ `declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
412
+ `The one-command fix is \`everystack db:provision\`: it ENACTS these grants (and ADOPTS any`,
413
+ `pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
414
+ `The exact statements, if you must run them by hand:`,
403
415
  ];
404
416
  for (const owner of [...missing].sort()) {
405
417
  lines.push(
@@ -555,12 +567,18 @@ export interface SchemaLogEntry {
555
567
  durationMs?: number;
556
568
  fromFingerprint?: string | null;
557
569
  toFingerprint?: string | null;
570
+ /** The FINGERPRINT_VERSION the fingerprints were computed under — the descent witness's canon. */
571
+ fpVersion?: number | null;
572
+ /** The apply ran from a dirty worktree (models differed from HEAD) — descent ignores such a row. */
573
+ dirty?: boolean;
558
574
  }
559
575
 
560
576
  export function renderSchemaLogInsert(entry: SchemaLogEntry): string {
561
577
  const duration = entry.durationMs === undefined ? 'NULL' : String(Math.round(entry.durationMs));
562
- return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, from_fingerprint, to_fingerprint, sql, duration_ms, outcome)
563
- VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${nullable(entry.fromFingerprint)}, ${nullable(entry.toFingerprint)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)})
578
+ const fpVersion = entry.fpVersion == null ? 'NULL' : String(Math.floor(entry.fpVersion));
579
+ const dirty = entry.dirty ? 'true' : 'false';
580
+ return `INSERT INTO everystack.schema_log (kind, actor, git_ref, plan_ref, from_fingerprint, to_fingerprint, sql, duration_ms, outcome, fp_version, dirty)
581
+ VALUES (${escapeLiteral(entry.kind)}, ${nullable(entry.actor)}, ${nullable(entry.gitRef)}, ${nullable(entry.planRef)}, ${nullable(entry.fromFingerprint)}, ${nullable(entry.toFingerprint)}, ${escapeLiteral(entry.sql)}, ${duration}, ${escapeLiteral(entry.outcome)}, ${fpVersion}, ${dirty})
564
582
  RETURNING id`;
565
583
  }
566
584
 
@@ -37,6 +37,20 @@ import { compileTableRenames, compileTableMoves } from './schema-compile';
37
37
 
38
38
  export const PLAN_VERSION = 2;
39
39
 
40
+ /**
41
+ * The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
42
+ * which versions the plan ARTIFACT. This versions the invoke PAYLOAD (its keys) and the ops
43
+ * Lambda's enforcement of them: the descent/authority/snapshot verdicts the CLI computes and the
44
+ * gates the server runs from them. PLAN_VERSION can hold while this moves — a payload key added or
45
+ * a hook's meaning changed leaves the plan file identical but breaks a mismatched peer silently.
46
+ *
47
+ * BUMP RULE: increment on ANY change to the db:apply payload keys, the set of gates the server
48
+ * runs, or a gate's semantics. Both packages import THIS binding (the server from the cli it
49
+ * bundles), so a version skew is a hard, exact-equality refusal in both directions — never a
50
+ * silently dropped gate.
51
+ */
52
+ export const APPLY_CONTRACT_VERSION = 1;
53
+
40
54
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
41
55
  export interface PlanClassification {
42
56
  /** Statements that positively ADD. Counted from a matcher, never `total - destructive` —