@everystack/cli 0.4.89 → 0.4.91

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.89",
3
+ "version": "0.4.91",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -266,7 +266,22 @@ async function runCatchUp(
266
266
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
267
267
  onRefusal,
268
268
  });
269
- if (verbose && outcome.run) for (const text of buildReconcileReport(outcome.run.plan)) info(text);
269
+ if (outcome.run) {
270
+ if (verbose) {
271
+ for (const text of buildReconcileReport(outcome.run.plan)) info(text);
272
+ } else if (outcome.run.plan.needsBaseline.length > 0) {
273
+ // NOT gated behind --verbose. A first-contact match (an object that predates Models
274
+ // tracking) is silently SKIPPED here — the reconciler will not guess whether live
275
+ // matches source — so a catch-up that only ever prints its full report under --verbose
276
+ // reads as "nothing to do" while real brownfield objects are left permanently
277
+ // unverified. This is the exact silence a consumer's derived-layer incident traced back
278
+ // to: db:apply reported success on every deploy while these objects sat unreconciled.
279
+ warn(
280
+ `${outcome.run.plan.needsBaseline.length} derived object(s) skipped (never reconciled, so this catch-up cannot tell whether live matches source): ${outcome.run.plan.needsBaseline.join(', ')} — ` +
281
+ 'run `everystack db:reconcile --baseline` to record trust once you have verified it matches, or `--rebuild` to force it from source. Does not fail this apply.',
282
+ );
283
+ }
284
+ }
270
285
  return { ok: true, statements: outcome.statements };
271
286
  } catch (err: any) {
272
287
  const cause = String(err?.message ?? err);
@@ -64,7 +64,10 @@ import { resolveConfig, opsFunction } from '../config';
64
64
  import { resolveOperatorUrlViaStage } from '../direct-venue';
65
65
  import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
66
66
  import { loadModels } from './db-generate';
67
- import { loadDeclaredDerived } from '../declared-derived';
67
+ import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
68
+ import { introspectDerived } from '../derived-introspect';
69
+ import { planReconcile } from '../derived-plan';
70
+ import { checkFails, buildReconcileReport } from './db-reconcile';
68
71
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
69
72
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
70
73
  import { alterTypeStatementTargets, findAlterTypeDependents, renderAlterTypeRefusal } from '../alter-type-dependents';
@@ -269,10 +272,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
269
272
  // The modules' extensions ride the same read. Without them the plan omits every
270
273
  // CREATE EXTENSION the target lacks, and the apply fails on a type the plan promised.
271
274
  let declaredExtensions: string[] = [];
275
+ // Kept for the derived-layer visibility check below — this IS the declared derived layer
276
+ // (functions/views/matviews/triggers), read once and reused rather than loaded twice.
277
+ let declaredDerived: DeclaredDerived | null = null;
272
278
  try {
273
- const declared = await loadDeclaredDerived(flags.models);
274
- declaredGovernedRoles = declared?.governedRoles ?? [];
275
- declaredExtensions = declared?.extensions ?? [];
279
+ declaredDerived = await loadDeclaredDerived(flags.models);
280
+ declaredGovernedRoles = declaredDerived?.governedRoles ?? [];
281
+ declaredExtensions = declaredDerived?.extensions ?? [];
276
282
  } catch {
277
283
  // The barrel's own compose errors surface on the paths that need the derived layer;
278
284
  // a plan must not fail to mint because a module could not be read for this one field.
@@ -456,6 +462,33 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
456
462
  }
457
463
  }
458
464
 
465
+ // DERIVED-LAYER VISIBILITY — db:apply ALWAYS reconciles functions/views/matviews/triggers
466
+ // against the models, whether or not a --plan is passed; the edge this command mints never
467
+ // covers that layer. Without this, an empty table/column/grant edge reads as "the target
468
+ // matches the checkout" even when a function body changed underneath it — an operator who
469
+ // trusts an empty plan never learns db:apply still has work to do. Read-only: the same pure
470
+ // planReconcile() that backs `db:reconcile --check`, applied to nothing here — advisory
471
+ // only, like the FK-orphan and backfill checks above, so it can go stale between mint and
472
+ // apply without corrupting the plan file (see the file header's rationale for why nothing
473
+ // read-time-only belongs in the artifact).
474
+ if (declaredDerived) {
475
+ try {
476
+ const liveDerived = await introspectDerived(session);
477
+ const derivedPlan = planReconcile(
478
+ { objects: declaredDerived.objects, warnings: [] },
479
+ liveDerived,
480
+ { renamedTables: declaredDerived.renamedTables },
481
+ );
482
+ if (checkFails(derivedPlan)) {
483
+ warn('derived layer out of sync (functions/views/matviews/triggers) — this plan does not preview these, only tables/columns/grants:');
484
+ for (const line of buildReconcileReport(derivedPlan)) warn(` ${line}`);
485
+ warn('db:apply always catches up the derived layer, --plan or not — run it to reconcile the above.');
486
+ }
487
+ } catch (err: any) {
488
+ warn(`derived-layer check could not run against this target (${err?.message ?? err}) — db:plan cannot say whether functions/views/matviews/triggers are in sync.`);
489
+ }
490
+ }
491
+
459
492
  await reportPipelineLastRun(runner);
460
493
 
461
494
  // THE DEFAULT REPORT — the only thing a reader sees when nothing is wrong.
@@ -476,6 +476,7 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
476
476
  for (const a of plan.actions) {
477
477
  const cost = a.bytes !== undefined ? `; ${formatBytes(a.bytes)}${a.rows ? `, ~${a.rows} rows` : ''}` : '';
478
478
  lines.push(`${VERB_GLYPH[a.action] ?? '?'} ${a.action} ${a.identity} (${a.reason}${cost})`);
479
+ for (const r of a.revokes ?? []) lines.push(` - ${r} (no longer declared)`);
479
480
  }
480
481
  // Pending identity migrations. Bookkeeping only — no DDL, no object changed — but they ARE a
481
482
  // write, and they were invisible here while also being excluded from `checkFails`. Silence in
@@ -140,9 +140,11 @@ export type OwnerApplyMode = 'set-role' | 'alter-owner';
140
140
  * carry pg_dump's WITH NO DATA ordering.)
141
141
  *
142
142
  * A declared owner is enacted one of two ways; see {@link OwnerApplyMode} for which and why.
143
+ * `revokes` (a replace's REVOKEs of dropped grantees) trail the attachments, inside the owner
144
+ * block for the same reason the attachments are.
143
145
  */
144
- function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role'): string[] {
145
- const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
146
+ function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', revokes: readonly string[] = []): string[] {
147
+ const statements = [obj.sql, ...obj.attachments.map((a) => a.sql), ...revokes];
146
148
  if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
147
149
  statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
148
150
  }
@@ -457,7 +459,7 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
457
459
  case 'replace': {
458
460
  const obj = srcById.get(action.identity);
459
461
  if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
460
- statements.push(...objectSql(obj, ownerMode).map(ensureOrReplace));
462
+ statements.push(...objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace));
461
463
  record.push(action.identity);
462
464
  break;
463
465
  }
@@ -62,6 +62,12 @@ export function isPlainGrantAttachment(a: Attachment): boolean {
62
62
  return g !== null && !g[2]; // g[2] is the (columns) group — present => column-scoped => not plain
63
63
  }
64
64
 
65
+ /** Whether the attachments run a `REVOKE ALL … FROM PUBLIC` — PUBLIC holds nothing after them
66
+ * beyond what a later attachment grants it back. */
67
+ export function revokesPublic(attachments: readonly Attachment[]): boolean {
68
+ return attachments.some((a) => a.kind === 'grant' && REVOKE_PUBLIC_RE.test(a.sql));
69
+ }
70
+
65
71
  /** The declared grant contract, read back from an object's own grant attachments. */
66
72
  export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
67
73
  const grants: Record<string, Set<string>> = {};
@@ -86,7 +92,12 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
86
92
  target ??= onTarget.trim();
87
93
  const privileges = privilegeList.split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
88
94
  for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
89
- for (const privilege of privileges) (grants[role] ??= new Set()).add(privilege);
95
+ // PUBLIC is a keyword, not a role: `TO public` and `TO PUBLIC` are one grantee, and the live
96
+ // read spells it `PUBLIC` (the table path's granteeKey, same rule). Keyed as written, a
97
+ // lowercase declaration read as a REVOKE from PUBLIC plus a GRANT to `public` — a replace,
98
+ // which keeps only the REVOKE, removed every caller's access.
99
+ const grantee = role.toLowerCase() === 'public' ? 'PUBLIC' : role;
100
+ for (const privilege of privileges) (grants[grantee] ??= new Set()).add(privilege);
90
101
  }
91
102
  }
92
103
 
@@ -27,7 +27,7 @@
27
27
  import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source';
28
28
  import type { DerivedCatalog, LiveObject } from './derived-introspect';
29
29
  import { triggerDropSql } from './derived-apply';
30
- import { parseGrantAttachments, diffObjectGrants } from './derived-grants';
30
+ import { parseGrantAttachments, diffObjectGrants, revokesPublic } from './derived-grants';
31
31
  import { legacyFunctionIdentity, splitFunctionIdentity } from './pg-argtypes';
32
32
 
33
33
  export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
@@ -43,6 +43,10 @@ export interface ReconcileAction {
43
43
  /** Drops for the structural kinds — a trigger's `DROP TRIGGER … ON table`, a 'sql'
44
44
  * object's recorded/declared drop. Legacy kinds keep the keyword rendering. */
45
45
  dropSql?: string;
46
+ /** A replace's REVOKEs of grantees the source no longer declares. CREATE OR REPLACE keeps the
47
+ * live ACL and the attachments only add, so without these a dropped role keeps its privilege.
48
+ * They run with the replace, inside its owner block — REVOKE on a function needs its owner. */
49
+ revokes?: string[];
46
50
  }
47
51
 
48
52
  export interface DriftFinding {
@@ -461,6 +465,10 @@ export function planReconcile(
461
465
 
462
466
  const regrants: RegrantFinding[] = [];
463
467
  const dependencyDrift: DependencyDriftFinding[] = [];
468
+ const ownerContext = (src: SourceObject, liveObj: LiveObject): { declared?: string; live?: string } => ({
469
+ ...(src.owner !== undefined ? { declared: src.owner } : {}),
470
+ ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
471
+ });
464
472
  // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
465
473
  // and running the checks NOW means a migration converges (grants ride the same apply)
466
474
  // instead of surfacing on the next plan.
@@ -493,11 +501,7 @@ export function planReconcile(
493
501
  // KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
494
502
  // is a failure to ADD access (availability), never to remove it, and unnormalized code did
495
503
  // not converge on it either.
496
- const owners = {
497
- ...(src.owner !== undefined ? { declared: src.owner } : {}),
498
- ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
499
- };
500
- const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
504
+ const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, ownerContext(src, liveObj));
501
505
  if (statements.length > 0) regrants.push({ identity: src.identity, statements });
502
506
  // Never silent: a withheld revoke is reported every run until the owner is declared.
503
507
  for (const note of suppressed) extraWarnings.push(note);
@@ -631,6 +635,34 @@ export function planReconcile(
631
635
  return { action, identity: id, kind, reason, ...cost, ...structural };
632
636
  };
633
637
 
638
+ /**
639
+ * The REVOKEs a function replace needs to converge its ACL in the same apply.
640
+ *
641
+ * CREATE OR REPLACE keeps the live ACL, and the compiled attachments only ADD: a REVOKE ALL
642
+ * FROM PUBLIC (unless PUBLIC is declared), then one GRANT per declared role. So a role dropped
643
+ * from the abilities in the same change as a body edit kept its EXECUTE until a second apply
644
+ * found it as grant drift. The ACL after the replace is the live one minus PUBLIC (when the
645
+ * attachments revoke it) plus the declared grants, so only the REVOKEs of the diff are left
646
+ * to do. They ride the replace itself rather than the regrant pass, because the replace runs
647
+ * inside its owner's SET ROLE block and a REVOKE on a function needs its owner.
648
+ *
649
+ * Read after the dependency cascade: a function it promoted to drop+create is recreated with a
650
+ * fresh ACL from its attachments alone, and is no longer in fnReplace.
651
+ */
652
+ const replaceRevokes = (id: string): string[] => {
653
+ const src = srcById.get(id);
654
+ const liveObj = liveById.get(id);
655
+ if (!src || !liveObj || liveObj.grants === undefined) return [];
656
+ const parsed = parseGrantAttachments(src.attachments);
657
+ if (parsed.hasColumnGrants) return [];
658
+ const target = parsed.target ?? `FUNCTION ${src.identity}`;
659
+ const afterReplace = { ...liveObj.grants };
660
+ if (revokesPublic(src.attachments)) delete afterReplace.PUBLIC;
661
+ const { statements, suppressed } = diffObjectGrants(target, parsed.grants, afterReplace, ownerContext(src, liveObj));
662
+ for (const note of suppressed) extraWarnings.push(note);
663
+ return statements.filter((st) => /^REVOKE\b/i.test(st));
664
+ };
665
+
634
666
  const actions: ReconcileAction[] = [
635
667
  ...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
636
668
  // 'sql'-kind drops (rebuilds + removals) — invisible to the live catalog, so they
@@ -638,7 +670,10 @@ export function planReconcile(
638
670
  ...[...sqlDrops.entries()].sort((a, b) => a[0].localeCompare(b[0]))
639
671
  .map(([id, d]) => ({ action: 'drop' as const, identity: id, kind: 'sql' as DerivedKind, reason: d.reason, dropSql: d.dropSql })),
640
672
  ...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
641
- .map(([id, reason]) => actionFor(id, 'replace', reason)),
673
+ .map(([id, reason]) => {
674
+ const revokes = replaceRevokes(id);
675
+ return { ...actionFor(id, 'replace', reason), ...(revokes.length ? { revokes } : {}) };
676
+ }),
642
677
  ...createSet.sort((a, b) => seqOf(a) - seqOf(b))
643
678
  .map((id) => actionFor(id, 'create', rebuild.get(id) ?? create.get(id) ?? '')),
644
679
  ...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
package/src/cli/index.ts CHANGED
@@ -331,8 +331,8 @@ Usage:
331
331
  everystack db:refresh [--stage <name> | --database-url <url> | --direct] [--only a,b] [--verify-nonempty] [--list] Refresh the declared materialized views in dependency order, credential-free. --stage runs the whole refresh in the ops Lambda on the operator connection (no URL on the operator's machine — the data-lane twin of the reconcile lane); --database-url refreshes over a local connection (dev); --stage <name> --direct resolves the stage's operator connection from its ops Lambda (credential never on argv) and refreshes CLI-side on an UNBOUNDED clock, for a matview set that exceeds the ops-Lambda 900s limit; --only refreshes a named subset (full identity or bare name); --verify-nonempty gates on populated-but-zero-rows matviews (the dark-panel check — a scoped EXISTS on just what was refreshed, so a promotion script gets the gate with no read authority) and FAILS naming any empty; --list previews the order without connecting. Plain REFRESH (ACCESS EXCLUSIVE); fail-fast, idempotent to re-run.
332
332
  everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
333
333
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
334
- everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref)
335
- everystack db:apply [--plan <file.plan.json>] [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--state-only] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] [--verbose] THE deploy verb — it does the WHOLE job: the reviewed plan (tables, columns, grants), then ALWAYS the functions, views, matviews and triggers, against the same target, reported as one receipt. With NO --plan it does the catch-up alone (this is what replaces running db:reconcile --apply by hand). --state-only skips the catch-up for the narrow case that wants the tables alone. If the plan lands and the catch-up then fails, the exit is non-zero and the message says the table/column changes are committed and safe and gives the rerun, which retries only the catch-up. Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there DEFAULT OUTPUT is the receipt: the same change lines back, then 'Applied N changes.'. --verbose restores the endpoint and plan-ref line
334
+ everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] [--verbose] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only; plans are ephemeral, never committed. VENUES: --stage runs via the ops Lambda, which reads TWICE and refuses on disagreement — agreement there is a DETECTOR, not a verification. --stage --direct resolves the stage's operator connection from its IAM-gated ops Lambda, holds it in memory only, and reads ONCE over one session: the lane for a fingerprint you intend to trust, with the credential never on argv. --database-url is the local-dev venue (same read guarantee, but against a deployed stage it puts a privileged DSN on the command line) DEFAULT OUTPUT is the plain change list: '+' something is now true, '-' something is no longer true, and a '⚠ DESTRUCTIVE' line of its own when real data can be lost. Nothing else. --verbose restores the full internal report (edge summary, kind histogram, adoption and ownership reports, plan ref). The edge covers tables/columns/grants ONLY — it also reads (never mints) whether the DERIVED layer (functions/views/matviews/triggers) is out of sync with the target and warns if so, since db:apply always reconciles that layer regardless of --plan: an empty edge here does not mean the target fully matches the checkout
335
+ everystack db:apply [--plan <file.plan.json>] [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--state-only] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] [--verbose] THE deploy verb — it does the WHOLE job: the reviewed plan (tables, columns, grants), then ALWAYS the functions, views, matviews and triggers, against the same target, reported as one receipt. With NO --plan it does the catch-up alone (this is what replaces running db:reconcile --apply by hand). --state-only skips the catch-up for the narrow case that wants the tables alone. A brownfield derived object the reconciler has never touched before (no provenance row) is SKIPPED, never guessed at, and that skip prints by default (not only under --verbose): 'N derived object(s) skipped ... db:reconcile --baseline' — a plain apply that only showed this under --verbose read as full success while such objects sat permanently unreconciled. If the plan lands and the catch-up then fails, the exit is non-zero and the message says the table/column changes are committed and safe and gives the rerun, which retries only the catch-up. Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule — it protects MERGED work: a commit on any ref that declares the live state and is not an ancestor of HEAD refuses by name, escaped git-natively by fetch + merge/rebase + re-plan; --attest-lineage-lost <sha> waives it ONLY for a declaring commit proven gone from every ref. When NO commit on ANY ref declares the live state — a database everystack never wrote, or one hand-edited since its last apply — the rule has nothing to protect: over a full, current clone that is PROVEN DRIFT, the reviewed plan is the ORIGIN, it applies with a loud warning, and the origin is recorded in schema_log so every later apply descends from it. A shallow, partial, or behind-remote clone, or a models tree that will not compile, is UNVERIFIABLE and refuses with a fetch/prune runbook), and for DESTRUCTIVE plans require --confirm always + a confirmed rollback point + the stage's approver set when declared (STS identity-verified). The STAGE lane (--stage without --direct) runs every catalog query in its own ops-Lambda invoke, so one read can be assembled from several containers: it reads the target TWICE and REFUSES when the two disagree (inconsistent containers), reports agreement as a NON-DETECTION (it cannot verify read consistency), and REFUSES a DESTRUCTIVE plan outright — destructive applies go over --database-url (direct) with the full ceremony. Every refusal that reaches the ops Lambda is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there DEFAULT OUTPUT is the receipt: the same change lines back, then 'Applied N changes.'. --verbose restores the endpoint and plan-ref line
336
336
  everystack db:build --database-url <url> [--models <barrel>] Build a database FROM the models and KEEP it — the fresh-database bootstrap a deleted migration folder is replaced BY. Runs the same compose db:check proves buildability with (extensions -> schemas -> contract roles -> declared functions -> state -> derived layer), against a real target, then reports MATCH. Venue is EXPLICIT: --database-url only, never the ambient environment, because this writes a whole schema. REFUSES a database that already holds objects — evolving an existing one is db:sync (dev) or db:plan -> db:apply (a stage). Contract roles are created NOLOGIN if absent, and roles are cluster-level
337
337
  everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] [--verbose] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, descriptors compile), every exposed RLS-enabled table must declare a read path (no force-RLS-with-no-read landmine that goes dark on the superuser drop), and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target DEFAULT OUTPUT is ONE line: 'Up to date.' or 'N changes pending. Run db:plan to review.'. --verbose restores every finding and the from-scratch build report
338
338
  everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only