@malloy-publisher/server 0.0.228 → 0.0.230

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.
Files changed (94) hide show
  1. package/dist/app/api-doc.yaml +102 -10
  2. package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
  3. package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
  6. package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
  7. package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
  8. package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
  9. package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
  11. package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
  12. package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
  13. package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/index-C_tJstcx.js +529 -0
  16. package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/package_load_worker.mjs +109 -8
  21. package/dist/server.mjs +7504 -80
  22. package/package.json +15 -13
  23. package/scripts/bake-duckdb-extensions.js +5 -2
  24. package/src/config.spec.ts +87 -1
  25. package/src/config.ts +142 -1
  26. package/src/controller/materialization.controller.spec.ts +9 -0
  27. package/src/controller/materialization.controller.ts +15 -0
  28. package/src/ducklake_version.spec.ts +163 -0
  29. package/src/ducklake_version.ts +153 -0
  30. package/src/errors.ts +12 -0
  31. package/src/malloy_pin_prereqs.spec.ts +25 -0
  32. package/src/materialization_metrics.ts +33 -0
  33. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  34. package/src/mcp/skills/skills_bundle.json +1 -1
  35. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  36. package/src/query_param_utils.ts +11 -0
  37. package/src/server.ts +74 -9
  38. package/src/service/authorize.spec.ts +22 -0
  39. package/src/service/authorize.ts +267 -10
  40. package/src/service/authorize_integration.spec.ts +1068 -26
  41. package/src/service/connection.spec.ts +79 -7
  42. package/src/service/connection.ts +312 -25
  43. package/src/service/connection_config.spec.ts +21 -0
  44. package/src/service/connection_config.ts +15 -1
  45. package/src/service/cron_evaluator.spec.ts +79 -0
  46. package/src/service/cron_evaluator.ts +105 -0
  47. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  48. package/src/service/environment.ts +156 -17
  49. package/src/service/environment_store.spec.ts +569 -14
  50. package/src/service/environment_store.ts +237 -31
  51. package/src/service/environment_store_anchoring.spec.ts +107 -0
  52. package/src/service/extension_fetch_policy.spec.ts +256 -0
  53. package/src/service/manifest_loader.spec.ts +3 -1
  54. package/src/service/manifest_loader.ts +9 -5
  55. package/src/service/materialization_scheduler.spec.ts +289 -0
  56. package/src/service/materialization_scheduler.ts +304 -0
  57. package/src/service/materialization_service.spec.ts +286 -5
  58. package/src/service/materialization_service.ts +121 -8
  59. package/src/service/materialization_test_fixtures.ts +10 -1
  60. package/src/service/model.spec.ts +67 -0
  61. package/src/service/model.ts +656 -31
  62. package/src/service/package.spec.ts +38 -0
  63. package/src/service/package.ts +102 -2
  64. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  65. package/src/service/persistence_policy.spec.ts +31 -0
  66. package/src/service/quoting.spec.ts +57 -1
  67. package/src/service/quoting.ts +33 -0
  68. package/src/storage/DatabaseInterface.ts +15 -0
  69. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  70. package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
  71. package/src/storage/duckdb/MaterializationRepository.ts +68 -0
  72. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  73. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  74. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  75. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  76. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  77. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  78. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  79. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  80. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  81. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  82. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  83. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  84. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  85. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  86. package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
  87. package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
  88. package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
  89. package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
  90. package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
  91. package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
  92. package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
  93. package/dist/app/assets/index-DDMrjIT3.js +0 -18
  94. package/dist/app/assets/index-EqslXZ44.js +0 -527
@@ -24,6 +24,14 @@ CREATE TABLE IF NOT EXISTS customers (
24
24
  region VARCHAR
25
25
  );
26
26
  INSERT INTO customers VALUES (1, 'a', 'us-west'), (2, 'b', 'us-east');
27
+
28
+ CREATE TABLE IF NOT EXISTS nested_cols (
29
+ id INTEGER,
30
+ rec STRUCT(a INTEGER),
31
+ arr INTEGER[],
32
+ arr_rec STRUCT(b INTEGER)[]
33
+ );
34
+ INSERT INTO nested_cols VALUES (1, {'a': 1}, [1, 2, 3], [{'b': 2}]);
27
35
  `;
28
36
 
29
37
  function getConnections(): Map<string, Connection> {
@@ -383,31 +391,33 @@ source: gated is duckdb.table('customers')
383
391
  });
384
392
  });
385
393
 
386
- describe("authorize runtime gate", () => {
387
- // Helper: run an ad-hoc query through the full getQueryResults path (which
388
- // is where the gate fires). Returns the result or throws AccessDeniedError.
389
- async function runGated(
390
- modelFile: string,
391
- query: string,
392
- givens?: Record<string, GivenValue>,
393
- bypassFilters?: boolean,
394
- ) {
395
- const model = await Model.create(
396
- "test-pkg",
397
- TEST_PKG_DIR,
398
- modelFile,
399
- getConnections(),
400
- );
401
- return model.getQueryResults(
402
- undefined,
403
- undefined,
404
- query,
405
- undefined,
406
- bypassFilters,
407
- givens,
408
- );
409
- }
394
+ // Helper: run an ad-hoc query through the full getQueryResults path (which is
395
+ // where the gate fires). Returns the result or throws AccessDeniedError.
396
+ // Module-scoped (not just inside "authorize runtime gate") so the
397
+ // BLOCKING-1/2/3 regression describes below can reuse it too.
398
+ async function runGated(
399
+ modelFile: string,
400
+ query: string,
401
+ givens?: Record<string, GivenValue>,
402
+ bypassFilters?: boolean,
403
+ ) {
404
+ const model = await Model.create(
405
+ "test-pkg",
406
+ TEST_PKG_DIR,
407
+ modelFile,
408
+ getConnections(),
409
+ );
410
+ return model.getQueryResults(
411
+ undefined,
412
+ undefined,
413
+ query,
414
+ undefined,
415
+ bypassFilters,
416
+ givens,
417
+ );
418
+ }
410
419
 
420
+ describe("authorize runtime gate", () => {
411
421
  const SINGLE_GATE = `##! experimental.givens
412
422
 
413
423
  given:
@@ -797,6 +807,34 @@ source: joiner is duckdb.table('customers') extend {
797
807
  join_one: base_locked on id = base_locked.id
798
808
  measure: c is count()
799
809
  }
810
+
811
+ source: joiner_aliased is duckdb.table('customers') extend {
812
+ join_one: b is base_locked on id = b.id
813
+ measure: c is count()
814
+ }
815
+
816
+ source: plain is duckdb.table('customers') extend { measure: c is count() }
817
+
818
+ source: mixed_joiner is duckdb.table('customers') extend {
819
+ join_one: base_locked on id = base_locked.id
820
+ join_one: plain on id = plain.id
821
+ measure: c is count()
822
+ }
823
+
824
+ source: inline_joiner is duckdb.table('customers') extend {
825
+ join_one: t is duckdb.table('customers') on id = t.id
826
+ measure: c is count()
827
+ }
828
+
829
+ source: mid_join is duckdb.table('customers') extend {
830
+ join_one: base_locked on id = base_locked.id
831
+ measure: c is count()
832
+ }
833
+
834
+ source: top_join is duckdb.table('customers') extend {
835
+ join_one: mid_join on id = mid_join.id
836
+ measure: c is count()
837
+ }
800
838
  `;
801
839
 
802
840
  it('denies a direct query against a base locked with #(authorize) "false"', async () => {
@@ -831,15 +869,338 @@ source: joiner is duckdb.table('customers') extend {
831
869
  ).rejects.toBeInstanceOf(AccessDeniedError);
832
870
  });
833
871
 
834
- it("[documented limitation] a query joining a locked base via an ungated source is allowed (top-level-source only)", async () => {
872
+ it("a query joining a locked base via an ungated source is denied", async () => {
873
+ await writeModel("rt_locked.malloy", LOCKED_BASE);
874
+ await expect(
875
+ runGated("rt_locked.malloy", "run: joiner -> { aggregate: c }", {}),
876
+ ).rejects.toBeInstanceOf(AccessDeniedError);
877
+ });
878
+
879
+ it("denies a transitive A→B→C join where C is locked", async () => {
880
+ await writeModel("rt_locked.malloy", LOCKED_BASE);
881
+ await expect(
882
+ runGated("rt_locked.malloy", "run: top_join -> { aggregate: c }", {}),
883
+ ).rejects.toBeInstanceOf(AccessDeniedError);
884
+ });
885
+
886
+ it("denies an aliased join (join_one: b is base_locked) — alias resolution can't dodge the gate", async () => {
887
+ await writeModel("rt_locked.malloy", LOCKED_BASE);
888
+ await expect(
889
+ runGated(
890
+ "rt_locked.malloy",
891
+ "run: joiner_aliased -> { aggregate: c }",
892
+ {},
893
+ ),
894
+ ).rejects.toBeInstanceOf(AccessDeniedError);
895
+ });
896
+
897
+ it("denies a query joining one gated and one ungated source (AND across sources)", async () => {
898
+ await writeModel("rt_locked.malloy", LOCKED_BASE);
899
+ await expect(
900
+ runGated(
901
+ "rt_locked.malloy",
902
+ "run: mixed_joiner -> { aggregate: c }",
903
+ {},
904
+ ),
905
+ ).rejects.toBeInstanceOf(AccessDeniedError);
906
+ });
907
+
908
+ it("allows a query joining only an inline duckdb.table(...) source (no annotations to gate)", async () => {
835
909
  await writeModel("rt_locked.malloy", LOCKED_BASE);
836
910
  const { result } = await runGated(
837
911
  "rt_locked.malloy",
838
- "run: joiner -> { aggregate: c }",
912
+ "run: inline_joiner -> { aggregate: c }",
913
+ {},
914
+ );
915
+ expect(result.data).toBeDefined();
916
+ });
917
+
918
+ const FILE_LEVEL_OVERRIDE_WITH_JOIN = `##! experimental.givens
919
+
920
+ given:
921
+ ROLE :: string
922
+
923
+ ##(authorize) "$ROLE = 'admin'"
924
+
925
+ #(authorize) "false"
926
+ source: fl_locked is duckdb.table('customers') extend { measure: c is count() }
927
+
928
+ source: fl_joiner is duckdb.table('customers') extend {
929
+ join_one: fl_locked on id = fl_locked.id
930
+ measure: c is count()
931
+ }
932
+ `;
933
+
934
+ it("allows a locked join for admin givens via the file-level ##(authorize) override", async () => {
935
+ await writeModel("rt_fl_override.malloy", FILE_LEVEL_OVERRIDE_WITH_JOIN);
936
+ const { result } = await runGated(
937
+ "rt_fl_override.malloy",
938
+ "run: fl_joiner -> { aggregate: c }",
939
+ { ROLE: "admin" },
940
+ );
941
+ expect(result.data).toBeDefined();
942
+ });
943
+
944
+ it("denies a locked join for a non-admin given despite the file-level override existing", async () => {
945
+ await writeModel("rt_fl_override.malloy", FILE_LEVEL_OVERRIDE_WITH_JOIN);
946
+ await expect(
947
+ runGated(
948
+ "rt_fl_override.malloy",
949
+ "run: fl_joiner -> { aggregate: c }",
950
+ { ROLE: "analyst" },
951
+ ),
952
+ ).rejects.toBeInstanceOf(AccessDeniedError);
953
+ });
954
+
955
+ it("denies a notebook cell whose run target joins a locked base", async () => {
956
+ await writeModel(
957
+ "rt_locked_join.malloynb",
958
+ `>>>malloy
959
+ ${LOCKED_BASE}
960
+ query: secret_join is joiner -> { aggregate: c }
961
+
962
+ >>>malloy
963
+ run: secret_join
964
+ `,
965
+ );
966
+ const model = await Model.create(
967
+ "test-pkg",
968
+ TEST_PKG_DIR,
969
+ "rt_locked_join.malloynb",
970
+ getConnections(),
971
+ );
972
+ await expect(
973
+ model.executeNotebookCell(1, undefined, false, {}),
974
+ ).rejects.toBeInstanceOf(AccessDeniedError);
975
+ });
976
+ });
977
+
978
+ // BLOCKING-1: `hasAuthorize()` only inspects `this.sources` (built from
979
+ // entry-file `modelDef.contents`), so a gated source reached ONLY via a
980
+ // cross-file/deep-transitive join is invisible to it and the joined-gate
981
+ // walk was skipped entirely. Three files: a locked base, a mid file that
982
+ // imports it and joins it, and an entry file that imports the mid file and
983
+ // joins IT — never naming (or importing directly) the locked base, and
984
+ // declaring no gate of its own.
985
+ describe("authorize deep cross-file join enforcement (BLOCKING-1)", () => {
986
+ it("denies a query whose run target reaches a locked source only via a two-hop cross-file join", async () => {
987
+ await writeModel(
988
+ "c_base.malloy",
989
+ `#(authorize) "false"
990
+ source: base_locked is duckdb.table('customers') extend { measure: c is count() }
991
+ `,
992
+ );
993
+ await writeModel(
994
+ "c_mid.malloy",
995
+ `import "c_base.malloy"
996
+
997
+ source: c_mid is duckdb.table('customers') extend {
998
+ join_one: base_locked on id = base_locked.id
999
+ measure: c is count()
1000
+ }
1001
+ `,
1002
+ );
1003
+ await writeModel(
1004
+ "c_entry.malloy",
1005
+ `import "c_mid.malloy"
1006
+
1007
+ source: top is duckdb.table('customers') extend {
1008
+ join_one: c_mid on id = c_mid.id
1009
+ measure: c is count()
1010
+ }
1011
+ `,
1012
+ );
1013
+ await expect(
1014
+ runGated("c_entry.malloy", "run: top -> { aggregate: c }", {}),
1015
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1016
+ });
1017
+ });
1018
+
1019
+ // SHOULD-1: `extractSourcesFromModelDef` only validates #(authorize) syntax
1020
+ // for TOP-LEVEL `modelDef.contents` sources at model load — a source reached
1021
+ // ONLY via a join (never itself imported/named at the entry file, same shape
1022
+ // as the BLOCKING-1 repro) is never probed there, so a malformed gate on it
1023
+ // does NOT fail model load. The runtime join walk must still fail CLOSED when
1024
+ // it hits that malformed annotation, not silently treat it as ungated.
1025
+ describe("authorize fail-closed on a malformed join-only gate (SHOULD-1)", () => {
1026
+ it("loads successfully despite the malformed gate (never validated — it's join-only, not top-level)", async () => {
1027
+ await writeModel(
1028
+ "d_base.malloy",
1029
+ `#(authorize) notquoted
1030
+ source: base_locked is duckdb.table('customers') extend { measure: c is count() }
1031
+ `,
1032
+ );
1033
+ await writeModel(
1034
+ "d_mid.malloy",
1035
+ `import "d_base.malloy"
1036
+
1037
+ source: d_mid is duckdb.table('customers') extend {
1038
+ join_one: base_locked on id = base_locked.id
1039
+ measure: c is count()
1040
+ }
1041
+ `,
1042
+ );
1043
+ await writeModel(
1044
+ "d_entry.malloy",
1045
+ `import "d_mid.malloy"
1046
+
1047
+ source: top is duckdb.table('customers') extend {
1048
+ join_one: d_mid on id = d_mid.id
1049
+ measure: c is count()
1050
+ }
1051
+ `,
1052
+ );
1053
+ const model = await Model.create(
1054
+ "test-pkg",
1055
+ TEST_PKG_DIR,
1056
+ "d_entry.malloy",
1057
+ getConnections(),
1058
+ );
1059
+ // Confirms the premise: the malformed gate did NOT fail model load, and
1060
+ // `base_locked` is not even in the entry model's own discovery surface.
1061
+ expect(model.getNotebookError()).toBeUndefined();
1062
+ expect(model.getSources()?.map((s) => s.name)).not.toContain(
1063
+ "base_locked",
1064
+ );
1065
+ });
1066
+
1067
+ it("denies (fail closed) rather than silently admitting through the malformed gate", async () => {
1068
+ await writeModel(
1069
+ "d_base.malloy",
1070
+ `#(authorize) notquoted
1071
+ source: base_locked is duckdb.table('customers') extend { measure: c is count() }
1072
+ `,
1073
+ );
1074
+ await writeModel(
1075
+ "d_mid.malloy",
1076
+ `import "d_base.malloy"
1077
+
1078
+ source: d_mid is duckdb.table('customers') extend {
1079
+ join_one: base_locked on id = base_locked.id
1080
+ measure: c is count()
1081
+ }
1082
+ `,
1083
+ );
1084
+ await writeModel(
1085
+ "d_entry.malloy",
1086
+ `import "d_mid.malloy"
1087
+
1088
+ source: top is duckdb.table('customers') extend {
1089
+ join_one: d_mid on id = d_mid.id
1090
+ measure: c is count()
1091
+ }
1092
+ `,
1093
+ );
1094
+ await expect(
1095
+ runGated("d_entry.malloy", "run: top -> { aggregate: c }", {}),
1096
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1097
+ });
1098
+ });
1099
+
1100
+ // BLOCKING-2: composite sources (`compose(a, b)`) resolve to ONE concrete
1101
+ // member branch per query (`Query.compositeResolvedSourceDef`), based on
1102
+ // which fields are referenced. The pre-fix code resolved only
1103
+ // `_query.structRef` (the composite's own merged shape) and scanned
1104
+ // `combo.fields`, never the composite's member branches — so a query whose
1105
+ // resolution picked the locked branch was never gated.
1106
+ describe("authorize composite source enforcement (BLOCKING-2)", () => {
1107
+ const COMPOSITE_MODEL = `##! experimental.composite_sources
1108
+
1109
+ source: open_src is duckdb.table('customers') extend {
1110
+ measure: c is count()
1111
+ dimension: open_flag is 1
1112
+ }
1113
+
1114
+ #(authorize) "false"
1115
+ source: locked_src is duckdb.table('customers') extend {
1116
+ measure: c is count()
1117
+ dimension: locked_flag is 1
1118
+ }
1119
+
1120
+ source: combo is compose(open_src, locked_src)
1121
+ `;
1122
+
1123
+ it("denies a query that resolves the composite to the locked member branch", async () => {
1124
+ await writeModel("c_composite.malloy", COMPOSITE_MODEL);
1125
+ await expect(
1126
+ runGated(
1127
+ "c_composite.malloy",
1128
+ "run: combo -> { aggregate: c; group_by: locked_flag }",
1129
+ {},
1130
+ ),
1131
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1132
+ });
1133
+
1134
+ it("allows a query that resolves the composite to the open member branch", async () => {
1135
+ await writeModel("c_composite.malloy", COMPOSITE_MODEL);
1136
+ const { result } = await runGated(
1137
+ "c_composite.malloy",
1138
+ "run: combo -> { aggregate: c; group_by: open_flag }",
839
1139
  {},
840
1140
  );
841
1141
  expect(result.data).toBeDefined();
842
1142
  });
1143
+
1144
+ // `Query.compositeResolvedSourceDef` is what lets assertAuthorizedForAllSources
1145
+ // gate the ONE concrete branch a composite run target resolved to (see
1146
+ // model.ts's resolveRunTargetStruct/assertAuthorizedForAllSources) instead
1147
+ // of every member. This confirms it's populated even when the query
1148
+ // references NO field that discriminates between members (just the shared
1149
+ // `c` measure) — Malloy's composite resolver still picks a concrete first
1150
+ // candidate rather than leaving the resolution unset, so the gate still
1151
+ // fires on whichever member happens to resolve first.
1152
+ it("still denies when the composite resolves with no field forcing a choice (locked member listed first)", async () => {
1153
+ await writeModel(
1154
+ "c_composite_no_disambiguator.malloy",
1155
+ `##! experimental.composite_sources
1156
+
1157
+ source: open_src is duckdb.table('customers') extend { measure: c is count() }
1158
+
1159
+ #(authorize) "false"
1160
+ source: locked_src is duckdb.table('customers') extend { measure: c is count() }
1161
+
1162
+ source: combo_locked_first is compose(locked_src, open_src)
1163
+ `,
1164
+ );
1165
+ await expect(
1166
+ runGated(
1167
+ "c_composite_no_disambiguator.malloy",
1168
+ "run: combo_locked_first -> { aggregate: c }",
1169
+ {},
1170
+ ),
1171
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1172
+ });
1173
+ });
1174
+
1175
+ // BLOCKING-3: codex vs. Fable disagreed on whether a QUERY-LOCAL `join_one`
1176
+ // declared inside a `-> { ... }` refinement (rather than on the source
1177
+ // itself) bypasses the gate — the join then lives on the query pipeline's
1178
+ // segment/extendSource, not on `open_src.fields`. Exercise codex's exact
1179
+ // shape and report which way it actually goes.
1180
+ describe("authorize query-local join_one enforcement (BLOCKING-3)", () => {
1181
+ it("gates a source joined ONLY inside a query's own -> { ... } refinement (not on the source definition)", async () => {
1182
+ await writeModel(
1183
+ "c_query_local_join.malloy",
1184
+ `source: open_src is duckdb.table('customers') extend { measure: c is count() }
1185
+
1186
+ #(authorize) "false"
1187
+ source: locked_src is duckdb.table('customers') extend {
1188
+ measure: c is count()
1189
+ dimension: secret is name
1190
+ }
1191
+ `,
1192
+ );
1193
+ await expect(
1194
+ runGated(
1195
+ "c_query_local_join.malloy",
1196
+ `run: open_src -> {
1197
+ join_one: locked_src on id = locked_src.id
1198
+ group_by: locked_src.secret
1199
+ }`,
1200
+ {},
1201
+ ),
1202
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1203
+ });
843
1204
  });
844
1205
 
845
1206
  // The /compile path gates via Model.assertAuthorizedForText (early,
@@ -865,6 +1226,16 @@ given:
865
1226
  ##(authorize) "$ROLE = 'admin'"
866
1227
 
867
1228
  source: declared is duckdb.table('customers') extend { measure: c is count() }
1229
+ `;
1230
+ const CP_JOIN = `##! experimental.givens
1231
+
1232
+ #(authorize) "false"
1233
+ source: cp_locked is duckdb.table('customers') extend { measure: c is count() }
1234
+
1235
+ source: cp_joiner is duckdb.table('customers') extend {
1236
+ join_one: cp_locked on id = cp_locked.id
1237
+ measure: c is count()
1238
+ }
868
1239
  `;
869
1240
 
870
1241
  async function cpModel(file: string, src: string): Promise<Model> {
@@ -929,6 +1300,21 @@ source: declared is duckdb.table('customers') extend { measure: c is count() }
929
1300
  model.assertAuthorizedForRunnable(openRunnable, {}),
930
1301
  ).resolves.toBeUndefined();
931
1302
  });
1303
+
1304
+ it("assertAuthorizedForRunnable walks joined sources on the /compile path", async () => {
1305
+ const model = await cpModel("cp_join.malloy", CP_JOIN);
1306
+ // structRef "cp_joiner" resolves to its real SourceDef (with the join
1307
+ // field), so the /compile backstop must gate the locked joined source
1308
+ // too — not just the ungated run target.
1309
+ const joinerRunnable = {
1310
+ getPreparedQuery: async () => ({
1311
+ _query: { structRef: "cp_joiner" },
1312
+ }),
1313
+ };
1314
+ await expect(
1315
+ model.assertAuthorizedForRunnable(joinerRunnable, {}),
1316
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1317
+ });
932
1318
  });
933
1319
 
934
1320
  // A given can be both a runtime query parameter (substituted into a view's
@@ -997,3 +1383,659 @@ source: regional is duckdb.table('customers') extend {
997
1383
  expect(countOf(none.compactResult)).toBe(0);
998
1384
  });
999
1385
  });
1386
+
1387
+ // BLOCKING-4: a GIVEN-based #(authorize) gate on a source reached only
1388
+ // through a TWO-HOP transitive import over-denied every caller regardless of
1389
+ // the supplied given's value. Root cause: evaluateAuthorize's probe compiled
1390
+ // against the ENTRY model's modelMaterializer, but `$ROLE` is declared in
1391
+ // g_base — two import hops away — and Malloy's given-namespace merge covers
1392
+ // only one hop of import, so the probe failed to compile and was swallowed
1393
+ // by the catch-and-deny in evaluateAuthorize, fail-closed regardless of
1394
+ // value. Fixed by making each probe self-contained (bindProbeGivens):
1395
+ // declare + bind just the givens an expression references, inferring type
1396
+ // from the supplied value, as a fallback when the ambient-namespace probe
1397
+ // fails to compile. (One-hop cases still resolve ambiently on the first
1398
+ // try — ambient-declares-ROLE is the common case and is left unchanged, see
1399
+ // `evaluateAuthorize`.) The self-contained probe fix alone isn't sufficient
1400
+ // end to end: the REAL query also has to accept a `ROLE` given the entry
1401
+ // model doesn't itself surface, which required filterGivensToModelSurface to
1402
+ // stop forwarding an authorize-only given to the real query (Malloy's
1403
+ // `resolveSuppliedGivens` rejects any given a model doesn't surface).
1404
+ describe("authorize given-based gate across a two-hop transitive import (BLOCKING-4)", () => {
1405
+ const G_BASE = `##! experimental.givens
1406
+
1407
+ given:
1408
+ ROLE :: string
1409
+
1410
+ #(authorize) "$ROLE = 'analyst'"
1411
+ source: base_gated is duckdb.table('customers') extend { measure: c is count() }
1412
+ `;
1413
+ const G_MID = `import "g_base.malloy"
1414
+
1415
+ source: g_mid is duckdb.table('customers') extend {
1416
+ join_one: base_gated on id = base_gated.id
1417
+ measure: c is count()
1418
+ }
1419
+ `;
1420
+ const G_ENTRY = `import "g_mid.malloy"
1421
+
1422
+ source: g_top is duckdb.table('customers') extend {
1423
+ join_one: g_mid on id = g_mid.id
1424
+ measure: c is count()
1425
+ }
1426
+ `;
1427
+
1428
+ it("allows a two-hop transitively-imported given-based gate when the correct given is supplied", async () => {
1429
+ await writeModel("g_base.malloy", G_BASE);
1430
+ await writeModel("g_mid.malloy", G_MID);
1431
+ await writeModel("g_entry.malloy", G_ENTRY);
1432
+ const { result } = await runGated(
1433
+ "g_entry.malloy",
1434
+ "run: g_top -> { aggregate: c }",
1435
+ { ROLE: "analyst" },
1436
+ );
1437
+ expect(result.data).toBeDefined();
1438
+ });
1439
+
1440
+ it("still denies the two-hop gate when the wrong given is supplied", async () => {
1441
+ await writeModel("g_base.malloy", G_BASE);
1442
+ await writeModel("g_mid.malloy", G_MID);
1443
+ await writeModel("g_entry.malloy", G_ENTRY);
1444
+ await expect(
1445
+ runGated("g_entry.malloy", "run: g_top -> { aggregate: c }", {
1446
+ ROLE: "intern",
1447
+ }),
1448
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1449
+ });
1450
+
1451
+ it("still denies a two-hop transitive join to a constant-`false`-gated source", async () => {
1452
+ // Control: the pre-existing BLOCKING-1 case (a constant gate, no given
1453
+ // involved) must still deny — the self-contained-probe fallback must
1454
+ // not accidentally turn an unconditional deny into an allow.
1455
+ await writeModel(
1456
+ "cf_base.malloy",
1457
+ `#(authorize) "false"
1458
+ source: base_locked is duckdb.table('customers') extend { measure: c is count() }
1459
+ `,
1460
+ );
1461
+ await writeModel(
1462
+ "cf_mid.malloy",
1463
+ `import "cf_base.malloy"
1464
+
1465
+ source: cf_mid is duckdb.table('customers') extend {
1466
+ join_one: base_locked on id = base_locked.id
1467
+ measure: c is count()
1468
+ }
1469
+ `,
1470
+ );
1471
+ await writeModel(
1472
+ "cf_entry.malloy",
1473
+ `import "cf_mid.malloy"
1474
+
1475
+ source: cf_top is duckdb.table('customers') extend {
1476
+ join_one: cf_mid on id = cf_mid.id
1477
+ measure: c is count()
1478
+ }
1479
+ `,
1480
+ );
1481
+ await expect(
1482
+ runGated("cf_entry.malloy", "run: cf_top -> { aggregate: c }", {}),
1483
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1484
+ });
1485
+
1486
+ it("still allows a ONE-hop given-based gate on a joined source (no regression)", async () => {
1487
+ // Control: a joined source whose gate's given is declared in a
1488
+ // DIRECTLY imported file (one hop) already resolved ambiently before
1489
+ // this fix — confirm the self-contained fallback didn't change that.
1490
+ await writeModel(
1491
+ "oh_base.malloy",
1492
+ `##! experimental.givens
1493
+
1494
+ given:
1495
+ ROLE :: string
1496
+
1497
+ #(authorize) "$ROLE = 'analyst'"
1498
+ source: base_gated is duckdb.table('customers') extend { measure: c is count() }
1499
+ `,
1500
+ );
1501
+ await writeModel(
1502
+ "oh_entry.malloy",
1503
+ `import "oh_base.malloy"
1504
+
1505
+ source: oh_top is duckdb.table('customers') extend {
1506
+ join_one: base_gated on id = base_gated.id
1507
+ measure: c is count()
1508
+ }
1509
+ `,
1510
+ );
1511
+ const { result } = await runGated(
1512
+ "oh_entry.malloy",
1513
+ "run: oh_top -> { aggregate: c }",
1514
+ { ROLE: "analyst" },
1515
+ );
1516
+ expect(result.data).toBeDefined();
1517
+ await expect(
1518
+ runGated("oh_entry.malloy", "run: oh_top -> { aggregate: c }", {
1519
+ ROLE: "intern",
1520
+ }),
1521
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1522
+ });
1523
+
1524
+ // Name-collision isolation: the entry model declares its OWN `$LEVEL`
1525
+ // given (default 99), and a deep-joined source's gate ALSO references
1526
+ // `$LEVEL` but means its own, unrelated given. Root cause: evaluateAuthorize
1527
+ // tried the AMBIENT probe first — compiled and evaluated against the
1528
+ // ENTRY model's `modelMaterializer` — so on a name collision it happily
1529
+ // compiled against the entry model's OWN `$LEVEL` default and granted a
1530
+ // caller who supplied no `LEVEL` at all, never falling back to the
1531
+ // isolating self-contained probe (that fallback only fires when the
1532
+ // ambient probe THROWS, and here it didn't). Fixed by evaluating a
1533
+ // joined-source's gate self-contained FIRST, so a caller supplying no
1534
+ // `LEVEL` fails to build a self-contained probe for the joined gate and is
1535
+ // denied, regardless of what the entry model's own `$LEVEL` default is.
1536
+ it("denies a joined source's $LEVEL gate on a name collision with the entry model's own $LEVEL given, when no LEVEL is supplied", async () => {
1537
+ // coll_base's $LEVEL is TWO hops from the entry (via coll_mid), so it is
1538
+ // never merged into the entry's own ambient given namespace (only a
1539
+ // one-hop import merges) — this is what lets the entry separately
1540
+ // declare its OWN, unrelated $LEVEL without a "Cannot redefine"
1541
+ // compile error, setting up the name collision.
1542
+ await writeModel(
1543
+ "coll_base.malloy",
1544
+ `##! experimental.givens
1545
+
1546
+ given:
1547
+ LEVEL :: number
1548
+
1549
+ #(authorize) "$LEVEL > 3"
1550
+ source: coll_base_gated is duckdb.table('customers') extend { measure: c is count() }
1551
+ `,
1552
+ );
1553
+ await writeModel(
1554
+ "coll_mid.malloy",
1555
+ `import "coll_base.malloy"
1556
+
1557
+ source: coll_mid is duckdb.table('customers') extend {
1558
+ join_one: coll_base_gated on id = coll_base_gated.id
1559
+ measure: c is count()
1560
+ }
1561
+ `,
1562
+ );
1563
+ await writeModel(
1564
+ "coll_entry.malloy",
1565
+ `import "coll_mid.malloy"
1566
+
1567
+ ##! experimental.givens
1568
+
1569
+ given:
1570
+ LEVEL :: number is 99
1571
+
1572
+ source: coll_top is duckdb.table('customers') extend {
1573
+ join_one: coll_mid on id = coll_mid.id
1574
+ measure: c is count()
1575
+ }
1576
+ `,
1577
+ );
1578
+ await expect(
1579
+ runGated("coll_entry.malloy", "run: coll_top -> { aggregate: c }", {}),
1580
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1581
+ });
1582
+ });
1583
+
1584
+ // BLOCKING-5: a query-source derivation (`source: laundered is locked_src ->
1585
+ // { ... }`) laundered a locked base's gate away — the run target resolves to
1586
+ // a `QuerySourceDef` whose own `.fields`/`.annotations` reflect the DERIVED
1587
+ // shape, not `locked_src`'s #(authorize), so the walk found nothing. Fixed
1588
+ // by resolveQuerySourceBases/collectQuerySourceBaseGates: a `QuerySourceDef`
1589
+ // keeps its base reachable via `query.structRef` (an inline SourceDef or a
1590
+ // string name resolving through modelDef.contents — the same StructRef
1591
+ // shape resolveRunTargetStruct already resolves for a run target), so the
1592
+ // walk now gates that base too, recursing for a chained derivation and for a
1593
+ // query-source reached only via a join.
1594
+ describe("authorize query-source derivation enforcement (BLOCKING-5)", () => {
1595
+ const QS_MODEL = `#(authorize) "false"
1596
+ source: locked_src is duckdb.table('customers') extend {
1597
+ measure: c is count()
1598
+ dimension: secret is name
1599
+ }
1600
+
1601
+ source: laundered is locked_src -> { select: id, secret }
1602
+
1603
+ source: open_src is duckdb.table('customers') extend { measure: c is count() }
1604
+
1605
+ source: open_laundered is open_src -> { select: id }
1606
+
1607
+ source: qs_joiner is duckdb.table('customers') extend {
1608
+ join_one: laundered on id = laundered.id
1609
+ measure: c is count()
1610
+ }
1611
+
1612
+ source: double_laundered is laundered -> { select: id, secret }
1613
+ `;
1614
+
1615
+ it("denies a query against a query-source derived from a locked base", async () => {
1616
+ await writeModel("qs.malloy", QS_MODEL);
1617
+ await expect(
1618
+ runGated("qs.malloy", "run: laundered -> { select: id, secret }", {}),
1619
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1620
+ });
1621
+
1622
+ it("allows a query-source derived from an ungated base", async () => {
1623
+ await writeModel("qs.malloy", QS_MODEL);
1624
+ const { result } = await runGated(
1625
+ "qs.malloy",
1626
+ "run: open_laundered -> { select: id }",
1627
+ {},
1628
+ );
1629
+ expect(result.data).toBeDefined();
1630
+ });
1631
+
1632
+ it("denies a query joining a query-source that derives from a locked base", async () => {
1633
+ await writeModel("qs.malloy", QS_MODEL);
1634
+ await expect(
1635
+ runGated("qs.malloy", "run: qs_joiner -> { aggregate: c }", {}),
1636
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1637
+ });
1638
+
1639
+ it("denies a CHAINED query-source (derived from a derivation of a locked base)", async () => {
1640
+ await writeModel("qs.malloy", QS_MODEL);
1641
+ await expect(
1642
+ runGated(
1643
+ "qs.malloy",
1644
+ "run: double_laundered -> { select: id, secret }",
1645
+ {},
1646
+ ),
1647
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1648
+ });
1649
+ });
1650
+
1651
+ // BLOCKING-6: a query-source's OWN inner pipeline segment can declare a
1652
+ // `join_one` INSIDE the derivation (`-> { extend: { join_one: locked ... }
1653
+ // ... }`). That join lives on the pipeline segment's `extendSource`, not on
1654
+ // `struct.fields` and not reachable via `query.structRef` (the base chain
1655
+ // resolveQuerySourceBases/collectQuerySourceBaseGates already walk) — so it
1656
+ // was never gated, laundering a locked source's #(authorize) through the
1657
+ // query-source's own derivation. The identical join declared inline in a
1658
+ // RUN query (rather than inside a `source: x is y -> {...}` derivation) was
1659
+ // already correctly denied via assertAuthorizedForAllSources's own
1660
+ // `extendSources` handling.
1661
+ describe("authorize query-source's own inner-pipeline join enforcement (BLOCKING-6)", () => {
1662
+ const QS_INNER_JOIN_MODEL = `#(authorize) "false"
1663
+ source: locked9 is duckdb.table('customers') extend { dimension: secret is name }
1664
+
1665
+ source: open9 is duckdb.table('customers') extend { measure: c is count() }
1666
+
1667
+ source: sneaky is open9 -> {
1668
+ extend: { join_one: locked9 on id = locked9.id }
1669
+ select: id, leak is locked9.secret
1670
+ }
1671
+
1672
+ source: qs_joiner is duckdb.table('customers') extend {
1673
+ join_one: sneaky on id = sneaky.id
1674
+ measure: c is count()
1675
+ }
1676
+
1677
+ source: open_inner_join is open9 -> {
1678
+ extend: { join_one: open9_b is duckdb.table('customers') on id = open9_b.id }
1679
+ select: id
1680
+ }
1681
+ `;
1682
+
1683
+ it("denies a direct query against a query-source whose own inner pipeline joins a locked source", async () => {
1684
+ await writeModel("qs_inner.malloy", QS_INNER_JOIN_MODEL);
1685
+ await expect(
1686
+ runGated("qs_inner.malloy", "run: sneaky -> { select: id, leak }", {}),
1687
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1688
+ });
1689
+
1690
+ it("denies a query joining a query-source whose own inner pipeline joins a locked source", async () => {
1691
+ await writeModel("qs_inner.malloy", QS_INNER_JOIN_MODEL);
1692
+ await expect(
1693
+ runGated(
1694
+ "qs_inner.malloy",
1695
+ "run: qs_joiner -> { group_by: sneaky.leak }",
1696
+ {},
1697
+ ),
1698
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1699
+ });
1700
+
1701
+ it("allows a query-source whose own inner pipeline joins an UNGATED source", async () => {
1702
+ await writeModel("qs_inner.malloy", QS_INNER_JOIN_MODEL);
1703
+ const { result } = await runGated(
1704
+ "qs_inner.malloy",
1705
+ "run: open_inner_join -> { select: id }",
1706
+ {},
1707
+ );
1708
+ expect(result.data).toBeDefined();
1709
+ });
1710
+ });
1711
+
1712
+ // SHOULD-2: filterGivensToModelSurface dropped EVERY given name absent from
1713
+ // the entry model's own surface, not just an authorize-only given. That
1714
+ // over-drops a genuine `where:` filter given declared two-plus import hops
1715
+ // away (never on the entry surface, per Malloy's one-hop given-namespace
1716
+ // merge — see BLOCKING-4): the caller's value is silently discarded and the
1717
+ // query falls back to the given's declared default, returning MORE rows
1718
+ // than the caller intended. It also silently swallows a typo'd given name
1719
+ // instead of raising Malloy's "unknown given" error. Fix: only drop a name
1720
+ // that is BOTH referenced by an authorize gate reachable in this model AND
1721
+ // absent from the surface -- a name no gate references is left alone so it
1722
+ // either reaches the real query (honoring the caller's value) or fails
1723
+ // closed with a compile/runtime error, never a silent default.
1724
+ describe("filterGivensToModelSurface no longer over-drops a non-gate given (SHOULD-2)", () => {
1725
+ const HB_BASE = `##! experimental.givens
1726
+
1727
+ given:
1728
+ HIDE :: string is 'none'
1729
+
1730
+ source: hb_base is duckdb.table('customers') extend {
1731
+ where: region != $HIDE
1732
+ measure: c is count()
1733
+ }
1734
+ `;
1735
+ const HB_MID = `import "hb_base.malloy"
1736
+
1737
+ source: hb_mid is duckdb.table('customers') extend {
1738
+ join_one: hb_base on id = hb_base.id
1739
+ measure: c is count()
1740
+ }
1741
+ `;
1742
+ const HB_ENTRY = `import "hb_mid.malloy"
1743
+
1744
+ source: hb_top is duckdb.table('customers') extend {
1745
+ join_one: hb_mid on id = hb_mid.id
1746
+ measure: c is count()
1747
+ }
1748
+ `;
1749
+
1750
+ it("does not silently over-return when a two-hop where-given is supplied", async () => {
1751
+ await writeModel("hb_base.malloy", HB_BASE);
1752
+ await writeModel("hb_mid.malloy", HB_MID);
1753
+ await writeModel("hb_entry.malloy", HB_ENTRY);
1754
+ // Seed has one 'us-west' row and one 'us-east' row. The caller intends
1755
+ // to exclude 'us-west'. Silently falling back to the default ('none',
1756
+ // which excludes nothing) would return BOTH rows via hb_base.c.
1757
+ let overExposed = false;
1758
+ try {
1759
+ const { compactResult } = await runGated(
1760
+ "hb_entry.malloy",
1761
+ "run: hb_top -> { aggregate: c is hb_mid.hb_base.c }",
1762
+ { HIDE: "us-west" },
1763
+ );
1764
+ const rows = compactResult as unknown as Array<
1765
+ Record<string, unknown>
1766
+ >;
1767
+ overExposed = Number(rows[0]?.c) === 2;
1768
+ } catch {
1769
+ // Erroring instead of over-returning is an acceptable outcome too
1770
+ // (fail closed on a name the entry model can't resolve).
1771
+ }
1772
+ expect(overExposed).toBe(false);
1773
+ });
1774
+
1775
+ it("does not silently swallow a typo'd given name", async () => {
1776
+ await writeModel("hb_base.malloy", HB_BASE);
1777
+ await writeModel("hb_mid.malloy", HB_MID);
1778
+ await writeModel("hb_entry.malloy", HB_ENTRY);
1779
+ await expect(
1780
+ runGated(
1781
+ "hb_entry.malloy",
1782
+ "run: hb_top -> { aggregate: c is hb_mid.hb_base.c }",
1783
+ { HIDEE: "us-west" }, // typo for HIDE
1784
+ ),
1785
+ ).rejects.toBeTruthy();
1786
+ });
1787
+ });
1788
+
1789
+ // BLOCKING-7: two call sites in assertAuthorizedForAllSources hand-composed
1790
+ // gateExprsForOwnAnnotations + collectJoinedAuthorizeGates for a struct
1791
+ // WITHOUT also calling collectQuerySourceBaseGates on it, unlike every other
1792
+ // call site in this file — so specifically when that struct is ITSELF a
1793
+ // query-source derived from a locked base, the derivation laundered the
1794
+ // locked base's gate away:
1795
+ // (1) a composite's RESOLVED branch (compositeResolvedSourceDef);
1796
+ // (2) a run target's own query-local join (extendSources).
1797
+ // Fixed by routing every reachable-gate call site through one unified
1798
+ // collectAllReachableGates walk (own annotations ++ joined sources ++
1799
+ // query-source bases ++ a query-source's own inner-pipeline joins, all
1800
+ // recursed through itself) instead of a hand-picked subset per call site.
1801
+ describe("BLOCKING-7 / unified walk", () => {
1802
+ const UNIFIED_MODEL = `##! experimental.composite_sources
1803
+ source: open_src is duckdb.table('customers') extend {
1804
+ measure: c is count()
1805
+ }
1806
+
1807
+ #(authorize) "false"
1808
+ source: locked is duckdb.table('customers') extend {
1809
+ measure: c is count()
1810
+ dimension: locked_region is region
1811
+ }
1812
+
1813
+ source: laundered is locked -> { group_by: id, locked_region }
1814
+
1815
+ source: combo is compose(open_src, laundered)
1816
+
1817
+ source: open_laundered is open_src -> { group_by: id }
1818
+ `;
1819
+
1820
+ it("denies a composite query resolving to a query-source branch derived from a locked base", async () => {
1821
+ await writeModel("c_unified.malloy", UNIFIED_MODEL);
1822
+ await expect(
1823
+ runGated(
1824
+ "c_unified.malloy",
1825
+ "run: combo -> { group_by: locked_region }",
1826
+ {},
1827
+ ),
1828
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1829
+ });
1830
+
1831
+ it("denies a run target's own query-local join to a query-source derived from a locked base", async () => {
1832
+ await writeModel("c_unified.malloy", UNIFIED_MODEL);
1833
+ await expect(
1834
+ runGated(
1835
+ "c_unified.malloy",
1836
+ `run: open_src -> {
1837
+ extend: { join_one: laundered on id = laundered.id }
1838
+ group_by: leak is laundered.locked_region
1839
+ }`,
1840
+ {},
1841
+ ),
1842
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1843
+ });
1844
+
1845
+ it("allows a composite query resolving to the ungated open branch", async () => {
1846
+ await writeModel("c_unified.malloy", UNIFIED_MODEL);
1847
+ const { result } = await runGated(
1848
+ "c_unified.malloy",
1849
+ "run: combo -> { aggregate: c }",
1850
+ {},
1851
+ );
1852
+ expect(result.data).toBeDefined();
1853
+ });
1854
+
1855
+ it("allows a run target's own query-local join to an UNGATED query-source", async () => {
1856
+ await writeModel("c_unified.malloy", UNIFIED_MODEL);
1857
+ const { result } = await runGated(
1858
+ "c_unified.malloy",
1859
+ `run: open_src -> {
1860
+ extend: { join_one: open_laundered on id = open_laundered.id }
1861
+ group_by: ok is open_laundered.id
1862
+ }`,
1863
+ {},
1864
+ );
1865
+ expect(result.data).toBeDefined();
1866
+ });
1867
+ });
1868
+
1869
+ // query_source's OWN base is a composite (not the run target itself): the
1870
+ // query_source branch of collectAllReachableGates resolved the base via
1871
+ // query.structRef (the raw composite) and walked query.pipeline, but never
1872
+ // walked the query-source's own compositeResolvedSourceDef — the RESOLVED
1873
+ // branch Malloy picked for that query-source's derivation. A query-source
1874
+ // derived from a locked composite member laundered that member's gate away.
1875
+ describe("query-source over composite: resolved-branch gate", () => {
1876
+ const QS_OVER_COMPOSITE_MODEL = `##! experimental.composite_sources
1877
+ source: open_src is duckdb.table('customers') extend {
1878
+ measure: c is count()
1879
+ dimension: region_d is region
1880
+ }
1881
+
1882
+ #(authorize) "false"
1883
+ source: locked is duckdb.table('customers') extend {
1884
+ measure: c is count()
1885
+ dimension: locked_region is region
1886
+ }
1887
+
1888
+ source: laundered is locked -> { group_by: id, locked_region }
1889
+
1890
+ source: inner_combo is compose(open_src, laundered)
1891
+
1892
+ source: qs_over_combo is inner_combo -> { group_by: id, locked_region }
1893
+
1894
+ source: open_qs_over_combo is inner_combo -> { group_by: id, region_d }
1895
+
1896
+ source: outer_qs is qs_over_combo -> { group_by: locked_region }
1897
+ `;
1898
+
1899
+ it("denies a query-source whose base composite resolves to a locked member", async () => {
1900
+ await writeModel("qsc_unified.malloy", QS_OVER_COMPOSITE_MODEL);
1901
+ await expect(
1902
+ runGated(
1903
+ "qsc_unified.malloy",
1904
+ "run: qs_over_combo -> { group_by: locked_region }",
1905
+ {},
1906
+ ),
1907
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1908
+ });
1909
+
1910
+ it("allows its open-branch twin: query-source whose base composite resolves to the ungated member", async () => {
1911
+ await writeModel("qsc_unified.malloy", QS_OVER_COMPOSITE_MODEL);
1912
+ const { result } = await runGated(
1913
+ "qsc_unified.malloy",
1914
+ "run: open_qs_over_combo -> { group_by: region_d }",
1915
+ {},
1916
+ );
1917
+ expect(result.data).toBeDefined();
1918
+ });
1919
+
1920
+ it("denies at nesting depth 2: a query-source over a query-source over a composite whose resolved branch hits a locked base", async () => {
1921
+ await writeModel("qsc_unified.malloy", QS_OVER_COMPOSITE_MODEL);
1922
+ await expect(
1923
+ runGated(
1924
+ "qsc_unified.malloy",
1925
+ "run: outer_qs -> { group_by: locked_region }",
1926
+ {},
1927
+ ),
1928
+ ).rejects.toBeInstanceOf(AccessDeniedError);
1929
+ });
1930
+ });
1931
+
1932
+ // MUST-FIX 1 (review regression): Malloy's RecordDef/RepeatedRecordDef/
1933
+ // BasicArrayDef (STRUCT/ARRAY/JSON-shaped nested columns) extend JoinBase,
1934
+ // so `isJoined()` is true for them even though they are ordinary columns,
1935
+ // not joins to another authorize-gated source. `isSourceDef()` correctly
1936
+ // excludes them ('record'/'array' aren't source kinds), which used to trip
1937
+ // the "isJoined but not isSourceDef" drift invariant and deny a query
1938
+ // against any table with a nested column — even with zero authorize
1939
+ // annotations anywhere in the model.
1940
+ describe("authorize tolerates record/array-typed columns (MUST-FIX 1)", () => {
1941
+ it("allows a query against a table with STRUCT/ARRAY/nested-STRUCT-ARRAY columns and no gates", async () => {
1942
+ await writeModel(
1943
+ "rt_nested.malloy",
1944
+ `source: nested_src is duckdb.table('nested_cols') extend {
1945
+ measure: c is count()
1946
+ }
1947
+ `,
1948
+ );
1949
+ const { result } = await runGated(
1950
+ "rt_nested.malloy",
1951
+ "run: nested_src -> { aggregate: c }",
1952
+ {},
1953
+ );
1954
+ expect(result.data).toBeDefined();
1955
+ });
1956
+ });
1957
+
1958
+ // MUST-FIX 2 (review regression): evaluateSelfContainedFirst treated
1959
+ // `decls.length === 0` as "unsatisfiable, deny" unconditionally — but that
1960
+ // conflates "the expression references givens the caller can't supply"
1961
+ // (correct deny) with "the expression references NO givens at all" (e.g. a
1962
+ // constant/public gate like `#(authorize) "true"` — there's nothing ambient
1963
+ // to isolate from, so there's nothing wrong with running it with no decls).
1964
+ describe("authorize allows a givens-free joined gate (MUST-FIX 2)", () => {
1965
+ it('allows a same-file `#(authorize) "true"` source joined by an ungated top', async () => {
1966
+ await writeModel(
1967
+ "rt_pub.malloy",
1968
+ `#(authorize) "true"
1969
+ source: pub_gated is duckdb.table('customers') extend { measure: c is count() }
1970
+
1971
+ source: pub_joiner is duckdb.table('customers') extend {
1972
+ join_one: pub_gated on id = pub_gated.id
1973
+ measure: c is count()
1974
+ }
1975
+ `,
1976
+ );
1977
+ const { result } = await runGated(
1978
+ "rt_pub.malloy",
1979
+ "run: pub_joiner -> { aggregate: c }",
1980
+ {},
1981
+ );
1982
+ expect(result.data).toBeDefined();
1983
+ });
1984
+ });
1985
+
1986
+ // MUST-FIX 3 (review regression): when a joined gate references TWO givens
1987
+ // and the caller supplies only ONE of them, the self-contained probe throws
1988
+ // (the other given is unbound) and the old code fell back to the AMBIENT
1989
+ // probe — which compiles against the ENTRY model's own given namespace. If
1990
+ // the entry model happens to declare its OWN given of the SAME NAME as the
1991
+ // unsupplied one (a two-hop-away collision, same setup as the existing
1992
+ // BLOCKING-4 $LEVEL-collision test), the entry's default silently decided
1993
+ // the outcome — reopening exactly the name-collision hole the
1994
+ // self-contained-first fix exists to close. Fix: only fall back to ambient
1995
+ // when EVERY referenced given was covered by the self-contained decls.
1996
+ describe("authorize multi-given joined gate stays fail-closed on partial given supply (MUST-FIX 3)", () => {
1997
+ it("denies when only one of two referenced givens is supplied and the entry model has a colliding default for the other", async () => {
1998
+ await writeModel(
1999
+ "mg_base.malloy",
2000
+ `##! experimental.givens
2001
+
2002
+ given:
2003
+ LEVEL :: number
2004
+ REGION :: string
2005
+
2006
+ #(authorize) "$LEVEL > 3 and $REGION = 'us-west'"
2007
+ source: mg_base_gated is duckdb.table('customers') extend { measure: c is count() }
2008
+ `,
2009
+ );
2010
+ await writeModel(
2011
+ "mg_mid.malloy",
2012
+ `import "mg_base.malloy"
2013
+
2014
+ source: mg_mid is duckdb.table('customers') extend {
2015
+ join_one: mg_base_gated on id = mg_base_gated.id
2016
+ measure: c is count()
2017
+ }
2018
+ `,
2019
+ );
2020
+ await writeModel(
2021
+ "mg_entry.malloy",
2022
+ `import "mg_mid.malloy"
2023
+
2024
+ ##! experimental.givens
2025
+
2026
+ given:
2027
+ LEVEL :: number is 99
2028
+
2029
+ source: mg_top is duckdb.table('customers') extend {
2030
+ join_one: mg_mid on id = mg_mid.id
2031
+ measure: c is count()
2032
+ }
2033
+ `,
2034
+ );
2035
+ await expect(
2036
+ runGated("mg_entry.malloy", "run: mg_top -> { aggregate: c }", {
2037
+ REGION: "us-west",
2038
+ }),
2039
+ ).rejects.toBeInstanceOf(AccessDeniedError);
2040
+ });
2041
+ });