@everystack/cli 0.4.81 → 0.4.83

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.
@@ -21,102 +21,25 @@
21
21
  */
22
22
 
23
23
  import { createHash } from 'node:crypto';
24
+ import type { DerivedAttachment, DerivedDescriptor, DerivedKind as WireDerivedKind } from '@everystack/model/wire';
24
25
 
25
26
  /**
26
27
  * `'trigger'` and `'sql'` carry the extra fields the reconciler edges need (a trigger's
27
28
  * table for `DROP TRIGGER … ON`, a defineSql object's explicit drop).
29
+ *
30
+ * DEFINED IN `@everystack/model/wire` and re-exported here. `db:reconcile` ships these objects
31
+ * over the wire to a deployed stage's ops Lambda, so the shape is a versioned wire contract
32
+ * (MODEL_WIRE_CONTRACT), not a CLI-local convenience — one definition, imported by both sides,
33
+ * rather than a CLI copy and a server copy that happen to agree.
28
34
  */
29
- export type DerivedKind = 'function' | 'view' | 'materialized view' | 'trigger' | 'sql';
35
+ export type DerivedKind = WireDerivedKind;
30
36
 
31
37
  /** A statement that belongs to an object: its index, comment, or grant. */
32
- export interface Attachment {
33
- kind: 'index' | 'comment' | 'grant';
34
- sql: string;
35
- }
38
+ export type Attachment = DerivedAttachment;
36
39
 
37
- export interface SourceObject {
38
- kind: DerivedKind;
39
- /** Schema name, defaulted to `public` when unqualified. */
40
- schema: string;
41
- name: string;
42
- /** `schema.name` — the reconciler's join key against the live catalog.
43
- * (Triggers: `schema.table.name` — compound identity, joined against pg_trigger.) */
44
- identity: string;
45
- /** The CREATE statement as authored (no trailing semicolon). */
46
- sql: string;
47
- /** Indexes / comments / grants targeting this object, in source order. */
48
- attachments: Attachment[];
49
- /** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
50
- hash: string;
51
- /**
52
- * sha256 of the normalized CREATE + attachments EXCEPT plain (non-column-scoped) grants — the
53
- * "would a rebuild be required" hash. Equal across an authz-only change (plain grants moved, body
54
- * unchanged), so the reconciler can route that to a bare GRANT delta instead of a matview rebuild.
55
- * Column-scoped grants stay IN (attacl not introspected → they must rebuild). `hash` stays the
56
- * full/stable formula for provenance compat; this is the additional discriminator.
57
- */
58
- bodyHash: string;
59
- /** Source file this object came from (descriptors: the declared-models marker). */
60
- file: string;
61
- /** Position in the concatenated source — a valid dependency order by convention. */
62
- seq: number;
63
- /**
64
- * kind 'function' only: the declared owning role.
65
- *
66
- * A SECURITY DEFINER function executes with its OWNER's rights, so this is a privilege
67
- * boundary. The applier enacts it by running the CREATE under `SET ROLE <owner>` rather
68
- * than following it with `ALTER … OWNER TO`: measured on PG16, once ownership has moved the
69
- * builder gets "must be owner of function" on the next `CREATE OR REPLACE`, so the
70
- * alter-after form works exactly once and breaks every re-run.
71
- */
72
- owner?: string;
73
- /**
74
- * kind 'function' only: the declared `SET search_path`, or the sentinel `'unpinned'`.
75
- *
76
- * It is already inside `sql`, so this is NOT in the hash and changes no fingerprint. It rides
77
- * structurally so the differ can compare the declared pin against the LIVE catalog directly
78
- * rather than only against a recorded hash. Without that, `--baseline` records a declared pin
79
- * the database does not have and every later check reports zero actions, forever: measured,
80
- * and it is how a model came to claim `search_path=pg_catalog` about a database with none
81
- * while every gate stayed green.
82
- *
83
- * `undefined` means NOT DECLARED — a parser-era db/sql object, or an invoker function with no
84
- * pin. The comparison is skipped there; absent evidence is not evidence.
85
- */
86
- searchPath?: string[] | 'unpinned';
87
- /** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
88
- objectKind?: string;
89
- /** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
90
- drop?: string;
91
- /** kind 'trigger' only: the (possibly schema-qualified) table/view the trigger rides. */
92
- table?: string;
93
- /**
94
- * Views/matviews: every declared `dependsOn` ref as an identity (models AND derived),
95
- * sorted. B4's dependency drift verifies it against the live catalog's actual edges —
96
- * "declared, then verified". Absent on kinds whose live edges pg_depend can't see.
97
- */
98
- declaredDeps?: string[];
99
- /**
100
- * kind 'function' only: the relations this function's SIGNATURE names — a `setof(ref)`
101
- * return, or an argument/return type spelled as a declared table, view or matview.
102
- *
103
- * Distinct from `dependsOn`, and the distinction is load-bearing for the from-scratch build.
104
- * A body reference is a FORWARD reference the build can defer (`check_function_bodies = off`
105
- * is exactly that escape), so a function whose body reads a not-yet-created table still
106
- * creates. A SIGNATURE reference is not deferrable: PostgreSQL resolves the return and
107
- * argument types when the function is created, so `RETURNS SETOF stats_view.x_row` needs
108
- * that view to exist already. `db:build` runs functions ahead of state (an RLS policy's
109
- * predicate must resolve the function it calls) and uses this to hold back the ones that
110
- * cannot go first; the compiler also orders the function after them.
111
- *
112
- * RELATIONS only — tables, views, matviews. A signature naming a declared ENUM, domain or
113
- * composite type has the same problem and is NOT covered here, so it still fails in the
114
- * ahead-of-state pass. Absent/empty means "names no declared relation", never "is safe".
115
- *
116
- * Not in the hash — it is derived from `sql`, so carrying it moves no fingerprint.
117
- */
118
- signatureDeps?: string[];
119
- }
40
+ /** The compiled derived-object descriptor — declared, field by field, in
41
+ * `@everystack/model/wire` as `DerivedDescriptor`. */
42
+ export type SourceObject = DerivedDescriptor;
120
43
 
121
44
  /** The provenance marker descriptor-compiled objects carry in `file` — the compiler
122
45
  * ALWAYS declares authz (a private relation is declared-empty, not undeclared), so
@@ -207,9 +130,21 @@ export function lex(
207
130
  continue;
208
131
  }
209
132
  if (ch === "'") {
210
- // E'...' / e'...' strings give backslash escape semantics.
133
+ // `E'...'` is PostgreSQL's backslash-escaping string literal — but the `E` only counts
134
+ // when it STANDS ALONE before the quote. Testing the single preceding character glued
135
+ // that rule onto the tail of any identifier or keyword ending in `e`, which in English
136
+ // is most of them: `date'2024-01-01'`, `store'x'`, `xe'y'`. A backslash inside such a
137
+ // literal then swallowed the closing quote, the lexer stayed "inside a string" past the
138
+ // real end, and `splitSqlStatements` stopped seeing the `;` boundaries after it — a
139
+ // multi-statement file counted as ONE, and db:exec/db:backfill sent the whole tail to
140
+ // the database as a single string. So require the character BEFORE the `E`/`e` to be a
141
+ // non-identifier character, or absent (the `E` starts the text).
142
+ // Mirrored in `packages/server/src/sql-lex.ts` — the deliberate fork, kept in step by hand.
211
143
  const prev = text[i - 1];
212
- state = { in: 'single', escaped: prev === 'E' || prev === 'e' };
144
+ const beforePrev = text[i - 2];
145
+ const standaloneE = (prev === 'E' || prev === 'e')
146
+ && (beforePrev === undefined || !WORDISH.test(beforePrev));
147
+ state = { in: 'single', escaped: standaloneE };
213
148
  emit(ch, 'single', i); i++;
214
149
  continue;
215
150
  }
@@ -34,6 +34,7 @@ import { compileTableContract } from './authz-compile';
34
34
  import { carryUngovernedAuthz, inheritedSequenceGrants, type GrantExemption } from './authz-reconcile';
35
35
  import { BASELINE_FILE } from './authz-baseline';
36
36
  import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply';
37
+ import { statementHead, statementHeadOrSelf } from './sql-statement';
37
38
  import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForModels, stableStringify, compareStoredFingerprint, formatChangedMessage, schemaOf, FINGERPRINT_VERSION, type Fingerprint } from './schema-fingerprint';
38
39
  import { compileDeclaredState } from './declared-diff';
39
40
  import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
@@ -127,6 +128,23 @@ export interface EdgePlan {
127
128
  * and verify raw, as they were minted).
128
129
  */
129
130
  governedRoles?: string[];
131
+ /**
132
+ * Every grantee the models declared AT MINT, sorted — the discriminator that separates a revoke
133
+ * that is ordinary churn from one that is a STRIP (a revoke against a role no model grants to,
134
+ * so nothing can put the access back).
135
+ *
136
+ * Pinned onto the plan for the same reason `governedRoles` is: the answer must be the one the
137
+ * mint used, not the one the checkout happens to hold later. db:apply can run long after
138
+ * db:plan — across a rebase, or a hotfix to the models — and a receipt that re-reads the models
139
+ * at apply time marks its ⚠ lines against a declared side the plan was never verified under.
140
+ * The plan's own destructive breakdown reads it here too, so what db:plan prints and what
141
+ * db:apply prints come from one snapshot rather than two readings of a moving tree.
142
+ *
143
+ * Absent means a plan minted before this field existed: both readers degrade to their prior
144
+ * behaviour (the summary shows no strips; the receipt re-reads the checkout's models) rather
145
+ * than failing — nothing is DECIDED from this field, every gate that acts on a strip ran at mint.
146
+ */
147
+ declaredGrantees?: string[];
130
148
  /** The models-only fingerprint — context for the strong claim. Equals `to` when nothing is unmodeled. */
131
149
  declaredFingerprint: string;
132
150
  /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
@@ -390,6 +408,23 @@ export function governedLiveFingerprint(
390
408
  * state. Pure: introspection in, plan out. Throws when the edge would hold
391
409
  * drops back — a plan must be complete to have a reachable endpoint.
392
410
  */
411
+ /**
412
+ * Every grantee the models declare — the discriminator for a STRIP (a revoke against a role no
413
+ * model grants to has nothing to restore it from, so it carries a dropped column's ceremony).
414
+ *
415
+ * Exported because the PLAN REPORT has to mark exactly the statements the --confirm gate counts.
416
+ * Two copies of this loop would eventually disagree, and the direction it would disagree in is
417
+ * the bad one: a report calling a strip ordinary churn while the gate calls it destructive.
418
+ */
419
+ export function declaredGranteesOf(models: ModelDescriptor[], opts: { schema?: string } = {}): Set<string> {
420
+ const grantees = new Set<string>();
421
+ for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
422
+ for (const g of Object.keys(t.grants)) grantees.add(g);
423
+ for (const g of Object.keys(t.columnGrants ?? {})) grantees.add(g);
424
+ }
425
+ return grantees;
426
+ }
427
+
393
428
  export function mintEdgePlan(
394
429
  models: ModelDescriptor[],
395
430
  snapshot: SchemaSnapshot,
@@ -418,11 +453,7 @@ export function mintEdgePlan(
418
453
  // The grantees the models actually declare — the discriminator for a STRIP (a revoke
419
454
  // against a role no model grants to has nothing to restore it from, so it carries the
420
455
  // same ceremony as a dropped column).
421
- const declaredGrantees = new Set<string>();
422
- for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
423
- for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
424
- for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
425
- }
456
+ const declaredGrantees = declaredGranteesOf(models, { schema: opts.schema });
426
457
  const breakdown = partitionStatements(classified.executable, { declaredGrantees });
427
458
  const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
428
459
 
@@ -444,6 +475,10 @@ export function mintEdgePlan(
444
475
  defaultAclHash: defaultAclHash(contract),
445
476
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
446
477
  governedRoles: [...governedRoles].sort(),
478
+ // Pinned, not re-derived later: the receipt after the apply and the breakdown in this plan's
479
+ // own summary both read the declared side from HERE, so neither can drift onto a checkout
480
+ // whose models moved between mint and apply.
481
+ declaredGrantees: [...declaredGrantees].sort(),
447
482
  statements,
448
483
  executable: classified.executable.length,
449
484
  destructive,
@@ -487,7 +522,7 @@ export function tablesLeftWithoutARead(statements: readonly string[], live?: Aut
487
522
  const touched = new Set<string>();
488
523
 
489
524
  for (const statement of statements) {
490
- const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
525
+ const head = statementHead(statement);
491
526
  let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?(\S+)\s+ON\s+(\S+?);?$/i.exec(head);
492
527
  if (m) {
493
528
  touched.add(m[2]);
@@ -627,20 +662,48 @@ export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] }
627
662
  // The shape first — a four-digit edge is reviewable as a histogram, not a wall.
628
663
  const histogram = renderStatementHistogram(classifyGeneratedStatements(plan.statements).executable);
629
664
  if (histogram) lines.push(`shape: ${histogram}`);
665
+ // The declared side comes off the PLAN, never off the checkout — this is the same set the mint
666
+ // counted `destructive` with, so every bucket below agrees with what the plan already says about
667
+ // itself. Both classifiers in this function read it: they partition the SAME statement stream,
668
+ // and handing the set to one but not the other makes the two halves of one summary disagree.
669
+ // Absent on a pre-field plan: every reading falls back to the old one exactly, because
670
+ // best-effort marking never blocks the operator.
671
+ const declaredOpt = plan.declaredGrantees
672
+ ? { declaredGrantees: new Set(plan.declaredGrantees) }
673
+ : {};
674
+ const declared = plan.declaredGrantees !== undefined;
630
675
  if (plan.destructive > 0) {
631
- const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
676
+ // Without the declared side the strip bucket is empty by construction (classifyDestructive
677
+ // will not guess), and a strips-only plan printed `N DESTRUCTIVE — 0 drop(s), 0 narrowing type
678
+ // change(s)` with the statement itself missing from the list under it.
679
+ const { drops, narrowings, strips } = classifyDestructive(
680
+ classifyGeneratedStatements(plan.statements).executable,
681
+ declaredOpt,
682
+ );
683
+ const stripClause = declared ? `, ${strips.length} authorization strip(s)` : '';
632
684
  lines.push(
633
- `! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
685
+ `! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s)${stripClause}. Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
634
686
  );
635
- for (const statement of [...drops, ...narrowings]) {
636
- const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
637
- lines.push(`! ${ddl.trim()}`);
687
+ for (const statement of [...drops, ...narrowings, ...strips]) {
688
+ lines.push(`! ${statementHeadOrSelf(statement)}`);
638
689
  }
639
690
  }
640
691
  // Authorization removed loses no row, so it is not gated — but it decides who can SEE the
641
692
  // rows, and a plan that drops a table's only read policy leaves that table returning nothing.
642
693
  // 11 tables went dark in a real adoption plan that printed "0 notice(s)".
643
- const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
694
+ //
695
+ // Same declared side as the destructive breakdown above, and for the same reason. partitionStatements
696
+ // gives each statement exactly one bucket, consulting the data-loss buckets FIRST — so with the set
697
+ // in hand a strip lands in `strips` and is kept out of `authzRemovals`. Without it the strip is
698
+ // unrecognisable as such and falls through to `authzRemovals`, which is where it belongs on a
699
+ // pre-field plan: that is the only bucket that can hold it, and a revoke nobody can grade still
700
+ // has to be seen. Passing it to the classifier above but not here printed the strip TWICE — once
701
+ // under DESTRUCTIVE and again in the REMOVE-authorization count — and that count then disagreed
702
+ // with the plan's own `classification`, which the mint computed with the declared side.
703
+ const { authzRemovals, unclassified } = partitionStatements(
704
+ classifyGeneratedStatements(plan.statements).executable,
705
+ declaredOpt,
706
+ );
644
707
  // Prefer what the mint resolved against live. Recomputing here would have only the statement
645
708
  // text and would re-introduce the false positives the mint-time answer exists to avoid.
646
709
  const dark = plan.dark ?? tablesLeftWithoutARead(plan.statements);
@@ -653,8 +716,7 @@ export function buildPlanSummary(plan: EdgePlan, opts: { carriedBy?: string[] }
653
716
  if (unclassified.length > 0) {
654
717
  lines.push(`! ${unclassified.length} statement(s) could NOT be classified — read them before applying. They are not counted as additive:`);
655
718
  for (const statement of unclassified) {
656
- const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
657
- lines.push(`! ${ddl.trim()}`);
719
+ lines.push(`! ${statementHeadOrSelf(statement)}`);
658
720
  }
659
721
  }
660
722
  if (plan.unmodeled.length > 0) {
package/src/cli/index.ts CHANGED
@@ -351,9 +351,9 @@ Usage:
351
351
  everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
352
352
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
353
353
  everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
354
- everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
354
+ everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (read-only). Runs as the SELECT-only 'reader' role when the stage has one (db:provision --with-reader + QUERY_DATABASE_URL wired to dbPlugin's queryDatabaseUrl); WITHOUT one it runs on the OPERATOR connection, fenced only by a read-only transaction — see docs/cli.md
355
355
  everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
356
- everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation
356
+ everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] [--with-reader] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation. --with-reader adds the SELECT-only 'reader' login db:query runs as, grants it read on the declared schemas, and writes QUERY_DATABASE_URL (opt-in once, then kept converged on every re-provision)
357
357
  everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
358
358
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
359
359
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
@@ -364,17 +364,17 @@ Usage:
364
364
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
365
365
  everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
366
366
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
367
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
367
+ everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts | dir>] [--abilities live|public-read] Introspect the live DB → render field() Models — THE on-ramp for an existing database: commit what it renders, then db:plan --stage <s>. EMPTY plan = adopted as it is (that commit declares the database; every later db:apply descends from it). NON-EMPTY plan = the hardening edge: review it, db:apply --plan <f> --stage <s> — no commit declares the database yet, so the reviewed plan is the ORIGIN. Render from the stage, never from a local restore of a dump. --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. --derived-out <dir> (anything not ending in .ts) splits that module by kind and schema instead — views.ts, functions-<schema>.ts, triggers.ts, index.ts barrel — for a real schema whose derived layer is otherwise one unreviewable file; refuses loudly, before writing anything, if the split files would import each other. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --govern-roles <r1,r2> (with --abilities live) transcribes the named FOREIGN roles' grants into privileges — the models then OWN them, so a fresh build recreates them; required for migration deletion when the migrations created grants to an ops/connection role. Explicit only (a re-pull must not silently govern), complete-or-refuse (a listed role holding a column-scoped grant fails the pull before anything is written). --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
368
368
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
369
369
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
370
- everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
370
+ everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--rebaseline] [--overwrite-drift] [--only a,b] [--json] ADVANCED — you do not run this for a deploy: db:apply runs it for you (after a plan, and alone when given no --plan), and db:check runs its checkout-side gates. It is fully supported, not deprecated — scripts and CI jobs calling it directly keep working unchanged. Reach for it yourself for a surgical fix (--only <schema.name> rebuilds one object without minting a plan), an adoption move (--baseline / --rebaseline), or a hand-edit overwrite (--overwrite-drift). Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
371
371
  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.
372
372
  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
373
373
  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)
374
- everystack db:plan [--stage <name> [--direct] | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] 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)
375
- everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--attest-lineage-lost <sha>] 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
374
+ 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)
375
+ 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
376
376
  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
377
- everystack db:check [--models <barrel>] [--schema-out <file.ts>] [--database-url <url>] [--json] 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
377
+ 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
378
378
  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
379
379
  everystack db:backfill [--database-url <url>] [--dir db/backfills] [--apply] [--mark-applied <file.sql>] [--json] One-shot data jobs in their own lane: plan shows applied (by CONTENT identity — renames/comment edits are no-ops) / pending (in order, unbounded-pass advisories) / blocked (a name that already ran in a different form — one-shot jobs are immutable). --apply runs each pending job as its own transaction, recorded in everystack.backfill_log (a failure rolls back alone, is recorded, stops the run); --mark-applied records without running. Never runs as a schema side effect; direct connection required
380
380
  everystack db:exec <file.sql> [--stage <name> | --database-url <url>] [--schemas a,b] [--confirm] Credential-free write SQL — the psql-piped-file replacement (or pipe SQL on stdin). Runs the whole file as ONE transaction, DML-only enforced SEMANTICALLY: a catalog digest before vs after rejects ANY schema change (however caused — a function, a DO block, dynamic SQL) by rolling the txn back. Schema changes go through db:apply / db:reconcile. Every run is recorded crash-truthfully in everystack.exec_log (intent before, per-statement rows-affected after). --stage runs in the ops Lambda (operator holds no URL); prod-tier writes require --confirm; --database-url is local-direct (--schemas scopes the digest, default public)
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The Model-derived wire-contract PREFLIGHT — the CLI half of the handshake.
3
+ *
4
+ * `db:provision`, `db:converge-ownership` and `db:reconcile --apply --stage` all compute a payload
5
+ * from the Models barrel and hand it to a deployed stage's ops Lambda, which acts on it against a
6
+ * real database: it reassigns object ownership, or creates and replaces derived objects. The CLI
7
+ * doing the computing and the `@everystack/cli` bundled into that Lambda are separate installs on
8
+ * independent release trains, so their idea of those shapes can drift.
9
+ *
10
+ * The SERVER gate is the authority (never trust a client's claim about itself). This preflight
11
+ * covers the half the server structurally cannot: a stage deployed BEFORE the guard existed has no
12
+ * code to refuse with. Its `_health` still answers — it simply omits `modelContract` — so ABSENCE
13
+ * is the older-stage signal and refuses exactly as hard as a mismatch.
14
+ *
15
+ * TWO functions, and the split matters when you go to test them:
16
+ *
17
+ * `checkStageModelContract` is PURE — a `_health` answer in, a verdict and its phrasing out. No
18
+ * connection, no I/O, no exit. Every rule about what counts as skew is decided here, so it can
19
+ * be exercised by passing it plain objects.
20
+ *
21
+ * `modelContractPreflight` is the ORCHESTRATION around it, and it is not pure: it invokes
22
+ * `_health` over the wire, prints, and calls `process.exit(1)` on a refusal. It exists so the
23
+ * commands do not each hand-roll that sequence and drift into three wordings for one skew.
24
+ *
25
+ * Sibling of `checkStageApplyContract` in apply-lane.ts, which decides the same question for
26
+ * db:apply's separate `APPLY_CONTRACT_VERSION`. (That command still orchestrates its own preflight
27
+ * inline; consolidating the two is deliberately NOT done here — the apply contract's machinery is
28
+ * left untouched on this branch.) The two numbers are never compared to each other.
29
+ */
30
+
31
+ import { MODEL_WIRE_CONTRACT } from '../wire';
32
+ import { invokeAction } from './aws';
33
+ import { step, fail, info } from './output';
34
+
35
+ /**
36
+ * Does the deployed stage speak THIS CLI's Model-derived wire contract?
37
+ *
38
+ * `health` is the raw `_health` answer. `verb` is the command about to dispatch, so the refusal
39
+ * names the thing the operator actually typed.
40
+ *
41
+ * Exact equality, never `>=`: a bump means the payload keys or a field's meaning changed, so an
42
+ * off-by-any peer must refuse rather than guess. A false refusal costs one redeploy; a false
43
+ * accept risks wrong ownership or a wrong derived object on a real database.
44
+ */
45
+ export function checkStageModelContract(
46
+ health: unknown,
47
+ verb: string,
48
+ ): { ok: true } | { ok: false; reason: string } {
49
+ const advertised = (health as { modelContract?: unknown } | null | undefined)?.modelContract;
50
+ if (typeof advertised === 'number' && advertised === MODEL_WIRE_CONTRACT) return { ok: true };
51
+
52
+ if (advertised === undefined) {
53
+ return {
54
+ ok: false,
55
+ reason:
56
+ `the deployed stage does not advertise a Model-derived wire contract — its ops Lambda `
57
+ + `predates this handshake, so it would accept the ${verb} payload this CLI builds `
58
+ + `(model-wire v${MODEL_WIRE_CONTRACT}) without checking that it understands its shape. `
59
+ + `Deploy the stage first so its server matches this CLI; nothing was sent.`,
60
+ };
61
+ }
62
+
63
+ return {
64
+ ok: false,
65
+ reason:
66
+ `the deployed stage speaks model-wire contract v${String(advertised)} but this CLI speaks `
67
+ + `v${MODEL_WIRE_CONTRACT} — the CLI and the stage disagree about the shape of the `
68
+ + `Model-derived payload ${verb} sends. Deploy the stage (or align the CLI) so they match; `
69
+ + `nothing was sent.`,
70
+ };
71
+ }
72
+
73
+ /** A wire-contract refusal, raised instead of printed when the caller asked for `onRefusal: 'throw'`. */
74
+ export class ModelContractError extends Error {
75
+ constructor(message: string) {
76
+ super(message);
77
+ this.name = 'ModelContractError';
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The orchestration around that decision, shared by every command that dispatches a Model-derived
83
+ * payload to a stage: read `_health`, judge it, and exit on a refusal. One implementation and one
84
+ * wording, so `db:provision` and `db:reconcile` cannot drift into saying different things about
85
+ * the same skew.
86
+ *
87
+ * Returns a MEMOIZED runner. `db:provision` dispatches twice to the same stage (credentials, then
88
+ * `db:converge-ownership`), and one handshake covers both — but each dispatch still asks, so the
89
+ * guarantee is per-dispatch rather than a fact an earlier line has to remember to establish.
90
+ *
91
+ * An UNREACHABLE `_health` is deliberately NOT a refusal. A stage with no ops Lambda at all throws
92
+ * here, and that is a different failure with a much better message waiting further down (for
93
+ * `db:provision` it is also the path the ADMIN_DATABASE_URL auto-fallback exists for). Absence of
94
+ * evidence is not evidence of skew. A stage that ANSWERS and names no contract IS evidence, and
95
+ * refuses.
96
+ *
97
+ * `directCommand` is the venue that crosses no wire — the CLI resolves the stage's operator
98
+ * connection itself and runs the work locally — so a refused operator is left with somewhere to go.
99
+ * `directHint` carries whatever is true of that venue for THIS command only; sharing one runner
100
+ * must not cost a caller the specific thing its own operator needed to read.
101
+ *
102
+ * `onRefusal` decides WHO renders the refusal, and the answer belongs to the CALLER, because
103
+ * only the caller knows whether anything has been written yet. A call site that runs BEFORE any
104
+ * write — which is every original one — wants printing and exiting: the refusal already says the
105
+ * stage and the CLI disagree, that nothing was sent, and where the `--direct` venue is. That is
106
+ * `'exit'`. A call site that runs AFTER a write — `db:apply`'s
107
+ * function/view catch-up, once the table and column changes have committed — needs one specific
108
+ * sentence first: your changes are safe, here is what to rerun. A `process.exit` from in here
109
+ * would end the process before that could be said, so that caller asks for `'throw'`.
110
+ *
111
+ * Note the shape: the CALLER passes it. A callee that hardcodes one of the two answers on behalf
112
+ * of callers with opposite post-conditions is the bug this parameter exists to prevent.
113
+ */
114
+ export function modelContractPreflight(opts: {
115
+ region: string;
116
+ fn: string;
117
+ directCommand: string;
118
+ /** Command-specific detail about `--direct`, appended to the hint. Omit for the generic line. */
119
+ directHint?: string;
120
+ /** REQUIRED. `'exit'` prints and exits. `'throw'` hands the caller a `ModelContractError`. */
121
+ onRefusal: 'exit' | 'throw';
122
+ }): (verb: string) => Promise<void> {
123
+ let verified = false;
124
+ return async (verb: string): Promise<void> => {
125
+ if (verified) return;
126
+ step("Verifying the deployed stage speaks this CLI's Model-derived wire contract...");
127
+ const health = await invokeAction(opts.region, opts.fn, '_health', {}).catch(() => null);
128
+ if (health === null) return;
129
+ const verdict = checkStageModelContract(health, verb);
130
+ if (!verdict.ok) {
131
+ // One wording for both exits — a thrown refusal and a printed one must never drift.
132
+ const directLine =
133
+ `The --direct venue crosses no wire (the CLI resolves the stage's operator connection `
134
+ + `internally and runs the work itself): ${opts.directCommand}`
135
+ + (opts.directHint ? ` — ${opts.directHint}` : '');
136
+ if (opts.onRefusal === 'throw') throw new ModelContractError(`${verdict.reason} ${directLine}`);
137
+ fail(`REFUSED: ${verdict.reason}`);
138
+ info(directLine);
139
+ process.exit(1);
140
+ }
141
+ verified = true;
142
+ };
143
+ }
package/src/cli/output.ts CHANGED
@@ -47,3 +47,15 @@ function say(line: string): void {
47
47
  if (stdoutReserved) console.error(line);
48
48
  else console.log(line);
49
49
  }
50
+
51
+ /**
52
+ * A line printed EXACTLY as given \u2014 no marker, no indent of ours.
53
+ *
54
+ * The default reports (plain-output.ts) own their own shape: a header, a blank line, indented
55
+ * change lines, a verdict. `info` would prepend two spaces to every one of them and turn a
56
+ * designed block into a ragged one. Everything else about `say` still applies, so `--out -`
57
+ * keeps moving the report off stdout.
58
+ */
59
+ export function line(text: string): void {
60
+ say(text);
61
+ }