@everystack/cli 0.4.78 → 0.4.80
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/CHANGELOG.md +49 -4
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +40 -17
- package/src/cli/authz-contract.ts +68 -2
- package/src/cli/authz-reconcile.ts +68 -2
- package/src/cli/commands/db-check.ts +23 -2
- package/src/cli/commands/db-plan.ts +17 -11
- package/src/cli/commands/db-pull.ts +31 -0
- package/src/cli/edge-plan.ts +219 -28
- package/src/cli/git-descent.ts +4 -3
- package/src/cli/index.ts +1 -6
- package/src/cli/migration-generate.ts +37 -3
- package/src/cli/schema-diff.ts +14 -4
- package/src/cli/schema-fingerprint.ts +12 -9
- package/src/cli/adopt.ts +0 -58
- package/src/cli/commands/db-adopt.ts +0 -243
- package/src/cli/live-models.ts +0 -65
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.80] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- apply-contract v3 — the default-privilege lock is a server-run gate; redeploy the stage before the first staged db:apply after upgrading
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- empty-edge summary names the ungoverned grantees, not 35 tables
|
|
16
|
+
- carryUngovernedAuthz — the full-prediction merge helper (N3 step 1)
|
|
17
|
+
- predictLiveState carryUngoverned — the full endpoint flavor (N3 step 2)
|
|
18
|
+
- the plan `to` is the full endpoint, verified and disclosed (N3 steps 3-5)
|
|
19
|
+
- a plan-created serial sequence is granted in the same plan
|
|
20
|
+
- the plan locks the default-privilege posture it predicted from
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- the full endpoint carries default privileges onto plan-created objects
|
|
25
|
+
- bump apply-contract to v2 for the full endpoint; sharpen the empty-edge summary
|
|
26
|
+
- db:check fails on a named artifact path with nothing at it
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
- the plan `to` flavor; test: condition (a) drop end-to-end (N3 step 6)
|
|
31
|
+
- every db verb has a reference entry, and a test says so
|
|
32
|
+
- the disclosed gap names what actually compiles, and a test pins it
|
|
33
|
+
- the last two rollback-point sentences say what the code does; the retargeting edge is named
|
|
34
|
+
|
|
35
|
+
## [0.4.79] - 2026-09-07
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- remove db:adopt — its proof was "would db:plan be empty", and db:plan answers that on the review surface
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- db:pull ends with the on-ramp runbook; docs and help say the on-ramp is pull, commit, plan
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- closing-gate round 1 — the as-is lane runs the shipped db:pull, the runbook is gated on --abilities live, the fixture proves live derivation, live-models.ts is gone
|
|
48
|
+
|
|
7
49
|
## [0.4.78] - 2026-09-07
|
|
8
50
|
|
|
9
51
|
### Fixed
|
|
@@ -941,6 +983,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
941
983
|
|
|
942
984
|
## [0.2.37] - 2026-06-17
|
|
943
985
|
|
|
986
|
+
### Removed
|
|
987
|
+
|
|
988
|
+
- remove onMutation callback — events replaces it
|
|
989
|
+
- remove unused @everystack/jobs devDependency
|
|
990
|
+
- remove secret auto-injection, skip SSR check for web-only updates
|
|
991
|
+
- remove media asset pruning from update command
|
|
992
|
+
|
|
944
993
|
### Added
|
|
945
994
|
|
|
946
995
|
- rename @everystack/updates → @everystack/cli, add db:migrate/db:seed, remove AdminSecret
|
|
@@ -992,7 +1041,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
992
1041
|
### Fixed
|
|
993
1042
|
|
|
994
1043
|
- normalize cache:purge --path to strip query params and ensure leading slash
|
|
995
|
-
- remove unused @everystack/jobs devDependency
|
|
996
1044
|
- include updateId in register-web payload
|
|
997
1045
|
- psql Lambda-only queries, composite key notify, logging defaults
|
|
998
1046
|
- add AWS KVS + SigV4a as direct dependencies
|
|
@@ -1011,14 +1059,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
1011
1059
|
- drop GetFunctionConfiguration from log group resolution
|
|
1012
1060
|
- detect export completion to prevent static pre-rendering hang
|
|
1013
1061
|
- rewrite secrets for SST Ion, filter EXPO_PUBLIC_* only
|
|
1014
|
-
- remove secret auto-injection, skip SSR check for web-only updates
|
|
1015
1062
|
- clear Metro transform cache on export
|
|
1016
1063
|
- worker function discovery and log group resolution for SQS subscribers
|
|
1017
1064
|
- replace infrastructure headers with single X-Server-Debug timestamp
|
|
1018
1065
|
- version bumps and publish fixes for cli, server, jobs
|
|
1019
1066
|
- invalidate stale config cache on schema change
|
|
1020
1067
|
- fall back to outputs.json for custom-named media buckets
|
|
1021
|
-
- remove media asset pruning from update command
|
|
1022
1068
|
- media-sync test uses same minimatch interop as source
|
|
1023
1069
|
- discover the Router distribution by domain and SST tags
|
|
1024
1070
|
- use minimatch's named export — the package ships types now
|
|
@@ -1027,7 +1073,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
1027
1073
|
|
|
1028
1074
|
### Changed
|
|
1029
1075
|
|
|
1030
|
-
- remove onMutation callback — events replaces it
|
|
1031
1076
|
- migrate all package schemas to everyschema canonical tables
|
|
1032
1077
|
- lazy-load AWS SDK imports for faster CLI startup
|
|
1033
1078
|
- drop type module from all packages, switch Jest to CJS mode
|
package/package.json
CHANGED
package/src/cli/apply-execute.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
COLUMN_GRANTS_SQL,
|
|
39
39
|
EFFECTIVE_PRIVILEGES_SQL,
|
|
40
40
|
SCHEMA_ACL_SQL,
|
|
41
|
-
SEQUENCE_ACLS_SQL,
|
|
41
|
+
SEQUENCE_ACLS_SQL, DEFAULT_ACL_SQL,
|
|
42
42
|
type QueryRunner,
|
|
43
43
|
type AuthzContract,
|
|
44
44
|
} from './authz-contract';
|
|
@@ -56,10 +56,9 @@ import {
|
|
|
56
56
|
} from './schema-introspect';
|
|
57
57
|
import { FUNCTIONS_SQL, contractFunctionRow } from './security-catalog';
|
|
58
58
|
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
59
|
-
import { governedLiveFingerprint } from './edge-plan';
|
|
60
59
|
import { applyGeneratedStatements } from './state-apply';
|
|
61
60
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
62
|
-
import { checkPlanPrecondition, planHash, type EdgePlan } from './edge-plan';
|
|
61
|
+
import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
|
|
63
62
|
import type { DescentLedgerRow } from './git-descent';
|
|
64
63
|
|
|
65
64
|
/**
|
|
@@ -255,6 +254,9 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
255
254
|
[
|
|
256
255
|
COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL,
|
|
257
256
|
RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, FUNCTIONS_SQL,
|
|
257
|
+
// The default-privilege posture the plan's `defaultAclHash` is checked against — read in
|
|
258
|
+
// the SAME session as the lock's state, so the two gates describe one instant.
|
|
259
|
+
DEFAULT_ACL_SQL,
|
|
258
260
|
{ sql: SCHEMA_LOG_WITNESS_SQL, allowFailure: true },
|
|
259
261
|
],
|
|
260
262
|
INTROSPECTION_SESSION,
|
|
@@ -270,8 +272,9 @@ export async function liveBaseAndLedger(session: SessionRunner): Promise<LiveBas
|
|
|
270
272
|
columnGrants: results[9] as any[], effectivePrivileges: results[10] as any[],
|
|
271
273
|
schemaAcls: results[11] as any[], sequenceAcls: results[12] as any[],
|
|
272
274
|
functions: (results[13] as any[]).map(contractFunctionRow),
|
|
275
|
+
defaultAcls: results[14] as any[],
|
|
273
276
|
});
|
|
274
|
-
const ledger = mapLedgerRows(rowsOrEmpty(results[
|
|
277
|
+
const ledger = mapLedgerRows(rowsOrEmpty(results[15]));
|
|
275
278
|
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract, ledger };
|
|
276
279
|
}
|
|
277
280
|
|
|
@@ -284,15 +287,17 @@ export async function executeApplyPlan(
|
|
|
284
287
|
): Promise<ApplyPlanResult> {
|
|
285
288
|
const { hash: live, snapshot: before, contract: beforeAuthz, ledger } = await liveBaseAndLedger(session);
|
|
286
289
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// the
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
290
|
+
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
291
|
+
// the RAW live hash directly — the same flavor as `from`. A plan without that flavor predates
|
|
292
|
+
// the full endpoint (N3, milestone plan-endpoint-full): its `to` is a governed hash, and
|
|
293
|
+
// verifying the full live against it would false-fail AFTER the DDL landed. Refuse and re-mint.
|
|
294
|
+
if (plan.toFlavor !== 'full') {
|
|
295
|
+
const reason = 'this plan predates the full endpoint — its `to` is a governed fingerprint. Re-mint with db:plan.';
|
|
296
|
+
await recordRefusal(runner, plan, live, 'plan flavor', reason, options);
|
|
297
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
298
|
+
}
|
|
294
299
|
|
|
295
|
-
if (
|
|
300
|
+
if (live === plan.toFingerprint) {
|
|
296
301
|
return { status: 'already-applied', liveFingerprint: live };
|
|
297
302
|
}
|
|
298
303
|
const pre = checkPlanPrecondition(plan, live);
|
|
@@ -301,6 +306,24 @@ export async function executeApplyPlan(
|
|
|
301
306
|
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
302
307
|
}
|
|
303
308
|
|
|
309
|
+
// The lock covers object state. The default-privilege posture is not object state — `from`
|
|
310
|
+
// never hashed it — yet the FULL prediction consumed it for every object this plan creates.
|
|
311
|
+
// Compare it BEFORE any write: an ALTER DEFAULT PRIVILEGES since minting used to pass the
|
|
312
|
+
// lock, land the DDL, and fail verify-after post-DDL. A plan without the field predates the
|
|
313
|
+
// gate and cannot be checked; plans are minutes old, so refusing costs one re-mint.
|
|
314
|
+
if (plan.defaultAclHash === undefined) {
|
|
315
|
+
const reason = 'this plan predates the default privileges lock — it carries no defaultAclHash. Re-mint with db:plan.';
|
|
316
|
+
await recordRefusal(runner, plan, live, 'default privileges', reason, options);
|
|
317
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
318
|
+
}
|
|
319
|
+
if (defaultAclHash(beforeAuthz) !== plan.defaultAclHash) {
|
|
320
|
+
const reason =
|
|
321
|
+
'the default-privilege posture (ALTER DEFAULT PRIVILEGES) moved since minting — the plan predicted its endpoint ' +
|
|
322
|
+
'from the old one, so the objects it creates would land with grants it did not carry. Re-mint with db:plan.';
|
|
323
|
+
await recordRefusal(runner, plan, live, 'default privileges', reason, options);
|
|
324
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
if (options.checkDescent) {
|
|
305
328
|
const descent = await options.checkDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live, ledger });
|
|
306
329
|
if (!descent.ok) {
|
|
@@ -347,19 +370,19 @@ export async function executeApplyPlan(
|
|
|
347
370
|
...(options.descentNote ? { descentNote: options.descentNote } : {}),
|
|
348
371
|
});
|
|
349
372
|
|
|
350
|
-
const { hash: landed
|
|
373
|
+
const { hash: landed } = await liveBaseFingerprint(session);
|
|
351
374
|
if (result.logId !== undefined) {
|
|
352
|
-
// The log stamps the RAW landed hash — the same flavor as `from`, so the
|
|
375
|
+
// The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
|
|
353
376
|
// schema_log chain reads as one consistent series of live states.
|
|
354
377
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
355
378
|
}
|
|
356
379
|
|
|
357
|
-
|
|
358
|
-
if (
|
|
380
|
+
// The full `to` equals the post-apply raw live hash, so verify-after compares them directly.
|
|
381
|
+
if (landed !== plan.toFingerprint) {
|
|
359
382
|
return {
|
|
360
383
|
status: 'verify-failed',
|
|
361
384
|
liveFingerprint: landed,
|
|
362
|
-
reason: `applied, but the target landed on ${
|
|
385
|
+
reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
363
386
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
364
387
|
};
|
|
365
388
|
}
|
|
@@ -357,6 +357,15 @@ export interface AuthzContract {
|
|
|
357
357
|
* it did not read.
|
|
358
358
|
*/
|
|
359
359
|
sequenceGrants?: SequenceGrantContract[];
|
|
360
|
+
/**
|
|
361
|
+
* Default privileges (`pg_default_acl`) that fire for objects the current role CREATES —
|
|
362
|
+
* `{ tables, sequences }`, each schema (`''` = all schemas) → grantee → sorted privileges. The
|
|
363
|
+
* FULL prediction folds the ungoverned slice onto plan-created objects: Postgres auto-grants
|
|
364
|
+
* them at creation, so a `to` endpoint without them false-verify-fails a successful CREATE
|
|
365
|
+
* apply on a brownfield BI stage. Absent means NOT MEASURED (older recorder, hand-built
|
|
366
|
+
* fixture) — the prediction then models no default ACL, as before.
|
|
367
|
+
*/
|
|
368
|
+
defaultAcls?: { tables: DefaultAclScope; sequences: DefaultAclScope };
|
|
360
369
|
}
|
|
361
370
|
|
|
362
371
|
/**
|
|
@@ -682,6 +691,59 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
|
|
|
682
691
|
return [...bySequence.values()].sort((a, b) => a.sequence.localeCompare(b.sequence));
|
|
683
692
|
}
|
|
684
693
|
|
|
694
|
+
/**
|
|
695
|
+
* The default privileges (`ALTER DEFAULT PRIVILEGES`) that fire for TABLES and SEQUENCES the
|
|
696
|
+
* CURRENT role creates — so the prediction can model what a plan-CREATED object will carry.
|
|
697
|
+
*
|
|
698
|
+
* `pg_default_acl` is keyed by (defaclrole, defaclnamespace, defaclobjtype). An object created by
|
|
699
|
+
* role R in schema S receives the ACL for (R, S) AND for (R, all-schemas). We read the current
|
|
700
|
+
* role's rows only: everystack applies as one operator, so the creator IS the introspector. A
|
|
701
|
+
* NULL `defaclnamespace` means "every schema" — reported as an empty schema string so the
|
|
702
|
+
* resolver can union it with any specific schema. Objtype 'r' is a table, 'S' a sequence.
|
|
703
|
+
*/
|
|
704
|
+
export const DEFAULT_ACL_SQL = `
|
|
705
|
+
SELECT
|
|
706
|
+
COALESCE(n.nspname, '') AS schema,
|
|
707
|
+
d.defaclobjtype AS objtype,
|
|
708
|
+
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
709
|
+
a.privilege_type AS privilege
|
|
710
|
+
FROM pg_default_acl d
|
|
711
|
+
LEFT JOIN pg_namespace n ON n.oid = d.defaclnamespace
|
|
712
|
+
CROSS JOIN LATERAL aclexplode(d.defaclacl) a
|
|
713
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
714
|
+
WHERE d.defaclrole = (SELECT oid FROM pg_roles WHERE rolname = current_user)
|
|
715
|
+
AND d.defaclobjtype IN ('r', 'S')
|
|
716
|
+
AND (n.nspname IS NULL OR (n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'))
|
|
717
|
+
ORDER BY 1, 2, 3, 4;
|
|
718
|
+
`.trim();
|
|
719
|
+
|
|
720
|
+
export interface DefaultAclRow {
|
|
721
|
+
schema: unknown;
|
|
722
|
+
/** 'r' = table, 'S' = sequence. */
|
|
723
|
+
objtype: unknown;
|
|
724
|
+
grantee: unknown;
|
|
725
|
+
privilege: unknown;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** schema (`''` = all schemas) → grantee → sorted privileges. */
|
|
729
|
+
export type DefaultAclScope = Record<string, Record<string, string[]>>;
|
|
730
|
+
|
|
731
|
+
/** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
|
|
732
|
+
export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
|
|
733
|
+
const tables: DefaultAclScope = {};
|
|
734
|
+
const sequences: DefaultAclScope = {};
|
|
735
|
+
for (const row of rows) {
|
|
736
|
+
if (row.grantee == null || row.privilege == null) continue;
|
|
737
|
+
const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
|
|
738
|
+
if (!scope) continue;
|
|
739
|
+
const schema = String(row.schema ?? '');
|
|
740
|
+
const grantee = String(row.grantee);
|
|
741
|
+
const byGrantee = scope[schema] ?? (scope[schema] = {});
|
|
742
|
+
byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
|
|
743
|
+
}
|
|
744
|
+
return { tables, sequences };
|
|
745
|
+
}
|
|
746
|
+
|
|
685
747
|
/** The two privileges a schema ACL can carry, in aclitem letter form. */
|
|
686
748
|
const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
|
|
687
749
|
|
|
@@ -872,6 +934,8 @@ export interface ContractRows {
|
|
|
872
934
|
schemaAcls?: SchemaAclRow[];
|
|
873
935
|
/** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
|
|
874
936
|
sequenceAcls?: SequenceAclRow[];
|
|
937
|
+
/** DEFAULT_ACL_SQL. Optional — absent means not measured, never "no default privileges". */
|
|
938
|
+
defaultAcls?: DefaultAclRow[];
|
|
875
939
|
/** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
|
|
876
940
|
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean }[];
|
|
877
941
|
}
|
|
@@ -966,6 +1030,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
966
1030
|
...(schemaAcls ? { schemaAcls } : {}),
|
|
967
1031
|
// Same absent-means-unmeasured contract as schemaAcls above.
|
|
968
1032
|
...(rows.sequenceAcls ? { sequenceGrants: assembleSequenceGrants(rows.sequenceAcls) } : {}),
|
|
1033
|
+
...(rows.defaultAcls ? { defaultAcls: assembleDefaultAcls(rows.defaultAcls) } : {}),
|
|
969
1034
|
};
|
|
970
1035
|
}
|
|
971
1036
|
|
|
@@ -978,8 +1043,8 @@ export async function introspectContract(
|
|
|
978
1043
|
// ONE session: the queries describe one moment under one pinned search_path.
|
|
979
1044
|
// Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
|
|
980
1045
|
// across connections can report authz drift that does not exist.
|
|
981
|
-
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, fnRows] = await session(
|
|
982
|
-
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
|
|
1046
|
+
const [rls, policies, grants, columnGrants, effectivePrivileges, schemaAcls, sequenceAcls, defaultAcls, fnRows] = await session(
|
|
1047
|
+
[RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, EFFECTIVE_PRIVILEGES_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, DEFAULT_ACL_SQL, functionsSql],
|
|
983
1048
|
INTROSPECTION_SESSION,
|
|
984
1049
|
);
|
|
985
1050
|
return assembleContract({
|
|
@@ -990,6 +1055,7 @@ export async function introspectContract(
|
|
|
990
1055
|
effectivePrivileges: effectivePrivileges as EffectivePrivilegeRow[],
|
|
991
1056
|
schemaAcls: schemaAcls as SchemaAclRow[],
|
|
992
1057
|
sequenceAcls: sequenceAcls as SequenceAclRow[],
|
|
1058
|
+
defaultAcls: defaultAcls as DefaultAclRow[],
|
|
993
1059
|
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
994
1060
|
});
|
|
995
1061
|
}
|
|
@@ -89,6 +89,61 @@ export function ungovernedGrants(live: AuthzContract, governed: ReadonlySet<stri
|
|
|
89
89
|
return out.sort((a, b) => a.grantee.localeCompare(b.grantee) || a.table.localeCompare(b.table));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A declared table's authz AFTER the edge lands — the FULL prediction's per-table building block.
|
|
94
|
+
*
|
|
95
|
+
* The reconciler converges the GOVERNED roles onto the declared contract and leaves every other
|
|
96
|
+
* (foreign) grantee alone. So the post-apply truth for a declared table is: the declared contract
|
|
97
|
+
* PLUS the live authz of the ungoverned roles. The governed prediction drops that second half; on
|
|
98
|
+
* a brownfield stage the foreign grants live on, so a `to` endpoint without them can never equal
|
|
99
|
+
* the post-apply full live hash (milestone plan-endpoint-full).
|
|
100
|
+
*
|
|
101
|
+
* Carried ONLY onto objects the declared target still has (condition (a) of the N2 ruling):
|
|
102
|
+
* - table-level grants ride whole — the table is present by construction;
|
|
103
|
+
* - a column grant rides only for the columns in `presentColumns`; a grant on a column the edge
|
|
104
|
+
* DROPS is gone after apply, so carrying it would predict an endpoint the apply cannot reach —
|
|
105
|
+
* a false verify-failed after the DDL already landed;
|
|
106
|
+
* - a policy rides only when EVERY role it names is ungoverned. A policy touching a governed role
|
|
107
|
+
* is the reconciler's to manage, so it is not carried here (if the reconciler leaves it, the
|
|
108
|
+
* verify-after fails LOUD rather than being hidden).
|
|
109
|
+
*
|
|
110
|
+
* The declared (governed) grants always win over any live grant to the same role: a governed role
|
|
111
|
+
* whose live grant the models dropped must be REVOKED by the plan, never carried through.
|
|
112
|
+
*/
|
|
113
|
+
export function carryUngovernedAuthz(
|
|
114
|
+
declared: TableContract,
|
|
115
|
+
live: TableContract,
|
|
116
|
+
governed: ReadonlySet<string>,
|
|
117
|
+
presentColumns: ReadonlySet<string>,
|
|
118
|
+
): TableContract {
|
|
119
|
+
const foreign = (role: string): boolean => !isGoverned(governed, role);
|
|
120
|
+
|
|
121
|
+
const grants: Record<string, string[]> = { ...declared.grants };
|
|
122
|
+
for (const [role, privileges] of Object.entries(live.grants)) {
|
|
123
|
+
if (foreign(role)) grants[role] = [...privileges];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const columnGrants: Record<string, Record<string, string[]>> = { ...(declared.columnGrants ?? {}) };
|
|
127
|
+
for (const [role, byPrivilege] of Object.entries(live.columnGrants ?? {})) {
|
|
128
|
+
if (!foreign(role)) continue;
|
|
129
|
+
const kept: Record<string, string[]> = {};
|
|
130
|
+
for (const [privilege, columns] of Object.entries(byPrivilege)) {
|
|
131
|
+
const present = columns.filter((c) => presentColumns.has(c));
|
|
132
|
+
if (present.length > 0) kept[privilege] = present;
|
|
133
|
+
}
|
|
134
|
+
if (Object.keys(kept).length > 0) columnGrants[role] = kept;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const carriedPolicies = live.policies.filter((p) => p.roles.length > 0 && p.roles.every(foreign));
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...declared,
|
|
141
|
+
grants,
|
|
142
|
+
...(Object.keys(columnGrants).length > 0 ? { columnGrants } : {}),
|
|
143
|
+
policies: [...declared.policies, ...carriedPolicies],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
/**
|
|
93
148
|
* The exemption report, one line per grantee — the shared rendering every surface prints.
|
|
94
149
|
*
|
|
@@ -307,7 +362,18 @@ function reconcileSequenceGrants(
|
|
|
307
362
|
export function emitReconcileSql(
|
|
308
363
|
declared: AuthzContract,
|
|
309
364
|
live: AuthzContract,
|
|
310
|
-
opts: {
|
|
365
|
+
opts: {
|
|
366
|
+
governedRoles?: ReadonlySet<string>;
|
|
367
|
+
/**
|
|
368
|
+
* The serial sequences the SAME plan's data phase CREATES (a new serial table, a serial
|
|
369
|
+
* column added to an existing one) — reconciled as if live with no grants. Without them a
|
|
370
|
+
* created sequence got no governed grant until the NEXT plan: db:apply verified green and
|
|
371
|
+
* every INSERT by the governed holder failed `permission denied for sequence` for one plan
|
|
372
|
+
* cycle (milestone finish-0-4-x F1). They are emitted even when the live side never read
|
|
373
|
+
* sequences: the plan creates these, so there is no unread live state to misjudge.
|
|
374
|
+
*/
|
|
375
|
+
createdSequences?: readonly SequenceGrantContract[];
|
|
376
|
+
} = {},
|
|
311
377
|
): string[] {
|
|
312
378
|
// Condition C — two phases PER TABLE in one transaction: every access-REMOVING statement
|
|
313
379
|
// (policy DROP, REVOKE, RLS ENABLE/FORCE) before every access-ADDING one (policy CREATE, GRANT,
|
|
@@ -325,7 +391,7 @@ export function emitReconcileSql(
|
|
|
325
391
|
// Absent = the recorder never read sequences; skip the pass entirely rather than diff
|
|
326
392
|
// against a set nobody measured. Grouped once, not re-scanned per table.
|
|
327
393
|
const sequencesByTable = new Map<string, SequenceGrantContract[]>();
|
|
328
|
-
for (const seq of live.sequenceGrants ?? []) {
|
|
394
|
+
for (const seq of [...(live.sequenceGrants ?? []), ...(opts.createdSequences ?? [])]) {
|
|
329
395
|
(sequencesByTable.get(seq.table) ?? sequencesByTable.set(seq.table, []).get(seq.table)!).push(seq);
|
|
330
396
|
}
|
|
331
397
|
|
|
@@ -80,6 +80,15 @@ export interface StaticCheckInput {
|
|
|
80
80
|
artifactPath: string;
|
|
81
81
|
/** null = no generated schema artifact on disk. */
|
|
82
82
|
artifactSource: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* Where `artifactPath` came from — the same resolution db:generate records. An ABSENT artifact
|
|
85
|
+
* is only "nothing to match" at the untouched default (an app that never generated one). A path
|
|
86
|
+
* the operator named with `--schema-out`, or one the journal recorded, is a claim that an
|
|
87
|
+
* artifact lives there; its absence is the artifact gate vanishing, and must FAIL. Measured:
|
|
88
|
+
* `db:check --schema-out db/does-not-exist.ts` printed the note and passed with exit 0.
|
|
89
|
+
* Absent = default (older callers).
|
|
90
|
+
*/
|
|
91
|
+
artifactOrigin?: 'flag' | 'recorded' | 'default';
|
|
83
92
|
/** Set when a retired db/sql directory still carries .sql files — the tombstone
|
|
84
93
|
* message (with the migration path); a hard fail, never a shrug. */
|
|
85
94
|
sqlDirRetired?: string | null;
|
|
@@ -250,7 +259,19 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
|
|
253
|
-
if (input.artifactSource === null) {
|
|
262
|
+
if (input.artifactSource === null && input.artifactOrigin === 'flag') {
|
|
263
|
+
findings.push({
|
|
264
|
+
level: 'fail',
|
|
265
|
+
area: 'artifact',
|
|
266
|
+
message: `no generated schema at ${input.artifactPath} — the path came from --schema-out, so an artifact is expected there. Fix the path, or run db:generate to write it. A named path with nothing at it is a gate that guards nothing.`,
|
|
267
|
+
});
|
|
268
|
+
} else if (input.artifactSource === null && input.artifactOrigin === 'recorded') {
|
|
269
|
+
findings.push({
|
|
270
|
+
level: 'fail',
|
|
271
|
+
area: 'artifact',
|
|
272
|
+
message: `no generated schema at ${input.artifactPath} — the migration journal records that path as the artifact (db:generate wrote it there). Restore it, run db:generate, or re-record with --schema-out.`,
|
|
273
|
+
});
|
|
274
|
+
} else if (input.artifactSource === null) {
|
|
254
275
|
findings.push({
|
|
255
276
|
level: 'note',
|
|
256
277
|
area: 'artifact',
|
|
@@ -393,7 +414,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
|
|
|
393
414
|
} catch { /* no baseline — see above */ }
|
|
394
415
|
|
|
395
416
|
const findings = runStaticChecks({
|
|
396
|
-
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, sqlDirRetired,
|
|
417
|
+
modelsPath, models, modelsError, modelFileScans, extensions, artifactPath, artifactSource, artifactOrigin: artifactRes.source, sqlDirRetired,
|
|
397
418
|
sequences: declaredDb?.sequences, derived: declaredDb?.derived, derivedError,
|
|
398
419
|
moduleModels: declaredDb?.models ?? null,
|
|
399
420
|
baselineSource,
|
|
@@ -49,7 +49,7 @@ import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport } fr
|
|
|
49
49
|
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
|
-
import { mintEdgePlan, planHash, buildPlanSummary } from '../edge-plan';
|
|
52
|
+
import { mintEdgePlan, planHash, buildPlanSummary, explainCarriedBy } from '../edge-plan';
|
|
53
53
|
import { proveDescent, describeDriftOrigin, proveLineageLost, realLineageProbe, type ProveVerdict, type LineageProof } from '../git-descent';
|
|
54
54
|
import { fingerprintLive, divergentSurfaces } from '../schema-fingerprint';
|
|
55
55
|
import { predictLiveState } from '../edge-plan';
|
|
@@ -337,16 +337,22 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
337
337
|
await fs.writeFile(out, body, 'utf8');
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
// An EMPTY edge
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
340
|
+
// An EMPTY edge still has a story to tell, and the two cases tell DIFFERENT ones:
|
|
341
|
+
// - `from == to`: the full endpoint carried the foreign grants through, so the plan is empty
|
|
342
|
+
// AND equal. Name the ungoverned roles (condition (c)) — the operator must still see what
|
|
343
|
+
// rides untouched and under which baseline.
|
|
344
|
+
// - `from != to`: the FULL prediction failed to reproduce raw live — a real, unexplained
|
|
345
|
+
// divergence. The foreign grants are ABSORBED into `to`, so they are NOT the cause; naming
|
|
346
|
+
// them would misdirect. Name the divergent surface of the FULL prediction vs live instead.
|
|
347
|
+
const emptyEdge = plan.statements.length === 0;
|
|
348
|
+
const carriedBy = !emptyEdge
|
|
349
|
+
? undefined
|
|
350
|
+
: plan.fromFingerprint === plan.toFingerprint
|
|
351
|
+
? explainCarriedBy(exemptions, [])
|
|
352
|
+
: divergentSurfaces(
|
|
353
|
+
predictLiveState(models, snapshot, contract, { governedRoles: governedRoleSet(declaredAuthz, declaredGovernedRoles), carryUngoverned: true }).canonical,
|
|
354
|
+
fingerprintLive(snapshot, contract).canonical,
|
|
355
|
+
);
|
|
350
356
|
for (const line of buildPlanSummary(plan, carriedBy ? { carriedBy } : {})) info(line);
|
|
351
357
|
info(`plan_ref: ${planHash(plan).slice(0, 12)}`);
|
|
352
358
|
|
|
@@ -38,6 +38,36 @@ import { fingerprintLive } from '../schema-fingerprint';
|
|
|
38
38
|
import { ungovernedGrants, vestigialSequenceGrants, renderVestigialSequenceGrants, ALWAYS_GOVERNED, type GrantExemption } from '../authz-reconcile';
|
|
39
39
|
import { buildStageBaseline, mergeBaseline, readBaselineFile, writeBaselineFile, BASELINE_FILE } from '../authz-baseline';
|
|
40
40
|
import { introspectDerived, type DerivedCatalog } from '../derived-introspect';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The on-ramp runbook, printed last so the operator is never left guessing what a pull is FOR.
|
|
44
|
+
*
|
|
45
|
+
* db:pull is the only way an existing database comes in. What happens next is decided by
|
|
46
|
+
* db:plan, not by a verb: an EMPTY plan means the committed Models declare the database as it
|
|
47
|
+
* is (adopted — descent proves the pull commit by recompile, and every later apply descends from
|
|
48
|
+
* it); a NON-EMPTY plan is the hardening edge, reviewed and applied as the origin. db:adopt used
|
|
49
|
+
* to bolt a proof onto this render; the proof was "would the plan be empty", so the verb is gone
|
|
50
|
+
* (cli 0.4.79) and this is what replaced it. Exported so a test pins the lines and the
|
|
51
|
+
* refusal-runbook lint reads them.
|
|
52
|
+
*/
|
|
53
|
+
export function pullOnRampRunbook(opts: { stage?: string; out?: string; abilities?: string }): string[] {
|
|
54
|
+
const target = opts.stage ? `--stage ${opts.stage}` : '--stage <name>';
|
|
55
|
+
const what = opts.out ? `the files in ${opts.out}` : 'these Models';
|
|
56
|
+
// Only a LIVE pull transcribes the database's own authz; only then can an empty plan mean
|
|
57
|
+
// "adopted as it is". A commented or public-read pull carries authored abilities — its plan is
|
|
58
|
+
// an edge to review (public-read on an existing database GRANTS public read of every table),
|
|
59
|
+
// never an adoption, and the runbook must not call it one (closing-gate review, 2026-09-07).
|
|
60
|
+
if (opts.abilities !== 'live') {
|
|
61
|
+
return [
|
|
62
|
+
`Next: author each model's abilities (db:check fails until every model declares) — for an EXISTING database, re-pull with --abilities live to derive them from the grants and policies already there. Then commit ${what} and run \`everystack db:plan ${target}\`: the plan is an edge to review, not an adoption of what is live.`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
`Next: commit ${what}, then \`everystack db:plan ${target}\`.`,
|
|
67
|
+
'EMPTY plan = adopted as it is: that commit declares the database, and every later db:apply descends from it. Nothing to apply.',
|
|
68
|
+
`NON-EMPTY plan = the hardening edge: review it, then \`everystack db:apply --plan <file> ${target}\`. No commit declares this database yet, so the reviewed plan is the ORIGIN — db:apply proves that and records it.`,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
41
71
|
import { introspectContract, type TableContract, type AuthzContract } from '../authz-contract';
|
|
42
72
|
import { introspectTableOwners, buildOwnershipReport, renderOwnershipReport, type TableOwner } from '../authz-ownership';
|
|
43
73
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
@@ -461,5 +491,6 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
461
491
|
note(`Committing this file ADOPTS those roles. Anything not in it — or anything that grows beyond it — refuses at db:plan.`);
|
|
462
492
|
}
|
|
463
493
|
}
|
|
494
|
+
for (const line of pullOnRampRunbook({ stage: flags.stage, out: flags.out, abilities })) note(line);
|
|
464
495
|
process.exit(0);
|
|
465
496
|
}
|