Repository: single-cell-data/TileDB-SOMA (language: python)

Issue:
[python/r/c++] Revisit `shape` for component arrays
## PRs

**Merged PRs:**

<details>

* #2909 `kerl/schevo-timestamp-methodize`
* #2913 `kerl/name-neaten`
* #2908 `kerl/ut-soma-exc-simplify`
* #2910 `kerl/test-common-parameterize`
* #2918 `kerl/cpp-test-deadstrip`
* #2919 `kerl/minor-unit-test-helper-mod`
* #2936 `kerl/cpp-ut-helper-neaten`
* #2938 `kerl/more-cur-dom-parameterize`
* #2915 `kerl/cpp-strict-int64-shape`
* #2911 `kerl/arrow-util-current-domain-optional`
* #2939 `kerl/step-two-temp`
* #2947 `kerl/cpp-ndarray-resize-testing`
* #2945 `kerl/dataframe-test-fixture`
* #2944 `kerl/cpp-variant-indexed-dataframes`
* #2916 `kerl/sdf-shape`
* #2917 `kerl/cpp-resizes`
* #2948 `kerl/upgrade-shape-int64`
* #2951 `kerl/sdf-test-accessors`
* #2953 `kerl/py-r-accessor-plumbing`
* #2957 `kerl/sdf-domain-accessors`
* #2960 `kerl/dense-link`
* #2963 `kerl/nightly-fix`
* #2970 `kerl/dense-writeable-after-create`
* #2968 `kerl/minor-trim`
* #2969 `kerl/more-py-domain-name-neaten`
* #2972 `kerl/libtiledbsoma-env-logging-level`
* #2962 `kerl/py-r-creation-paths`
* #2950 `kerl/py-r-test-2`
* #2994 `kerl/nanoarrow-helpers`
* #3011 `kerl/polydom3`
* #3017 `kerl/polydom5`
* #3018 `kerl/polydom6`
* #2990 `kerl/variant-nnz-bug`
* #3019 `kerl/index-swap`
* #3020 `kerl/ut-max-shape`
* #3012 `kerl/polydom4`
* #3025 `kerl/fix-3020-merge`
* #3026 `kerl/one-more-rename`
* #3028 `kerl/ff-not`
* #3029 `kerl/ut-vg`
* #3030 `kerl/table-utils-memory`
* #3067 `kerl/improve-sdf-test-field-names`
* #3068 `kerl/ut-generate`
* #3069 `kerl/cpp-sdf-domain-at-create`
* #3027 `kerl/hll-domainish`
* #3088 `kerl/max-domain-int64`
* #3090 `kerl/maybe-resize-soma-joinid-cpp-tweak`
* #3032 `kerl/sdf-domain-at-create` -- fixes #2967
* #3091 `kerl/maybe-resize-soma-joinid-py-r`
* #3095 `kerl/cpp-exp-resize-prep`
* #3089 `kerl/r-dataframe-shapeable`
* https://github.com/single-cell-data/SOMA/pull/233
* #3125 `kerl/cpp-ut-name-shortens`
* #3127 `kerl/helper-rename`
* #3130 `kerl/cpp-can-resizers-names`
* #3132 `kerl/cpp-dataframe-sizing-helpers`
* #3139 `kerl/cpp-dataframe-upgrade-test`
* #3140 `kerl/py-resizer-connects`
* #3151 `kerl/py-can-upgrade-shape`
* #3152 `kerl/registration-shape-acceessors`
* #3156 `kerl/py-exp-shaping`
* #3157 `kerl/py-exp-shaping2`
* #3148 `kerl/py-exp-resize`
* #3191 `kerl/py-domain-at-create-ut-1`
* #3190 `kerl/py-domain-at-create-ut-2`
* #3192 `kerl/py-domain-at-create-ut-3`
* #3193 `kerl/py-domain-at-create-ut-4`
* #3194 `kerl/py-domain-at-create-ut-5`
* #3203 `kerl/min-size-2`
* #3208 `kerl/r-min-sizing`
* #3211 `kerl/cpp-ugr-dom`
* #3232 `kerl/ff-interop`
* #3230 `kerl/ffon`
* #3234 `kerl/docstring-prune`
* #3236 `kerl/prefixing`
* #3241 `kerl/fix-bad-merge`
* #3235 `kerl/py-r-ugr-dom`
* #3237 `kerl/py-r-ugr-dom-2`
* #3238 `kerl/py-r-ugr-dom-3`
* #3253 `kerl/set-coords-rename`
* #3261 `kerl/pybind11-nda-sizing`
* #3265 `kerl/dense-227-a`
* #3263 `kerl/dense-range-trim`
* #3268 `kerl/dim-explosion`
* #3269 `kerl/python-227-dense-ned-read`
* #3270 `kerl/r-227-dense-fixes`
* #3280 `kerl/r-dense-227-more`
* #3286 `kerl/more-fn4m`
* #3283 `kerl/readthedocs-pre-1.15`
* #3288 `kerl/dense-ugrsh`
* #3289 `kerl/notebook-shape-upgrade`
* #3285 `kerl/new-shape-doc-updates`
* #3295 `kerl/notebook-data-refresh`
* #3290 `kerl/notebook-new-shape-refresh`
* #3301 `kerl/ffena`
* #3303 `kerl/r-data-refresh`
* #3300 `kerl/sdf-sjid-lower-zero`
* #3296 `kerl/dense-example-data-refresh`
* #3294 `kerl/new-shape-notebook-and-vignette`
* #3308 `kerl/upgrade-experiment-resources`
* #3309 `kerl/fix-notebook-merge`
* #3321 `kerl/more-use-shape`
* #3358 `kerl/revert-3300`
* #3368 `kerl/227a`
* #3369 `kerl/ucd1`
* #3370 `kerl/ucd2`
* #3371 `kerl/ucd3`
* #3372 `kerl/ucd4`
* #3396 `kerl/domain-at-create-docstrings`
* #3302 `kerl/new-shape-vignette`
* https://github.com/single-cell-data/SOMA/pull/250
* #3399 `kerl/new-shape-more-docstrings`
* #3400 `kerl/check-only-r`
* https://github.com/TileDB-Inc/somacore-feedstock/pull/27

</details>

**Closed/abandoned PRs:**

<details>

* #2785 -- This was only dogfooding for the core 2.25 release -- not to be merged
* #2952 `kerl/feature-flag-temp` -- folded into 2962
* #2995 `kerl/polydom`
* #2964 `kerl/tiledbsoma-io-test`
* #3189 `kerl/min-size`
* #3220 `kerl/cpp-ugr-dom-2`
* #3244 `kerl/dense-227-fixes`
* #3276 `kerl/dataframe-shape`

</details>

**Issues which are related but non-blocking:**

* https://github.com/single-cell-data/SOMA/pull/216
* https://github.com/TileDB-Inc/TileDB/pull/5303
* #2966
* #3081
* Note: R append mode does not exist yet -- see #1630 -- so an experiment-level resizer is not a priority in R
* #3271 
* #3272
* #3273

**See also:** [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Problem to be solved

Users want to know the `shape` of an array, in the SciPy sense:

* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`.
  * Some users need the ability to grow their datasets later, using either `tiledbsoma.io`'s append mode, or subsequent writes using the `tiledbsoma` API.
  * Note that the cellxgene census doesn't need this: eact week's published census has fixed shape, and any updates will happen in new storage, on a new week.

Using TileDB-SOMA up until the present:

* The TIleDB `domain` is immutable after array creation
  * This does bounds-checking for reads and writes, which is good
  * To leverage this to function as a `shape`, users would need to set the `domain` at array-creation time. However, users lose the ability to grow their datasets later.
* There is a `non_empty_domain` accessor
  * This only indicates min/max coordinates _at which data exists_. Consider an `X` array for 100 cells and 200 genes. If non-zero expression counts exist only for cell join IDs 2-17, then the `non_empty_domain` will indicate `(2,17)` along `soma_dim_0`.
  * Consider an `obms["X_pca"]` within the same experiment. This may be 100 cells by 50 PCA components: we need a placd to store the number 50.
  * Therefore users cannot leverage this to function as a `shape` accessor.
* We have offered a `used_shape` accessor since TileDB-SOMA 1.5.
  * This functions as a `shape` accessor, in the SciPy sense, but it is not multi-writer safe.

New feature for TileDB-SOMA 1.15:

* Arrays will have a  `shape`
* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`
* The `used_shape` accessor will be deprecated in TileDB-SOMA 1.13, and slated for removal in TileDB-SOMA 1.14.

Compatiblity:

This will now require users to do an explicit `resize` before appending/growing TileDB-SOMA Experiments. Guidance in the form of example notebooks will be provided.

##  Tracking

See also: [[sc-41074]](https://app.shortcut.com/tiledb-inc/story/41074) and [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Scheduling

Support arrives in TileDB Core 2.25. Deprecations for TileDB-SOMA will be released with 1.13. Full support within TileDB-SOMA will be release in 1.14.

## Details

SOMA API mods as we've discussed in a Google doc are as follows.

### `SOMADataFrame`

* `create`: Retain the `domain` argument
  * Issue:
    * Core has a `(lo, hi)` tuple per dim, e.g. `(0,99)` or `(10,19)`
    * SOMA has count per dim, with 0 implicit: e.g. 100 or 20
    * For `SparseNDArray` and `DenseNDArray` core can have `(lo, hi)` and SOMA can have `count`
    * For `DataFrame` there can be multiple dims --- default is a single `soma_joinid`
    * That could be treated either in `(lo, hi)` fashion or `count` fashion
    * However additional dims (e.g. `cell_type`) can be on any type, including strings, floats, etc. where there is no implicit lo=0
    * Therefore we need to keep the current SOMA API wherein `DataFrame` takes a `domain` argument (in `(lo, hi)` fashion) and not a `shape` argument (in `count` fashion)

### `SparseNDArray and DenseNDArray`

* `create`
  * Have an optional shape argument which is of type `Tuple[Int,...]` where each element is the cell count of the corresponding dimension
    * If unsupplied, or if supplied but None in any slot: use the minimum 0 in each slot – nothing larger makes sense since we will not support downsize
  * User guidance should make clear that it will not be possible to create an ‘old’ style array with the ‘new style’ API. (See also the upgrade logic below.)

### All three of `SOMADataFrame`, `SparseNDArray`, `DenseNDArray`

* `write`
  * For new arrays, created with the new shape feature:
    * Core will bounds-check that coordinates provided at `write` time are within the current shape
    * Core will raise `tiledb.cc.TileDBError` to TileDB-SOMA, which will catch and raise `IndexError`, and R-standard behavior on the R side
  * For old arrays created before this feature:
    * Core will not bounds-check that coordinates provided at write time are within the current shape
* Existing `used_shape` accessor
  * TileDB-SOMA will deprecate this over a release cycle.
  * For new arrays: raise `NotImplementedError`
  * For old arrays: return what’s currently returned, with a deprecation warning.
  * Mechanism for determining old vs. new: `array.schema.version` (the core storage version).
* Existing `shape` accessor
  * For new arrays:
    * Have this return the new shape as proposed by core, no longer returning the TileDB domain.
  * For old arrays created before this feature:
    * Return the TileDB domain as now.
* Existing `non_empty_domain` accessor
  * Same behavior for old and new arrays (unaffected by this proposal).
  * Keep this accessor supported, but, with user notes that it’s generally non-useful
  * This should return None (or R equivalent) when there is a schema but no data have been written.
* New `maxshape` accessor
  * Maps the core-level `(lo, hi)` accessor for domain to count-style accessor hi+1. E.g. if the core domain is either `(0,99)` or `(50,99)` then TileDB-SOMA `maxshape` will say 100.
  * Same behavior for old and new arrays.
  * Let users query for what the TileDB domain is, with user notes that it’s the maximum that users can reshape to.
  * Issac suggests: maybe `domain` or `maxshape` (see h5py).
* New `resize` mutator
  * Note: `reshape` means something else in the community (numpy, zarr, h5py), e.g. a 5x20 (total 100 cells) being reinterpreted as 4x25 (still 100 cells). The standard name for changing cell-count is `resize`.
  * For old arrays created before this feature: raise `NotImplementedError`.
  * For new arrays:
    * Will raise `ValueError` if the new shape is smaller on any dim than currently in storage
    * Regardless of whether any data have been written whatsoever
    * Will raise `ValueError` if the new shape exceeds the TileDB domain from create time, which will serve TileDB-SOMA in a role of “max possible shape the user can reshape to”
    * Otherwise, any calls to write from this point will bounds-check writes within this new shape
    * **We don’t expect resize to be multi-writer safe with regard to write ; user notes must be clear on this point**
* New `tiledbsoma_upgrade_shape` method for SparseNDArray and DenseNDArray
  * This will leverage `array.schema.version` to see if an upgrade is needed
  * Leverage core support for storage-version updates
  * This will take a shape argument as in `create`
  * For arrays created with “just-right” size: this will succeed
  * For arrays created with “room-for-growth” / “two billion-ish” size: this will succeed
  * If the user passes a shape which exceeds the current TileDB domain: this will fail
* New `tiledbsoma_upgrade_domain method` for `DataFrame`
  * Same as for `SparseNDArray`/`DenseNDArray` except it will take a domain at the SOMA-API level just as `DataFrame`'s create method

### `tiledbsoma.io`

* The user-facing API has no shape arguments and thus won’t need changing.
* Internally to `tiledbsoma.io`, we’ll still ask the tiledbsoma API for the “big domain” (2 billionish)
* Append mode:
  * Will need a new `resize` method at the `Experiment` level
  * Users will need to:
    * Register as now
    * Call the experiment-level `resize`
      * Could be `exp.resize(...)`, or (better) this could be `tiledbsoma.io.reshape_experiment`
  * In either case: this method will take the new `obs` and `var` counts as inputs:
    * `exp.obs.reshape` to new `obs` count
    * `exp.ms[name].var.reshape` to new `var` count
    * `exp.ms[name].X[name].reshape` to new `obs` count x `var` count
    * `exp.ms[name].obsm[name].reshape` to new `obs` count x same width
    * `exp.ms[name].obsp[name].reshape` to new `obs` count x `obs` count
    * `exp.ms[name].varm[name].reshape` to new `var` count x same width
    * `exp.ms[name].varp[name].reshape` to new `var` count x `var` count
  * Do the individual append-mode writes as now

[python/r/c++] Revisit `shape` for component arrays
## PRs

**Merged PRs:**

<details>

* #2909 `kerl/schevo-timestamp-methodize`
* #2913 `kerl/name-neaten`
* #2908 `kerl/ut-soma-exc-simplify`
* #2910 `kerl/test-common-parameterize`
* #2918 `kerl/cpp-test-deadstrip`
* #2919 `kerl/minor-unit-test-helper-mod`
* #2936 `kerl/cpp-ut-helper-neaten`
* #2938 `kerl/more-cur-dom-parameterize`
* #2915 `kerl/cpp-strict-int64-shape`
* #2911 `kerl/arrow-util-current-domain-optional`
* #2939 `kerl/step-two-temp`
* #2947 `kerl/cpp-ndarray-resize-testing`
* #2945 `kerl/dataframe-test-fixture`
* #2944 `kerl/cpp-variant-indexed-dataframes`
* #2916 `kerl/sdf-shape`
* #2917 `kerl/cpp-resizes`
* #2948 `kerl/upgrade-shape-int64`
* #2951 `kerl/sdf-test-accessors`
* #2953 `kerl/py-r-accessor-plumbing`
* #2957 `kerl/sdf-domain-accessors`
* #2960 `kerl/dense-link`
* #2963 `kerl/nightly-fix`
* #2970 `kerl/dense-writeable-after-create`
* #2968 `kerl/minor-trim`
* #2969 `kerl/more-py-domain-name-neaten`
* #2972 `kerl/libtiledbsoma-env-logging-level`
* #2962 `kerl/py-r-creation-paths`
* #2950 `kerl/py-r-test-2`
* #2994 `kerl/nanoarrow-helpers`
* #3011 `kerl/polydom3`
* #3017 `kerl/polydom5`
* #3018 `kerl/polydom6`
* #2990 `kerl/variant-nnz-bug`
* #3019 `kerl/index-swap`
* #3020 `kerl/ut-max-shape`
* #3012 `kerl/polydom4`
* #3025 `kerl/fix-3020-merge`
* #3026 `kerl/one-more-rename`
* #3028 `kerl/ff-not`
* #3029 `kerl/ut-vg`
* #3030 `kerl/table-utils-memory`
* #3067 `kerl/improve-sdf-test-field-names`
* #3068 `kerl/ut-generate`
* #3069 `kerl/cpp-sdf-domain-at-create`
* #3027 `kerl/hll-domainish`
* #3088 `kerl/max-domain-int64`
* #3090 `kerl/maybe-resize-soma-joinid-cpp-tweak`
* #3032 `kerl/sdf-domain-at-create` -- fixes #2967
* #3091 `kerl/maybe-resize-soma-joinid-py-r`
* #3095 `kerl/cpp-exp-resize-prep`
* #3089 `kerl/r-dataframe-shapeable`
* https://github.com/single-cell-data/SOMA/pull/233
* #3125 `kerl/cpp-ut-name-shortens`
* #3127 `kerl/helper-rename`
* #3130 `kerl/cpp-can-resizers-names`
* #3132 `kerl/cpp-dataframe-sizing-helpers`
* #3139 `kerl/cpp-dataframe-upgrade-test`
* #3140 `kerl/py-resizer-connects`
* #3151 `kerl/py-can-upgrade-shape`
* #3152 `kerl/registration-shape-acceessors`
* #3156 `kerl/py-exp-shaping`
* #3157 `kerl/py-exp-shaping2`
* #3148 `kerl/py-exp-resize`
* #3191 `kerl/py-domain-at-create-ut-1`
* #3190 `kerl/py-domain-at-create-ut-2`
* #3192 `kerl/py-domain-at-create-ut-3`
* #3193 `kerl/py-domain-at-create-ut-4`
* #3194 `kerl/py-domain-at-create-ut-5`
* #3203 `kerl/min-size-2`
* #3208 `kerl/r-min-sizing`
* #3211 `kerl/cpp-ugr-dom`
* #3232 `kerl/ff-interop`
* #3230 `kerl/ffon`
* #3234 `kerl/docstring-prune`
* #3236 `kerl/prefixing`
* #3241 `kerl/fix-bad-merge`
* #3235 `kerl/py-r-ugr-dom`
* #3237 `kerl/py-r-ugr-dom-2`
* #3238 `kerl/py-r-ugr-dom-3`
* #3253 `kerl/set-coords-rename`
* #3261 `kerl/pybind11-nda-sizing`
* #3265 `kerl/dense-227-a`
* #3263 `kerl/dense-range-trim`
* #3268 `kerl/dim-explosion`
* #3269 `kerl/python-227-dense-ned-read`
* #3270 `kerl/r-227-dense-fixes`
* #3280 `kerl/r-dense-227-more`
* #3286 `kerl/more-fn4m`
* #3283 `kerl/readthedocs-pre-1.15`
* #3288 `kerl/dense-ugrsh`
* #3289 `kerl/notebook-shape-upgrade`
* #3285 `kerl/new-shape-doc-updates`
* #3295 `kerl/notebook-data-refresh`
* #3290 `kerl/notebook-new-shape-refresh`
* #3301 `kerl/ffena`
* #3303 `kerl/r-data-refresh`
* #3300 `kerl/sdf-sjid-lower-zero`
* #3296 `kerl/dense-example-data-refresh`
* #3294 `kerl/new-shape-notebook-and-vignette`
* #3308 `kerl/upgrade-experiment-resources`
* #3309 `kerl/fix-notebook-merge`
* #3321 `kerl/more-use-shape`
* #3358 `kerl/revert-3300`
* #3368 `kerl/227a`
* #3369 `kerl/ucd1`
* #3370 `kerl/ucd2`
* #3371 `kerl/ucd3`
* #3372 `kerl/ucd4`
* #3396 `kerl/domain-at-create-docstrings`
* #3302 `kerl/new-shape-vignette`
* https://github.com/single-cell-data/SOMA/pull/250
* #3399 `kerl/new-shape-more-docstrings`
* #3400 `kerl/check-only-r`
* https://github.com/TileDB-Inc/somacore-feedstock/pull/27

</details>

**Closed/abandoned PRs:**

<details>

* #2785 -- This was only dogfooding for the core 2.25 release -- not to be merged
* #2952 `kerl/feature-flag-temp` -- folded into 2962
* #2995 `kerl/polydom`
* #2964 `kerl/tiledbsoma-io-test`
* #3189 `kerl/min-size`
* #3220 `kerl/cpp-ugr-dom-2`
* #3244 `kerl/dense-227-fixes`
* #3276 `kerl/dataframe-shape`

</details>

**Issues which are related but non-blocking:**

* https://github.com/single-cell-data/SOMA/pull/216
* https://github.com/TileDB-Inc/TileDB/pull/5303
* #2966
* #3081
* Note: R append mode does not exist yet -- see #1630 -- so an experiment-level resizer is not a priority in R
* #3271 
* #3272
* #3273

**See also:** [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Problem to be solved

Users want to know the `shape` of an array, in the SciPy sense:

* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`.
  * Some users need the ability to grow their datasets later, using either `tiledbsoma.io`'s append mode, or subsequent writes using the `tiledbsoma` API.
  * Note that the cellxgene census doesn't need this: eact week's published census has fixed shape, and any updates will happen in new storage, on a new week.

Using TileDB-SOMA up until the present:

* The TIleDB `domain` is immutable after array creation
  * This does bounds-checking for reads and writes, which is good
  * To leverage this to function as a `shape`, users would need to set the `domain` at array-creation time. However, users lose the ability to grow their datasets later.
* There is a `non_empty_domain` accessor
  * This only indicates min/max coordinates _at which data exists_. Consider an `X` array for 100 cells and 200 genes. If non-zero expression counts exist only for cell join IDs 2-17, then the `non_empty_domain` will indicate `(2,17)` along `soma_dim_0`.
  * Consider an `obms["X_pca"]` within the same experiment. This may be 100 cells by 50 PCA components: we need a placd to store the number 50.
  * Therefore users cannot leverage this to function as a `shape` accessor.
* We have offered a `used_shape` accessor since TileDB-SOMA 1.5.
  * This functions as a `shape` accessor, in the SciPy sense, but it is not multi-writer safe.

New feature for TileDB-SOMA 1.15:

* Arrays will have a  `shape`
* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`
* The `used_shape` accessor will be deprecated in TileDB-SOMA 1.13, and slated for removal in TileDB-SOMA 1.14.

Compatiblity:

This will now require users to do an explicit `resize` before appending/growing TileDB-SOMA Experiments. Guidance in the form of example notebooks will be provided.

##  Tracking

See also: [[sc-41074]](https://app.shortcut.com/tiledb-inc/story/41074) and [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Scheduling

Support arrives in TileDB Core 2.25. Deprecations for TileDB-SOMA will be released with 1.13. Full support within TileDB-SOMA will be release in 1.14.

## Details

SOMA API mods as we've discussed in a Google doc are as follows.

### `SOMADataFrame`

* `create`: Retain the `domain` argument
  * Issue:
    * Core has a `(lo, hi)` tuple per dim, e.g. `(0,99)` or `(10,19)`
    * SOMA has count per dim, with 0 implicit: e.g. 100 or 20
    * For `SparseNDArray` and `DenseNDArray` core can have `(lo, hi)` and SOMA can have `count`
    * For `DataFrame` there can be multiple dims --- default is a single `soma_joinid`
    * That could be treated either in `(lo, hi)` fashion or `count` fashion
    * However additional dims (e.g. `cell_type`) can be on any type, including strings, floats, etc. where there is no implicit lo=0
    * Therefore we need to keep the current SOMA API wherein `DataFrame` takes a `domain` argument (in `(lo, hi)` fashion) and not a `shape` argument (in `count` fashion)

### `SparseNDArray and DenseNDArray`

* `create`
  * Have an optional shape argument which is of type `Tuple[Int,...]` where each element is the cell count of the corresponding dimension
    * If unsupplied, or if supplied but None in any slot: use the minimum 0 in each slot – nothing larger makes sense since we will not support downsize
  * User guidance should make clear that it will not be possible to create an ‘old’ style array with the ‘new style’ API. (See also the upgrade logic below.)

### All three of `SOMADataFrame`, `SparseNDArray`, `DenseNDArray`

* `write`
  * For new arrays, created with the new shape feature:
    * Core will bounds-check that coordinates provided at `write` time are within the current shape
    * Core will raise `tiledb.cc.TileDBError` to TileDB-SOMA, which will catch and raise `IndexError`, and R-standard behavior on the R side
  * For old arrays created before this feature:
    * Core will not bounds-check that coordinates provided at write time are within the current shape
* Existing `used_shape` accessor
  * TileDB-SOMA will deprecate this over a release cycle.
  * For new arrays: raise `NotImplementedError`
  * For old arrays: return what’s currently returned, with a deprecation warning.
  * Mechanism for determining old vs. new: `array.schema.version` (the core storage version).
* Existing `shape` accessor
  * For new arrays:
    * Have this return the new shape as proposed by core, no longer returning the TileDB domain.
  * For old arrays created before this feature:
    * Return the TileDB domain as now.
* Existing `non_empty_domain` accessor
  * Same behavior for old and new arrays (unaffected by this proposal).
  * Keep this accessor supported, but, with user notes that it’s generally non-useful
  * This should return None (or R equivalent) when there is a schema but no data have been written.
* New `maxshape` accessor
  * Maps the core-level `(lo, hi)` accessor for domain to count-style accessor hi+1. E.g. if the core domain is either `(0,99)` or `(50,99)` then TileDB-SOMA `maxshape` will say 100.
  * Same behavior for old and new arrays.
  * Let users query for what the TileDB domain is, with user notes that it’s the maximum that users can reshape to.
  * Issac suggests: maybe `domain` or `maxshape` (see h5py).
* New `resize` mutator
  * Note: `reshape` means something else in the community (numpy, zarr, h5py), e.g. a 5x20 (total 100 cells) being reinterpreted as 4x25 (still 100 cells). The standard name for changing cell-count is `resize`.
  * For old arrays created before this feature: raise `NotImplementedError`.
  * For new arrays:
    * Will raise `ValueError` if the new shape is smaller on any dim than currently in storage
    * Regardless of whether any data have been written whatsoever
    * Will raise `ValueError` if the new shape exceeds the TileDB domain from create time, which will serve TileDB-SOMA in a role of “max possible shape the user can reshape to”
    * Otherwise, any calls to write from this point will bounds-check writes within this new shape
    * **We don’t expect resize to be multi-writer safe with regard to write ; user notes must be clear on this point**
* New `tiledbsoma_upgrade_shape` method for SparseNDArray and DenseNDArray
  * This will leverage `array.schema.version` to see if an upgrade is needed
  * Leverage core support for storage-version updates
  * This will take a shape argument as in `create`
  * For arrays created with “just-right” size: this will succeed
  * For arrays created with “room-for-growth” / “two billion-ish” size: this will succeed
  * If the user passes a shape which exceeds the current TileDB domain: this will fail
* New `tiledbsoma_upgrade_domain method` for `DataFrame`
  * Same as for `SparseNDArray`/`DenseNDArray` except it will take a domain at the SOMA-API level just as `DataFrame`'s create method

### `tiledbsoma.io`

* The user-facing API has no shape arguments and thus won’t need changing.
* Internally to `tiledbsoma.io`, we’ll still ask the tiledbsoma API for the “big domain” (2 billionish)
* Append mode:
  * Will need a new `resize` method at the `Experiment` level
  * Users will need to:
    * Register as now
    * Call the experiment-level `resize`
      * Could be `exp.resize(...)`, or (better) this could be `tiledbsoma.io.reshape_experiment`
  * In either case: this method will take the new `obs` and `var` counts as inputs:
    * `exp.obs.reshape` to new `obs` count
    * `exp.ms[name].var.reshape` to new `var` count
    * `exp.ms[name].X[name].reshape` to new `obs` count x `var` count
    * `exp.ms[name].obsm[name].reshape` to new `obs` count x same width
    * `exp.ms[name].obsp[name].reshape` to new `obs` count x `obs` count
    * `exp.ms[name].varm[name].reshape` to new `var` count x same width
    * `exp.ms[name].varp[name].reshape` to new `var` count x `var` count
  * Do the individual append-mode writes as now

[python/r/c++] Revisit `shape` for component arrays
## PRs

**Merged PRs:**

<details>

* #2909 `kerl/schevo-timestamp-methodize`
* #2913 `kerl/name-neaten`
* #2908 `kerl/ut-soma-exc-simplify`
* #2910 `kerl/test-common-parameterize`
* #2918 `kerl/cpp-test-deadstrip`
* #2919 `kerl/minor-unit-test-helper-mod`
* #2936 `kerl/cpp-ut-helper-neaten`
* #2938 `kerl/more-cur-dom-parameterize`
* #2915 `kerl/cpp-strict-int64-shape`
* #2911 `kerl/arrow-util-current-domain-optional`
* #2939 `kerl/step-two-temp`
* #2947 `kerl/cpp-ndarray-resize-testing`
* #2945 `kerl/dataframe-test-fixture`
* #2944 `kerl/cpp-variant-indexed-dataframes`
* #2916 `kerl/sdf-shape`
* #2917 `kerl/cpp-resizes`
* #2948 `kerl/upgrade-shape-int64`
* #2951 `kerl/sdf-test-accessors`
* #2953 `kerl/py-r-accessor-plumbing`
* #2957 `kerl/sdf-domain-accessors`
* #2960 `kerl/dense-link`
* #2963 `kerl/nightly-fix`
* #2970 `kerl/dense-writeable-after-create`
* #2968 `kerl/minor-trim`
* #2969 `kerl/more-py-domain-name-neaten`
* #2972 `kerl/libtiledbsoma-env-logging-level`
* #2962 `kerl/py-r-creation-paths`
* #2950 `kerl/py-r-test-2`
* #2994 `kerl/nanoarrow-helpers`
* #3011 `kerl/polydom3`
* #3017 `kerl/polydom5`
* #3018 `kerl/polydom6`
* #2990 `kerl/variant-nnz-bug`
* #3019 `kerl/index-swap`
* #3020 `kerl/ut-max-shape`
* #3012 `kerl/polydom4`
* #3025 `kerl/fix-3020-merge`
* #3026 `kerl/one-more-rename`
* #3028 `kerl/ff-not`
* #3029 `kerl/ut-vg`
* #3030 `kerl/table-utils-memory`
* #3067 `kerl/improve-sdf-test-field-names`
* #3068 `kerl/ut-generate`
* #3069 `kerl/cpp-sdf-domain-at-create`
* #3027 `kerl/hll-domainish`
* #3088 `kerl/max-domain-int64`
* #3090 `kerl/maybe-resize-soma-joinid-cpp-tweak`
* #3032 `kerl/sdf-domain-at-create` -- fixes #2967
* #3091 `kerl/maybe-resize-soma-joinid-py-r`
* #3095 `kerl/cpp-exp-resize-prep`
* #3089 `kerl/r-dataframe-shapeable`
* https://github.com/single-cell-data/SOMA/pull/233
* #3125 `kerl/cpp-ut-name-shortens`
* #3127 `kerl/helper-rename`
* #3130 `kerl/cpp-can-resizers-names`
* #3132 `kerl/cpp-dataframe-sizing-helpers`
* #3139 `kerl/cpp-dataframe-upgrade-test`
* #3140 `kerl/py-resizer-connects`
* #3151 `kerl/py-can-upgrade-shape`
* #3152 `kerl/registration-shape-acceessors`
* #3156 `kerl/py-exp-shaping`
* #3157 `kerl/py-exp-shaping2`
* #3148 `kerl/py-exp-resize`
* #3191 `kerl/py-domain-at-create-ut-1`
* #3190 `kerl/py-domain-at-create-ut-2`
* #3192 `kerl/py-domain-at-create-ut-3`
* #3193 `kerl/py-domain-at-create-ut-4`
* #3194 `kerl/py-domain-at-create-ut-5`
* #3203 `kerl/min-size-2`
* #3208 `kerl/r-min-sizing`
* #3211 `kerl/cpp-ugr-dom`
* #3232 `kerl/ff-interop`
* #3230 `kerl/ffon`
* #3234 `kerl/docstring-prune`
* #3236 `kerl/prefixing`
* #3241 `kerl/fix-bad-merge`
* #3235 `kerl/py-r-ugr-dom`
* #3237 `kerl/py-r-ugr-dom-2`
* #3238 `kerl/py-r-ugr-dom-3`
* #3253 `kerl/set-coords-rename`
* #3261 `kerl/pybind11-nda-sizing`
* #3265 `kerl/dense-227-a`
* #3263 `kerl/dense-range-trim`
* #3268 `kerl/dim-explosion`
* #3269 `kerl/python-227-dense-ned-read`
* #3270 `kerl/r-227-dense-fixes`
* #3280 `kerl/r-dense-227-more`
* #3286 `kerl/more-fn4m`
* #3283 `kerl/readthedocs-pre-1.15`
* #3288 `kerl/dense-ugrsh`
* #3289 `kerl/notebook-shape-upgrade`
* #3285 `kerl/new-shape-doc-updates`
* #3295 `kerl/notebook-data-refresh`
* #3290 `kerl/notebook-new-shape-refresh`
* #3301 `kerl/ffena`
* #3303 `kerl/r-data-refresh`
* #3300 `kerl/sdf-sjid-lower-zero`
* #3296 `kerl/dense-example-data-refresh`
* #3294 `kerl/new-shape-notebook-and-vignette`
* #3308 `kerl/upgrade-experiment-resources`
* #3309 `kerl/fix-notebook-merge`
* #3321 `kerl/more-use-shape`
* #3358 `kerl/revert-3300`
* #3368 `kerl/227a`
* #3369 `kerl/ucd1`
* #3370 `kerl/ucd2`
* #3371 `kerl/ucd3`
* #3372 `kerl/ucd4`
* #3396 `kerl/domain-at-create-docstrings`
* #3302 `kerl/new-shape-vignette`
* https://github.com/single-cell-data/SOMA/pull/250
* #3399 `kerl/new-shape-more-docstrings`
* #3400 `kerl/check-only-r`
* https://github.com/TileDB-Inc/somacore-feedstock/pull/27

</details>

**Closed/abandoned PRs:**

<details>

* #2785 -- This was only dogfooding for the core 2.25 release -- not to be merged
* #2952 `kerl/feature-flag-temp` -- folded into 2962
* #2995 `kerl/polydom`
* #2964 `kerl/tiledbsoma-io-test`
* #3189 `kerl/min-size`
* #3220 `kerl/cpp-ugr-dom-2`
* #3244 `kerl/dense-227-fixes`
* #3276 `kerl/dataframe-shape`

</details>

**Issues which are related but non-blocking:**

* https://github.com/single-cell-data/SOMA/pull/216
* https://github.com/TileDB-Inc/TileDB/pull/5303
* #2966
* #3081
* Note: R append mode does not exist yet -- see #1630 -- so an experiment-level resizer is not a priority in R
* #3271 
* #3272
* #3273

**See also:** [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Problem to be solved

Users want to know the `shape` of an array, in the SciPy sense:

* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`.
  * Some users need the ability to grow their datasets later, using either `tiledbsoma.io`'s append mode, or subsequent writes using the `tiledbsoma` API.
  * Note that the cellxgene census doesn't need this: eact week's published census has fixed shape, and any updates will happen in new storage, on a new week.

Using TileDB-SOMA up until the present:

* The TIleDB `domain` is immutable after array creation
  * This does bounds-checking for reads and writes, which is good
  * To leverage this to function as a `shape`, users would need to set the `domain` at array-creation time. However, users lose the ability to grow their datasets later.
* There is a `non_empty_domain` accessor
  * This only indicates min/max coordinates _at which data exists_. Consider an `X` array for 100 cells and 200 genes. If non-zero expression counts exist only for cell join IDs 2-17, then the `non_empty_domain` will indicate `(2,17)` along `soma_dim_0`.
  * Consider an `obms["X_pca"]` within the same experiment. This may be 100 cells by 50 PCA components: we need a placd to store the number 50.
  * Therefore users cannot leverage this to function as a `shape` accessor.
* We have offered a `used_shape` accessor since TileDB-SOMA 1.5.
  * This functions as a `shape` accessor, in the SciPy sense, but it is not multi-writer safe.

New feature for TileDB-SOMA 1.15:

* Arrays will have a  `shape`
* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`
* The `used_shape` accessor will be deprecated in TileDB-SOMA 1.13, and slated for removal in TileDB-SOMA 1.14.

Compatiblity:

This will now require users to do an explicit `resize` before appending/growing TileDB-SOMA Experiments. Guidance in the form of example notebooks will be provided.

##  Tracking

See also: [[sc-41074]](https://app.shortcut.com/tiledb-inc/story/41074) and [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Scheduling

Support arrives in TileDB Core 2.25. Deprecations for TileDB-SOMA will be released with 1.13. Full support within TileDB-SOMA will be release in 1.14.

## Details

SOMA API mods as we've discussed in a Google doc are as follows.

### `SOMADataFrame`

* `create`: Retain the `domain` argument
  * Issue:
    * Core has a `(lo, hi)` tuple per dim, e.g. `(0,99)` or `(10,19)`
    * SOMA has count per dim, with 0 implicit: e.g. 100 or 20
    * For `SparseNDArray` and `DenseNDArray` core can have `(lo, hi)` and SOMA can have `count`
    * For `DataFrame` there can be multiple dims --- default is a single `soma_joinid`
    * That could be treated either in `(lo, hi)` fashion or `count` fashion
    * However additional dims (e.g. `cell_type`) can be on any type, including strings, floats, etc. where there is no implicit lo=0
    * Therefore we need to keep the current SOMA API wherein `DataFrame` takes a `domain` argument (in `(lo, hi)` fashion) and not a `shape` argument (in `count` fashion)

### `SparseNDArray and DenseNDArray`

* `create`
  * Have an optional shape argument which is of type `Tuple[Int,...]` where each element is the cell count of the corresponding dimension
    * If unsupplied, or if supplied but None in any slot: use the minimum 0 in each slot – nothing larger makes sense since we will not support downsize
  * User guidance should make clear that it will not be possible to create an ‘old’ style array with the ‘new style’ API. (See also the upgrade logic below.)

### All three of `SOMADataFrame`, `SparseNDArray`, `DenseNDArray`

* `write`
  * For new arrays, created with the new shape feature:
    * Core will bounds-check that coordinates provided at `write` time are within the current shape
    * Core will raise `tiledb.cc.TileDBError` to TileDB-SOMA, which will catch and raise `IndexError`, and R-standard behavior on the R side
  * For old arrays created before this feature:
    * Core will not bounds-check that coordinates provided at write time are within the current shape
* Existing `used_shape` accessor
  * TileDB-SOMA will deprecate this over a release cycle.
  * For new arrays: raise `NotImplementedError`
  * For old arrays: return what’s currently returned, with a deprecation warning.
  * Mechanism for determining old vs. new: `array.schema.version` (the core storage version).
* Existing `shape` accessor
  * For new arrays:
    * Have this return the new shape as proposed by core, no longer returning the TileDB domain.
  * For old arrays created before this feature:
    * Return the TileDB domain as now.
* Existing `non_empty_domain` accessor
  * Same behavior for old and new arrays (unaffected by this proposal).
  * Keep this accessor supported, but, with user notes that it’s generally non-useful
  * This should return None (or R equivalent) when there is a schema but no data have been written.
* New `maxshape` accessor
  * Maps the core-level `(lo, hi)` accessor for domain to count-style accessor hi+1. E.g. if the core domain is either `(0,99)` or `(50,99)` then TileDB-SOMA `maxshape` will say 100.
  * Same behavior for old and new arrays.
  * Let users query for what the TileDB domain is, with user notes that it’s the maximum that users can reshape to.
  * Issac suggests: maybe `domain` or `maxshape` (see h5py).
* New `resize` mutator
  * Note: `reshape` means something else in the community (numpy, zarr, h5py), e.g. a 5x20 (total 100 cells) being reinterpreted as 4x25 (still 100 cells). The standard name for changing cell-count is `resize`.
  * For old arrays created before this feature: raise `NotImplementedError`.
  * For new arrays:
    * Will raise `ValueError` if the new shape is smaller on any dim than currently in storage
    * Regardless of whether any data have been written whatsoever
    * Will raise `ValueError` if the new shape exceeds the TileDB domain from create time, which will serve TileDB-SOMA in a role of “max possible shape the user can reshape to”
    * Otherwise, any calls to write from this point will bounds-check writes within this new shape
    * **We don’t expect resize to be multi-writer safe with regard to write ; user notes must be clear on this point**
* New `tiledbsoma_upgrade_shape` method for SparseNDArray and DenseNDArray
  * This will leverage `array.schema.version` to see if an upgrade is needed
  * Leverage core support for storage-version updates
  * This will take a shape argument as in `create`
  * For arrays created with “just-right” size: this will succeed
  * For arrays created with “room-for-growth” / “two billion-ish” size: this will succeed
  * If the user passes a shape which exceeds the current TileDB domain: this will fail
* New `tiledbsoma_upgrade_domain method` for `DataFrame`
  * Same as for `SparseNDArray`/`DenseNDArray` except it will take a domain at the SOMA-API level just as `DataFrame`'s create method

### `tiledbsoma.io`

* The user-facing API has no shape arguments and thus won’t need changing.
* Internally to `tiledbsoma.io`, we’ll still ask the tiledbsoma API for the “big domain” (2 billionish)
* Append mode:
  * Will need a new `resize` method at the `Experiment` level
  * Users will need to:
    * Register as now
    * Call the experiment-level `resize`
      * Could be `exp.resize(...)`, or (better) this could be `tiledbsoma.io.reshape_experiment`
  * In either case: this method will take the new `obs` and `var` counts as inputs:
    * `exp.obs.reshape` to new `obs` count
    * `exp.ms[name].var.reshape` to new `var` count
    * `exp.ms[name].X[name].reshape` to new `obs` count x `var` count
    * `exp.ms[name].obsm[name].reshape` to new `obs` count x same width
    * `exp.ms[name].obsp[name].reshape` to new `obs` count x `obs` count
    * `exp.ms[name].varm[name].reshape` to new `var` count x same width
    * `exp.ms[name].varp[name].reshape` to new `var` count x `var` count
  * Do the individual append-mode writes as now



Discussion / hints:
#2785 is a quick-and-dirty concept-prover -- its sole function is to flush out any API misunderstandings we might have, in prep for 2.25.0 core release.
#2785 is a quick-and-dirty concept-prover -- its sole function is to flush out any API misunderstandings we might have, in prep for 2.25.0 core release.
#2785 is a quick-and-dirty concept-prover -- its sole function is to flush out any API misunderstandings we might have, in prep for 2.25.0 core release.

Fix patch:
diff --git a/apis/python/src/tiledbsoma/_dataframe.py b/apis/python/src/tiledbsoma/_dataframe.py
index fbac57aabe..fb59049922 100644
--- a/apis/python/src/tiledbsoma/_dataframe.py
+++ b/apis/python/src/tiledbsoma/_dataframe.py
@@ -6,6 +6,7 @@
 """
 Implementation of a SOMA DataFrame
 """
+import inspect
 from typing import Any, List, Optional, Sequence, Tuple, Type, Union, cast
 
 import numpy as np
@@ -424,7 +425,7 @@ def tiledbsoma_has_upgraded_domain(self) -> bool:
         """
         return self._handle.tiledbsoma_has_upgraded_domain
 
-    def resize_soma_joinid_shape(
+    def tiledbsoma_resize_soma_joinid_shape(
         self, newshape: int, check_only: bool = False
     ) -> StatusAndReason:
         """Increases the shape of the dataframe on the ``soma_joinid`` index
@@ -437,16 +438,25 @@ def resize_soma_joinid_shape(
         1.15).  If ``check_only`` is ``True``, returns whether the operation
         would succeed if attempted, and a reason why it would not.
         """
+        frame = inspect.currentframe()
+        function_name_for_messages = frame.f_code.co_name if frame else "tiledbsoma"
+
         if check_only:
             return cast(
                 StatusAndReason,
-                self._handle._handle.can_resize_soma_joinid_shape(newshape),
+                self._handle._handle.can_resize_soma_joinid_shape(
+                    newshape,
+                    function_name_for_messages=function_name_for_messages,
+                ),
             )
         else:
-            self._handle._handle.resize_soma_joinid_shape(newshape)
+            self._handle._handle.resize_soma_joinid_shape(
+                newshape,
+                function_name_for_messages=function_name_for_messages,
+            )
             return (True, "")
 
-    def upgrade_soma_joinid_shape(
+    def tiledbsoma_upgrade_soma_joinid_shape(
         self, newshape: int, check_only: bool = False
     ) -> StatusAndReason:
         """This is like ``upgrade_domain``, but it only applies the specified
@@ -456,13 +466,22 @@ def upgrade_soma_joinid_shape(
         taken.  If ``check_only`` is ``True``, returns whether the operation
         would succeed if attempted, and a reason why it would not.
         """
+        frame = inspect.currentframe()
+        function_name_for_messages = frame.f_code.co_name if frame else "tiledbsoma"
+
         if check_only:
             return cast(
                 StatusAndReason,
-                self._handle._handle.can_upgrade_soma_joinid_shape(newshape),
+                self._handle._handle.can_upgrade_soma_joinid_shape(
+                    newshape,
+                    function_name_for_messages=function_name_for_messages,
+                ),
             )
         else:
-            self._handle._handle.upgrade_soma_joinid_shape(newshape)
+            self._handle._handle.upgrade_soma_joinid_shape(
+                newshape,
+                function_name_for_messages=function_name_for_messages,
+            )
             return (True, "")
 
     def __len__(self) -> int:
diff --git a/apis/python/src/tiledbsoma/_tdb_handles.py b/apis/python/src/tiledbsoma/_tdb_handles.py
index 7930fd2c26..aadbc11ef2 100644
--- a/apis/python/src/tiledbsoma/_tdb_handles.py
+++ b/apis/python/src/tiledbsoma/_tdb_handles.py
@@ -38,6 +38,9 @@
 from ._types import METADATA_TYPES, Metadatum, OpenTimestamp, StatusAndReason
 from .options._soma_tiledb_context import SOMATileDBContext
 
+AxisDomain = Union[None, Tuple[Any, Any], List[Any]]
+Domain = Sequence[AxisDomain]
+
 RawHandle = Union[
     clib.SOMAArray,
     clib.SOMADataFrame,
@@ -478,19 +481,27 @@ def tiledbsoma_can_upgrade_shape(
         """Not implemented for DataFrame."""
         raise NotImplementedError
 
-    def resize_soma_joinid_shape(self, newshape: int) -> None:
+    def resize_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> None:
         """Only implemented for DataFrame."""
         raise NotImplementedError
 
-    def can_resize_soma_joinid_shape(self, newshape: int) -> StatusAndReason:
+    def can_resize_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> StatusAndReason:
         """Only implemented for DataFrame."""
         raise NotImplementedError
 
-    def upgrade_soma_joinid_shape(self, newshape: int) -> None:
+    def upgrade_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> None:
         """Only implemented for DataFrame."""
         raise NotImplementedError
 
-    def can_upgrade_soma_joinid_shape(self, newshape: int) -> StatusAndReason:
+    def can_upgrade_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> StatusAndReason:
         """Only implemented for DataFrame."""
         raise NotImplementedError
 
@@ -509,80 +520,51 @@ def write(self, values: pa.RecordBatch) -> None:
 
     @property
     def maybe_soma_joinid_shape(self) -> Optional[int]:
-        """Return the shape slot for the soma_joinid dim, if the array has one.
-        This is an important test-point and dev-internal access-point,
-        in particular, for the tiledbsoma-io experiment-level resizer.
-
-        Lifecycle:
-            Maturing.
-        """
+        """Wrapper-class internals"""
         return cast(Optional[int], self._handle.maybe_soma_joinid_shape)
 
     @property
     def maybe_soma_joinid_maxshape(self) -> Optional[int]:
-        """Return the maxshape slot for the soma_joinid dim, if the array has one.
-        This is an important test-point and dev-internal access-point,
-        in particular, for the tiledbsoma-io experiment-level resizer.
-
-        Lifecycle:
-            Maturing.
-        """
+        """Wrapper-class internals"""
         return cast(Optional[int], self._handle.maybe_soma_joinid_maxshape)
 
     @property
     def tiledbsoma_has_upgraded_domain(self) -> bool:
-        """Returns true if the array has the upgraded resizeable domain feature
-        from TileDB-SOMA 1.15: the array was created with this support, or it has
-        had ``.tiledbsoma_upgrade_domain`` applied to it.
-
-        Lifecycle:
-            Maturing.
-        """
+        """Wrapper-class internals"""
         return cast(bool, self._handle.tiledbsoma_has_upgraded_domain)
 
-    def resize_soma_joinid_shape(self, newshape: int) -> None:
-        """Increases the shape of the dataframe on the ``soma_joinid`` index
-        column, if it indeed is an index column, leaving all other index columns
-        as-is. If the ``soma_joinid`` is not an index column, no change is made.
-        This is a special case of ``upgrade_domain`` (WIP for 1.15), but simpler
-        to keystroke, and handles the most common case for dataframe domain
-        expansion.  Raises an error if the dataframe doesn't already have a
-        domain: in that case please call ``tiledbsoma_upgrade_domain`` (WIP for
-        1.15).
-        """
-        self._handle.resize_soma_joinid_shape(newshape)
-
-    def can_resize_soma_joinid_shape(self, newshape: int) -> StatusAndReason:
-        """Increases the shape of the dataframe on the ``soma_joinid`` index
-        column, if it indeed is an index column, leaving all other index columns
-        as-is. If the ``soma_joinid`` is not an index column, no change is made.
-        This is a special case of ``upgrade_domain`` (WIP for 1.15), but simpler
-        to keystroke, and handles the most common case for dataframe domain
-        expansion.  Raises an error if the dataframe doesn't already have a
-        domain: in that case please call ``tiledbsoma_upgrade_domain`` (WIP for
-        1.15).  If ``check_only`` is ``True``, returns whether the operation
-        would succeed if attempted, and a reason why it would not.
-        """
+    def resize_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> None:
+        """Wrapper-class internals"""
+        self._handle.resize_soma_joinid_shape(newshape, function_name_for_messages)
+
+    def can_resize_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> StatusAndReason:
+        """Wrapper-class internals"""
         return cast(
-            StatusAndReason, self._handle.can_resize_soma_joinid_shape(newshape)
+            StatusAndReason,
+            self._handle.can_resize_soma_joinid_shape(
+                newshape, function_name_for_messages
+            ),
         )
 
-    def upgrade_soma_joinid_shape(self, newshape: int) -> None:
-        """This is like ``upgrade_domain``, but it only applies the specified domain
-        update to the ``soma_joinid`` index column. Any other index columns have their
-        domain set to match the maxdomain. If the ``soma_joinid`` column is not an index
-        column at all, then no action is taken."""
-        self._handle.upgrade_soma_joinid_shape(newshape)
-
-    def can_upgrade_soma_joinid_shape(self, newshape: int) -> StatusAndReason:
-        """This allows you to see if ``upgrade_soma_joinid_shape`` will succeed
-        before calling it.  This is an important test-point and dev-internal
-        access-point, in particular, for the tiledbsoma-io experiment-level
-        resizer.  If ``check_only`` is ``True``, returns whether the operation
-        would succeed if attempted, and a reason why it would not.
-        """
+    def upgrade_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> None:
+        """Wrapper-class internals"""
+        self._handle.upgrade_soma_joinid_shape(newshape, function_name_for_messages)
+
+    def can_upgrade_soma_joinid_shape(
+        self, newshape: int, function_name_for_messages: str
+    ) -> StatusAndReason:
+        """Wrapper-class internals"""
         return cast(
-            StatusAndReason, self._handle.can_upgrade_soma_joinid_shape(newshape)
+            StatusAndReason,
+            self._handle.can_upgrade_soma_joinid_shape(
+                newshape, function_name_for_messages
+            ),
         )
 
 
@@ -606,19 +588,11 @@ class DenseNDArrayWrapper(SOMAArrayWrapper[clib.SOMADenseNDArray]):
 
     @property
     def tiledbsoma_has_upgraded_shape(self) -> bool:
-        """Returns true if the array has the upgraded resizeable shape feature
-        from TileDB-SOMA 1.15: the array was created with this support, or it has
-        had ``.tiledbsoma_upgrade_shape`` applied to it.
-
-        Lifecycle:
-            Maturing.
-        """
+        """Wrapper-class internals"""
         return cast(bool, self._handle.tiledbsoma_has_upgraded_shape)
 
     def resize(self, newshape: Sequence[Union[int, None]]) -> None:
-        """Supported for ``SparseNDArray``; scheduled for implementation for
-        ``DenseNDArray`` in TileDB-SOMA 1.15
-        """
+        """Wrapper-class internals"""
         if clib.embedded_version_triple() >= (2, 27, 0):
             self._handle.resize(newshape)
         else:
@@ -627,9 +601,7 @@ def resize(self, newshape: Sequence[Union[int, None]]) -> None:
     def tiledbsoma_can_resize(
         self, newshape: Sequence[Union[int, None]]
     ) -> StatusAndReason:
-        """Supported for ``SparseNDArray``; scheduled for implementation for
-        ``DenseNDArray`` in TileDB-SOMA 1.15.
-        """
+        """Wrapper-class internals"""
         if clib.embedded_version_triple() >= (2, 27, 0):
             return cast(StatusAndReason, self._handle.tiledbsoma_can_resize(newshape))
         else:
@@ -647,49 +619,27 @@ def nnz(self) -> int:
 
     @property
     def tiledbsoma_has_upgraded_shape(self) -> bool:
-        """Returns true if the array has the upgraded resizeable shape feature
-        from TileDB-SOMA 1.15: the array was created with this support, or it has
-        had ``.tiledbsoma_upgrade_shape`` applied to it.
-
-        Lifecycle:
-            Maturing.
-        """
+        """Wrapper-class internals"""
         return cast(bool, self._handle.tiledbsoma_has_upgraded_shape)
 
     def resize(self, newshape: Sequence[Union[int, None]]) -> None:
-        """Increases the shape of the array as specfied. Raises an error if the new
-        shape is less than the current shape in any dimension. Raises an error if
-        the new shape exceeds maxshape in any dimension. Raises an error if the
-        array doesn't already have a shape: in that case please call
-        tiledbsoma_upgrade_shape.
-        """
+        """Wrapper-class internals"""
         self._handle.resize(newshape)
 
     def tiledbsoma_can_resize(
         self, newshape: Sequence[Union[int, None]]
     ) -> StatusAndReason:
-        """This allows you to see if ``resize`` will succeed before calling it.
-        This is an important test-point and dev-internal access-point, in
-        particular, for the tiledbsoma-io experiment-level resizer.  If
-        ``check_only`` is ``True``, returns whether the operation would succeed
-        if attempted, and a reason why it would not.
-        """
+        """Wrapper-class internals"""
         return cast(StatusAndReason, self._handle.can_resize(newshape))
 
     def tiledbsoma_upgrade_shape(self, newshape: Sequence[Union[int, None]]) -> None:
-        """Allows the array to have a resizeable shape as described in the TileDB-SOMA
-        1.15 release notes.  Raises an error if the new shape exceeds maxshape in
-        any dimension. Raises an error if the array already has a shape.
-        """
+        """Wrapper-class internals"""
         self._handle.tiledbsoma_upgrade_shape(newshape)
 
     def tiledbsoma_can_upgrade_shape(
         self, newshape: Sequence[Union[int, None]]
     ) -> StatusAndReason:
-        """Allows the array to have a resizeable shape as described in the TileDB-SOMA
-        1.15 release notes.  Raises an error if the new shape exceeds maxshape in
-        any dimension. Raises an error if the array already has a shape.
-        """
+        """Wrapper-class internals"""
         return cast(
             StatusAndReason, self._handle.tiledbsoma_can_upgrade_shape(newshape)
         )
diff --git a/apis/python/src/tiledbsoma/io/shaping.py b/apis/python/src/tiledbsoma/io/shaping.py
index 4dc8f47a69..ddd5e30eac 100644
--- a/apis/python/src/tiledbsoma/io/shaping.py
+++ b/apis/python/src/tiledbsoma/io/shaping.py
@@ -287,20 +287,20 @@ def _leaf_visitor_upgrade(
         _print_leaf_node_banner("DataFrame", node_name, item.uri, args)
         if check_only:
             print(
-                f"  Dry run for: upgrade_soma_joinid_shape({count})",
+                f"  Dry run for: tiledbsoma_upgrade_soma_joinid_shape({count})",
                 file=args["output_handle"],
             )
-            ok, msg = item.upgrade_soma_joinid_shape(count, check_only=True)
+            ok, msg = item.tiledbsoma_upgrade_soma_joinid_shape(count, check_only=True)
             _print_dry_run_result(ok, msg, args)
             retval = retval and ok
         elif not item.tiledbsoma_has_upgraded_domain:
             if verbose:
                 print(
-                    f"  Applying upgrade_soma_joinid_shape({count})",
+                    f"  Applying tiledbsoma_upgrade_soma_joinid_shape({count})",
                     file=args["output_handle"],
                 )
             with tiledbsoma.DataFrame.open(item.uri, "w") as writer:
-                writer.upgrade_soma_joinid_shape(count)
+                writer.tiledbsoma_upgrade_soma_joinid_shape(count)
         else:
             if verbose:
                 print("  Already upgraded", file=args["output_handle"])
@@ -312,7 +312,8 @@ def _leaf_visitor_upgrade(
         _print_leaf_node_banner("SparseNDArray", node_name, item.uri, args)
         if check_only:
             print(
-                f"  Dry run for: upgrade_shape({new_shape})", file=args["output_handle"]
+                f"  Dry run for: tiledbsoma_upgrade_shape({new_shape})",
+                file=args["output_handle"],
             )
             ok, msg = item.tiledbsoma_upgrade_shape(new_shape, check_only=True)
             _print_dry_run_result(ok, msg, args)
@@ -320,7 +321,8 @@ def _leaf_visitor_upgrade(
         elif not item.tiledbsoma_has_upgraded_shape:
             if verbose:
                 print(
-                    f"  Applying upgrade_shape({new_shape})", file=args["output_handle"]
+                    f"  Applying tiledbsoma_upgrade_shape({new_shape})",
+                    file=args["output_handle"],
                 )
             with tiledbsoma.SparseNDArray.open(item.uri, "w") as writer:
                 writer.tiledbsoma_upgrade_shape(new_shape)
@@ -368,10 +370,10 @@ def _leaf_visitor_resize(
 
         if check_only:
             print(
-                f"  Dry run for: resize_soma_joinid_shape({new_soma_joinid_shape})",
+                f"  Dry run for: tiledbsoma_resize_soma_joinid_shape({new_soma_joinid_shape})",
                 file=args["output_handle"],
             )
-            ok, msg = item.resize_soma_joinid_shape(
+            ok, msg = item.tiledbsoma_resize_soma_joinid_shape(
                 new_soma_joinid_shape, check_only=True
             )
             _print_dry_run_result(ok, msg, args)
@@ -379,11 +381,11 @@ def _leaf_visitor_resize(
         else:
             if verbose:
                 print(
-                    f"  Applying resize_soma_joinid_shape({new_soma_joinid_shape})",
+                    f"  Applying tiledbsoma_resize_soma_joinid_shape({new_soma_joinid_shape})",
                     file=args["output_handle"],
                 )
             with tiledbsoma.DataFrame.open(item.uri, "w") as writer:
-                writer.resize_soma_joinid_shape(new_soma_joinid_shape)
+                writer.tiledbsoma_resize_soma_joinid_shape(new_soma_joinid_shape)
 
     elif isinstance(item, tiledbsoma.SparseNDArray):
 
diff --git a/apis/python/src/tiledbsoma/soma_dataframe.cc b/apis/python/src/tiledbsoma/soma_dataframe.cc
index 0cde18b7b6..c7075ce564 100644
--- a/apis/python/src/tiledbsoma/soma_dataframe.cc
+++ b/apis/python/src/tiledbsoma/soma_dataframe.cc
@@ -160,50 +160,63 @@ void load_soma_dataframe(py::module& m) {
 
         .def(
             "resize_soma_joinid_shape",
-            [](SOMADataFrame& sdf, int64_t newshape) {
+            [](SOMADataFrame& sdf,
+               int64_t newshape,
+               std::string function_name_for_messages) {
                 try {
                     sdf.resize_soma_joinid_shape(
-                        newshape, "resize_soma_joinid_shape");
+                        newshape, function_name_for_messages);
                 } catch (const std::exception& e) {
                     throw TileDBSOMAError(e.what());
                 }
             },
-            "newshape"_a)
+            "newshape"_a,
+            "function_name_for_messages"_a)
 
         .def(
             "can_resize_soma_joinid_shape",
-            [](SOMADataFrame& sdf, int64_t newshape) {
+            [](SOMADataFrame& sdf,
+               int64_t newshape,
+               std::string function_name_for_messages) {
                 try {
                     return sdf.can_resize_soma_joinid_shape(
-                        newshape, "can_resize_soma_joinid_shape");
+                        newshape, function_name_for_messages);
                 } catch (const std::exception& e) {
                     throw TileDBSOMAError(e.what());
                 }
             },
-            "newshape"_a)
+            "newshape"_a,
+            "function_name_for_messages"_a)
 
         .def(
             "upgrade_soma_joinid_shape",
-            [](SOMADataFrame& sdf, int64_t newshape) {
+            [](SOMADataFrame& sdf,
+               int64_t newshape,
+               std::string function_name_for_messages) {
                 try {
                     sdf.upgrade_soma_joinid_shape(
-                        newshape, "upgrade_soma_joinid_shape");
+                        newshape, function_name_for_messages);
                 } catch (const std::exception& e) {
                     throw TileDBSOMAError(e.what());
                 }
             },
-            "newshape"_a)
+            "newshape"_a,
+            "function_name_for_messages"_a)
 
         .def(
             "can_upgrade_soma_joinid_shape",
-            [](SOMADataFrame& sdf, int64_t newshape) {
+            [](SOMADataFrame& sdf,
+               int64_t newshape,
+               std::string function_name_for_messages) {
                 try {
                     return sdf.can_upgrade_soma_joinid_shape(
-                        newshape, "can_upgrade_soma_joinid_shape");
+                        newshape, function_name_for_messages);
                 } catch (const std::exception& e) {
                     throw TileDBSOMAError(e.what());
                 }
             },
-            "newshape"_a);
+            "newshape"_a,
+            "function_name_for_messages"_a);
 }
+
 }  // namespace libtiledbsomacpp
diff --git a/libtiledbsoma/src/soma/soma_array.cc b/libtiledbsoma/src/soma/soma_array.cc
index dcc96a0a1a..b9f664327c 100644
--- a/libtiledbsoma/src/soma/soma_array.cc
+++ b/libtiledbsoma/src/soma/soma_array.cc
@@ -1452,7 +1452,7 @@ std::vector<int64_t> SOMAArray::maxshape() {
 // much overlap.
 StatusAndReason SOMAArray::_can_set_shape_helper(
     const std::vector<int64_t>& newshape,
-    bool is_resize,
+    bool must_already_have,
     std::string function_name_for_messages) {
     // E.g. it's an error to try to upgrade_domain or resize specifying
     // a 3-D shape on a 2-D array.
@@ -1472,7 +1472,7 @@ StatusAndReason SOMAArray::_can_set_shape_helper(
     // only on arrays that don't have a shape set, and resize must be called
     // only on arrays that do.
     bool has_shape = has_current_domain();
-    if (is_resize) {
+    if (must_already_have) {
         // They're trying to do resize on an array that doesn't already have a
         // shape.
         if (!has_shape) {
@@ -1588,10 +1588,12 @@ StatusAndReason SOMAArray::_can_set_shape_domainish_subhelper(
 }
 
 StatusAndReason SOMAArray::_can_set_soma_joinid_shape_helper(
-    int64_t newshape, bool is_resize, std::string function_name_for_messages) {
+    int64_t newshape,
+    bool must_already_have,
+    std::string function_name_for_messages) {
     // Fail if the array doesn't already have a shape yet (they should upgrade
     // first).
-    if (!is_resize) {
+    if (!must_already_have) {
         // Upgrading an array to give it a current domain
         if (has_current_domain()) {
             return std::pair(
@@ -1619,7 +1621,7 @@ StatusAndReason SOMAArray::_can_set_soma_joinid_shape_helper(
     }
 
     // Fail if the newshape isn't within the array's core current domain.
-    if (is_resize) {
+    if (must_already_have) {
         std::pair cur_dom_lo_hi = _core_current_domain_slot<int64_t>(
             "soma_joinid");
         if (newshape < cur_dom_lo_hi.second) {
@@ -1651,7 +1653,7 @@ StatusAndReason SOMAArray::_can_set_soma_joinid_shape_helper(
 
 void SOMAArray::_set_shape_helper(
     const std::vector<int64_t>& newshape,
-    bool is_resize,
+    bool must_already_have,
     std::string function_name_for_messages) {
     if (mq_->query_type() != TILEDB_WRITE) {
         throw TileDBSOMAError(fmt::format(
@@ -1659,7 +1661,7 @@ void SOMAArray::_set_shape_helper(
             function_name_for_messages));
     }
 
-    if (!is_resize) {
+    if (!must_already_have) {
         // Upgrading an array to install a current domain
         if (!_get_current_domain().is_empty()) {
             throw TileDBSOMAError(fmt::format(
@@ -1706,14 +1708,16 @@ void SOMAArray::_set_shape_helper(
 }
 
 void SOMAArray::_set_soma_joinid_shape_helper(
-    int64_t newshape, bool is_resize, std::string function_name_for_messages) {
+    int64_t newshape,
+    bool must_already_have,
+    std::string function_name_for_messages) {
     if (mq_->query_type() != TILEDB_WRITE) {
         throw TileDBSOMAError(fmt::format(
             "{}: array must be opened in write mode",
             function_name_for_messages));
     }
 
-    if (!is_resize) {
+    if (!must_already_have) {
         // Upgrading an array to install a current domain
         if (!_get_current_domain().is_empty()) {
             throw TileDBSOMAError(fmt::format(
@@ -1736,7 +1740,7 @@ void SOMAArray::_set_soma_joinid_shape_helper(
     ArraySchemaEvolution schema_evolution(*tctx);
     CurrentDomain new_current_domain(*tctx);
 
-    if (!is_resize) {
+    if (!must_already_have) {
         // For upgrade: copy from the full/wide/max domain except for the
         // soma_joinid restriction.
 
@@ -1884,37 +1888,52 @@ void SOMAArray::_set_soma_joinid_shape_helper(
     schema_evolution.array_evolve(uri_);
 }
 
-StatusAndReason SOMAArray::can_upgrade_domain(
-    const ArrowTable& newdomain, std::string function_name_for_messages) {
+StatusAndReason SOMAArray::_can_set_domain_helper(
+    const ArrowTable& newdomain,
+    bool must_already_have,
+    std::string function_name_for_messages) {
     // Enforce the semantics that tiledbsoma_upgrade_domain must be called
     // only on arrays that don't have a shape set, and resize must be called
     // only on arrays that do.
-    if (has_current_domain()) {
-        return std::pair(
-            false,
-            fmt::format(
-                "{}: dataframe already has a domain",
-                function_name_for_messages));
+    if (must_already_have) {
+        if (!has_current_domain()) {
+            return std::pair(
+                false,
+                fmt::format(
+                    "{}: dataframe does not have a domain: please upgrade it",
+                    function_name_for_messages));
+        }
+    } else {
+        if (has_current_domain()) {
+            return std::pair(
+                false,
+                fmt::format(
+                    "{}: dataframe already has a domain",
+                    function_name_for_messages));
+        }
     }
 
-    // * For old-style dataframe without shape: core domain (soma maxdomain) may
+    // * For old-style dataframe without shape: core domain (soma maxdomain)
+    // may
     //   be small (like 100) or big (like 2 billionish).
-    // * For new-style dataframe with shape: core current domain (soma domain)
-    //   will probably be small and core domain (soma maxdomain) will be huge.
+    // * For new-style dataframe with shape: core current domain (soma
+    // domain)
+    //   will probably be small and core domain (soma maxdomain) will be
+    //   huge.
     //
-    // In either case, we need to check that the user's requested soma domain
-    // isn't outside the core domain, which is immutable. For old-style
-    // dataframes, if the requested domain fits in the array's core domain, it's
-    // good to go as a new soma domain.
+    // In either case, we need to check that the user's requested soma
+    // domain isn't outside the core domain, which is immutable. For
+    // old-style dataframes, if the requested domain fits in the array's
+    // core domain, it's good to go as a new soma domain.
     auto domain_check = _can_set_dataframe_domainish_subhelper(
         newdomain, false, function_name_for_messages);
     if (!domain_check.first) {
         return domain_check;
     }
 
-    // For new-style dataframes, we need to additionally that the the requested
-    // soma domain (core current domain) isn't a downsize of the current
-    // one.
+    // For new-style dataframes, we need to additionally that the the
+    // requested soma domain (core current domain) isn't a downsize of the
+    // current one.
     if (has_current_domain()) {
         auto current_domain_check = _can_set_dataframe_domainish_subhelper(
             newdomain, true, function_name_for_messages);
@@ -2067,6 +2086,165 @@ StatusAndReason SOMAArray::_can_set_dataframe_domainish_subhelper(
     return std::pair(true, "");
 }
 
+void SOMAArray::_set_domain_helper(
+    const ArrowTable& newdomain,
+    bool must_already_have,
+    std::string function_name_for_messages) {
+    if (mq_->query_type() != TILEDB_WRITE) {
+        throw TileDBSOMAError(fmt::format(
+            "{}: array must be opened in write mode",
+            function_name_for_messages));
+    }
+
+    if (must_already_have) {
+        if (!has_current_domain()) {
+            throw TileDBSOMAError(fmt::format(fmt::format(
+                "{}: dataframe does not have a domain: please upgrade it",
+                function_name_for_messages)));
+        }
+    } else {
+        if (has_current_domain()) {
+            throw TileDBSOMAError(fmt::format(fmt::format(
+                "{}: dataframe already has a domain",
+                function_name_for_messages)));
+        }
+    }
+
+    Domain domain = arr_->schema().domain();
+
+    ArrowArray* new_domain_array = newdomain.first.get();
+    ArrowSchema* new_domain_schema = newdomain.second.get();
+
+    if (new_domain_schema->n_children != domain.ndim()) {
+        throw TileDBSOMAError(fmt::format(fmt::format(
+            "{}: requested domain has ndim={} but the dataframe has "
+            "ndim={}",
+            function_name_for_messages,
+            new_domain_schema->n_children,
+            domain.ndim())));
+    }
+
+    if (new_domain_schema->n_children != new_domain_array->n_children) {
+        throw TileDBSOMAError(fmt::format(fmt::format(
+            "{}: internal coding error", function_name_for_messages)));
+    }
+
+    auto tctx = ctx_->tiledb_ctx();
+    NDRectangle ndrect(*tctx, domain);
+    CurrentDomain new_current_domain(*tctx);
+    ArraySchemaEvolution schema_evolution(*tctx);
+
+    for (unsigned i = 0; i < domain.ndim(); i++) {
+        const Dimension& dim = domain.dimension(i);
+        const std::string dim_name = dim.name();
+
+        switch (dim.type()) {
+            case TILEDB_STRING_ASCII:
+            case TILEDB_STRING_UTF8:
+            case TILEDB_CHAR:
+            case TILEDB_GEOM_WKB:
+            case TILEDB_GEOM_WKT: {
+                auto lo_hi = ArrowAdapter::get_table_string_column_by_index(
+                    newdomain, i);
+                if (lo_hi[0] == "" && lo_hi[1] == "") {
+                    // Don't care -> as big as possible
+                    ndrect.set_range(dim_name, "", "\xff");
+                } else {
+                    throw TileDBSOMAError(
+                        fmt::format("domain (\"{}\", \"{}\") cannot be set for "
+                                    "string index columns: please use "
+                                    "(\"\", \"\")"));
+                }
+            } break;
+
+            case TILEDB_INT8: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    int8_t>(newdomain, i);
+                ndrect.set_range<int8_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_BOOL:
+            case TILEDB_UINT8: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    uint8_t>(newdomain, i);
+                ndrect.set_range<uint8_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_INT16: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    int16_t>(newdomain, i);
+                ndrect.set_range<int16_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_UINT16: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    uint16_t>(newdomain, i);
+                ndrect.set_range<uint16_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_INT32: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    int32_t>(newdomain, i);
+                ndrect.set_range<int32_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_UINT32: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    uint32_t>(newdomain, i);
+                ndrect.set_range<uint32_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_INT64:
+            case TILEDB_DATETIME_YEAR:
+            case TILEDB_DATETIME_MONTH:
+            case TILEDB_DATETIME_WEEK:
+            case TILEDB_DATETIME_DAY:
+            case TILEDB_DATETIME_HR:
+            case TILEDB_DATETIME_MIN:
+            case TILEDB_DATETIME_SEC:
+            case TILEDB_DATETIME_MS:
+            case TILEDB_DATETIME_US:
+            case TILEDB_DATETIME_NS:
+            case TILEDB_DATETIME_PS:
+            case TILEDB_DATETIME_FS:
+            case TILEDB_DATETIME_AS:
+            case TILEDB_TIME_HR:
+            case TILEDB_TIME_MIN:
+            case TILEDB_TIME_SEC:
+            case TILEDB_TIME_MS:
+            case TILEDB_TIME_US:
+            case TILEDB_TIME_NS:
+            case TILEDB_TIME_PS:
+            case TILEDB_TIME_FS:
+            case TILEDB_TIME_AS: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    int64_t>(newdomain, i);
+                ndrect.set_range<int64_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_UINT64: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    uint64_t>(newdomain, i);
+                ndrect.set_range<uint64_t>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_FLOAT32: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    float>(newdomain, i);
+                ndrect.set_range<float>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            case TILEDB_FLOAT64: {
+                auto lo_hi = ArrowAdapter::get_table_non_string_column_by_index<
+                    double>(newdomain, i);
+                ndrect.set_range<double>(dim_name, lo_hi[0], lo_hi[1]);
+            } break;
+            default:
+                throw TileDBSOMAError(fmt::format(
+                    "{}: internal error: unhandled type {} for {}.",
+                    function_name_for_messages,
+                    tiledb::impl::type_to_str(dim.type()),
+                    dim_name));
+        }
+    }
+
+    new_current_domain.set_ndrectangle(ndrect);
+
+    schema_evolution.expand_current_domain(new_current_domain);
+    schema_evolution.array_evolve(uri_);
+}
+
 std::vector<int64_t> SOMAArray::_tiledb_current_domain() {
     // Variant-indexed dataframes must use a separate path
     _check_dims_are_int64();
diff --git a/libtiledbsoma/src/soma/soma_array.h b/libtiledbsoma/src/soma/soma_array.h
index ed4619baeb..3bae58a912 100644
--- a/libtiledbsoma/src/soma/soma_array.h
+++ b/libtiledbsoma/src/soma/soma_array.h
@@ -1201,7 +1201,19 @@ class SOMAArray : public SOMAObject {
      * This is for SOMADataFrame.
      */
     StatusAndReason can_upgrade_domain(
-        const ArrowTable& newdomain, std::string function_name_for_messages);
+        const ArrowTable& newdomain, std::string function_name_for_messages) {
+        return _can_set_domain_helper(
+            newdomain, false, function_name_for_messages);
+    }
+
+    /**
+     * This is for SOMADataFrame.
+     */
+    StatusAndReason can_change_domain(
+        const ArrowTable& newdomain, std::string function_name_for_messages) {
+        return _can_set_domain_helper(
+            newdomain, true, function_name_for_messages);
+    }
 
     /**
      * @brief Resize the shape (what core calls "current domain") up to the
@@ -1274,9 +1286,21 @@ class SOMAArray : public SOMAObject {
 
     /**
      * This is for SOMADataFrame.
+     * XXX comment more
      */
     void upgrade_domain(
-        const ArrowTable& newdomain, std::string function_name_for_messages);
+        const ArrowTable& newdomain, std::string function_name_for_messages) {
+        _set_domain_helper(newdomain, false, function_name_for_messages);
+    }
+
+    /**
+     * This is for SOMADataFrame.
+     * XXX comment more
+     */
+    void change_domain(
+        const ArrowTable& newdomain, std::string function_name_for_messages) {
+        _set_domain_helper(newdomain, true, function_name_for_messages);
+    }
 
    protected:
     // See top-of-file notes regarding methods for SOMADataFrame being
@@ -1336,7 +1360,16 @@ class SOMAArray : public SOMAObject {
      */
     StatusAndReason _can_set_shape_helper(
         const std::vector<int64_t>& newshape,
-        bool is_resize,
+        bool must_already_have,
+        std::string function_name_for_messages);
+
+    /**
+     * This is a code-dedupe helper method for can_change_domain and
+     * can_upgrade_domain.
+     */
+    StatusAndReason _can_set_domain_helper(
+        const ArrowTable& newdomain,
+        bool must_already_have,
         std::string function_name_for_messages);
 
     /**
@@ -1361,7 +1394,7 @@ class SOMAArray : public SOMAObject {
      */
     StatusAndReason _can_set_soma_joinid_shape_helper(
         int64_t newshape,
-        bool is_resize,
+        bool must_already_have,
         std::string function_name_for_messages);
 
     /**
@@ -1369,7 +1402,7 @@ class SOMAArray : public SOMAObject {
      */
     void _set_shape_helper(
         const std::vector<int64_t>& newshape,
-        bool is_resize,
+        bool must_already_have,
         std::string function_name_for_messages);
 
     /**
@@ -1378,7 +1411,15 @@ class SOMAArray : public SOMAObject {
      */
     void _set_soma_joinid_shape_helper(
         int64_t newshape,
-        bool is_resize,
+        bool must_already_have,
+        std::string function_name_for_messages);
+
+    /**
+     * This is a code-dedupe helper method for change_domain and upgrade_domain.
+     */
+    void _set_domain_helper(
+        const ArrowTable& newdomain,
+        bool must_already_have,
         std::string function_name_for_messages);
 
     /**
@@ -1430,13 +1471,9 @@ class SOMAArray : public SOMAObject {
      * This is a helper for can_upgrade_domain.
      */
     StatusAndReason _can_set_dataframe_domainish_slot_checker_string(
-        bool check_current_domain,
+        bool /*check_current_domain*/,
         const ArrowTable& domain_table,
         std::string dim_name) {
-        std::pair<std::string, std::string>
-            old_lo_hi = check_current_domain ?
-                            _core_current_domain_slot_string(dim_name) :
-                            _core_domain_slot_string(dim_name);
         std::vector<std::string>
             new_lo_hi = ArrowAdapter::get_table_string_column_by_name(
                 domain_table, dim_name);
@@ -1446,28 +1483,16 @@ class SOMAArray : public SOMAObject {
                 "_can_set_dataframe_domainish_slot_checker");
         }
 
-        const std::string& old_lo = old_lo_hi.first;
-        const std::string& old_hi = old_lo_hi.second;
         const std::string& new_lo = new_lo_hi[0];
         const std::string& new_hi = new_lo_hi[1];
 
-        // It's difficult to use fmt::format within a header file since the
-        // include path to logger.h 'moves around' depending on which source
-        // file included us.
-        //
-        // TODO: once we're on C++ 20, just use std::format here and include
-        // things like "old ({}, {}) new ({}, {})".
-        if (new_lo > new_hi) {
-            return std::pair(false, "new lower > new upper");
-        }
-        if (new_lo > old_lo) {
+        if (new_lo != "" || new_hi != "") {
             return std::pair(
-                false, "new lower > old lower (downsize is unsupported)");
-        }
-        if (new_hi < old_hi) {
-            return std::pair(
-                false, "new upper < old upper (downsize is unsupported)");
+                false,
+                "domain cannot be set for string index columns: please use "
+                "(\"\", \"\")");
         }
+
         return std::pair(true, "");
     }
 


Test patch:
diff --git a/apis/python/tests/test_dataframe.py b/apis/python/tests/test_dataframe.py
index 162d23865c..589cbfebad 100644
--- a/apis/python/tests/test_dataframe.py
+++ b/apis/python/tests/test_dataframe.py
@@ -83,7 +83,7 @@ def test_dataframe(tmp_path, arrow_schema):
             rb = pa.Table.from_pydict(pydict)
 
             if soma._flags.NEW_SHAPE_FEATURE_FLAG_ENABLED:
-                sdf.resize_soma_joinid_shape(len(rb))
+                sdf.tiledbsoma_resize_soma_joinid_shape(len(rb))
 
             sdf.write(rb)
 
diff --git a/libtiledbsoma/test/unit_soma_dataframe.cc b/libtiledbsoma/test/unit_soma_dataframe.cc
index 131fa405a4..17272aafeb 100644
--- a/libtiledbsoma/test/unit_soma_dataframe.cc
+++ b/libtiledbsoma/test/unit_soma_dataframe.cc
@@ -515,271 +515,339 @@ TEST_CASE_METHOD(
     std::ostringstream section;
     section << "- use_current_domain=" << use_current_domain;
     SECTION(section.str()) {
-        std::string suffix = use_current_domain ? "true" : "false";
-        set_up(
-            std::make_shared<SOMAContext>(),
-            "mem://unit-test-variant-indexed-dataframe-1-" + suffix);
-
-        std::vector<helper::DimInfo> dim_infos(
-            {i64_dim_info(use_current_domain)});
-        std::vector<helper::AttrInfo> attr_infos(
-            {str_attr_info(), u32_attr_info()});
-
-        // Create
-        create(dim_infos, attr_infos);
-
-        // Check current domain
-        auto sdf = open(OpenMode::read);
-
-        CurrentDomain current_domain = sdf->get_current_domain_for_test();
-        if (!use_current_domain) {
-            REQUIRE(current_domain.is_empty());
-        } else {
-            REQUIRE(!current_domain.is_empty());
-            REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
-            NDRectangle ndrect = current_domain.ndrectangle();
-
-            std::array<int64_t, 2> i64_range = ndrect.range<int64_t>(
-                dim_infos[0].name);
-            REQUIRE(i64_range[0] == (int64_t)0);
-            REQUIRE(i64_range[1] == (int64_t)dim_infos[0].dim_max);
-        }
-
-        // Check shape before resize
-        int64_t expect = dim_infos[0].dim_max + 1;
-        std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(actual.has_value());
-        REQUIRE(actual.value() == expect);
-
-        REQUIRE(sdf->nnz() == 0);
+        std::string suffix1 = use_current_domain ? "true" : "false";
+        // We have these:
+        // * upgrade_domain requires the user to specify values for all index
+        //   columns. This is in the spec.
+        // * resize_soma_joinid_shape allows the user to specify only the
+        //   desired soma_joinid shape. This is crucial for experiment-level
+        //   resize as an internal method at the Python level.
+        // Both need testing. Each one adds a shape where there wasn't one
+        // before. So we need to test one or the other on a given run.
+        auto test_upgrade_domain = GENERATE(false, true);
+        std::ostringstream section2;
+        section2 << "- test_upgrade_domain=" << test_upgrade_domain;
+        SECTION(section2.str()) {
+            std::string suffix2 = test_upgrade_domain ? "true" : "false";
 
-        sdf->close();
+            set_up(
+                std::make_shared<SOMAContext>(),
+                "mem://unit-test-variant-indexed-dataframe-1-" + suffix1 + "-" +
+                    suffix2);
 
-        // Write data
-        write_sjid_u32_str_data_from(0);
+            std::vector<helper::DimInfo> dim_infos(
+                {i64_dim_info(use_current_domain)});
+            std::vector<helper::AttrInfo> attr_infos(
+                {str_attr_info(), u32_attr_info()});
 
-        // Check shape after write
-        sdf->open(OpenMode::read);
+            // Create
+            create(dim_infos, attr_infos);
 
-        REQUIRE(sdf->nnz() == 2);
+            // Check current domain
+            auto sdf = open(OpenMode::read);
 
-        expect = dim_infos[0].dim_max + 1;
-        actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(actual.has_value());
-        REQUIRE(actual.value() == expect);
+            CurrentDomain current_domain = sdf->get_current_domain_for_test();
+            if (!use_current_domain) {
+                REQUIRE(current_domain.is_empty());
+            } else {
+                REQUIRE(!current_domain.is_empty());
+                REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
+                NDRectangle ndrect = current_domain.ndrectangle();
+
+                std::array<int64_t, 2> i64_range = ndrect.range<int64_t>(
+                    dim_infos[0].name);
+                REQUIRE(i64_range[0] == (int64_t)0);
+                REQUIRE(i64_range[1] == (int64_t)dim_infos[0].dim_max);
+            }
 
-        // Check domainish accessors before resize
-        ArrowTable non_empty_domain = sdf->get_non_empty_domain();
-        std::vector<int64_t> ned_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                non_empty_domain, "soma_joinid");
+            // Check shape before resize
+            int64_t expect = dim_infos[0].dim_max + 1;
+            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+            REQUIRE(actual.has_value());
+            REQUIRE(actual.value() == expect);
 
-        ArrowTable soma_domain = sdf->get_soma_domain();
-        std::vector<int64_t> dom_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                soma_domain, "soma_joinid");
+            REQUIRE(sdf->nnz() == 0);
 
-        ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
-        std::vector<int64_t> maxdom_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                soma_maxdomain, "soma_joinid");
+            sdf->close();
 
-        REQUIRE(ned_sjid == std::vector<int64_t>({1, 2}));
+            // Write data
+            write_sjid_u32_str_data_from(0);
 
-        REQUIRE(dom_sjid == std::vector<int64_t>({0, SOMA_JOINID_DIM_MAX}));
+            // Check shape after write
+            sdf->open(OpenMode::read);
 
-        REQUIRE(maxdom_sjid.size() == 2);
-        REQUIRE(maxdom_sjid[0] == 0);
-        if (!use_current_domain) {
-            REQUIRE(maxdom_sjid[1] == SOMA_JOINID_DIM_MAX);
-        } else {
-            REQUIRE(maxdom_sjid[1] > 2000000000);
-        }
-        sdf->close();
+            REQUIRE(sdf->nnz() == 2);
 
-        REQUIRE(sdf->nnz() == 2);
-        write_sjid_u32_str_data_from(8);
-        REQUIRE(sdf->nnz() == 4);
+            expect = dim_infos[0].dim_max + 1;
+            actual = sdf->maybe_soma_joinid_shape();
+            REQUIRE(actual.has_value());
+            REQUIRE(actual.value() == expect);
 
-        sdf->open(OpenMode::read);
+            // Check domainish accessors before resize
+            ArrowTable non_empty_domain = sdf->get_non_empty_domain();
+            std::vector<int64_t> ned_sjid =
+                ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                    non_empty_domain, "soma_joinid");
 
-        // Check can_upgrade_soma_joinid_shape
-        if (!use_current_domain) {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            REQUIRE(check.first == true);
-            REQUIRE(check.second == "");
-        } else {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe already has its domain set.");
-        }
-        sdf->close();
+            ArrowTable soma_domain = sdf->get_soma_domain();
+            std::vector<int64_t> dom_sjid =
+                ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                    soma_domain, "soma_joinid");
 
-        // Check can_upgrade_domain
-        sdf->open(OpenMode::read);
-        std::unique_ptr<ArrowSchema>
-            domain_schema = create_index_cols_info_schema(dim_infos);
-        auto domain_array = ArrowAdapter::make_arrow_array_parent(
-            dim_infos.size());
-        domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
-            std::vector<int64_t>({0, 0}));
-        auto domain_table = ArrowTable(
-            std::move(domain_array), std::move(domain_schema));
-        if (!use_current_domain) {
-            StatusAndReason check = sdf->can_upgrade_domain(
-                domain_table, "testing");
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing for soma_joinid: new upper < old upper (downsize is "
-                "unsupported)");
-        } else {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe already has its domain set.");
-        }
-        sdf->close();
+            ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
+            std::vector<int64_t> maxdom_sjid =
+                ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                    soma_maxdomain, "soma_joinid");
 
-        // Resize
-        auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
+            REQUIRE(ned_sjid == std::vector<int64_t>({1, 2}));
 
-        if (!use_current_domain) {
-            // Domain is already set. The domain (not current domain but
-            // domain) is immutable. All we can do is check for:
-            // * throw on write beyond domain
-            // * throw on an attempt to resize.
-            REQUIRE_THROWS(write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+            REQUIRE(dom_sjid == std::vector<int64_t>({0, SOMA_JOINID_DIM_MAX}));
 
-            sdf = open(OpenMode::write);
-            // Array not resizeable if it has not already been sized
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
+            REQUIRE(maxdom_sjid.size() == 2);
+            REQUIRE(maxdom_sjid[0] == 0);
+            if (!use_current_domain) {
+                REQUIRE(maxdom_sjid[1] == SOMA_JOINID_DIM_MAX);
+            } else {
+                REQUIRE(maxdom_sjid[1] > 2000000000);
+            }
             sdf->close();
 
-        } else {
-            // Expect throw on write beyond current domain before resize
-            REQUIRE_THROWS(write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+            REQUIRE(sdf->nnz() == 2);
+            write_sjid_u32_str_data_from(8);
+            REQUIRE(sdf->nnz() == 4);
+
+            sdf->open(OpenMode::read);
+
+            // Check can_upgrade_domain
+            std::unique_ptr<ArrowSchema>
+                domain_schema = create_index_cols_info_schema(dim_infos);
+            auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                dim_infos.size());
+            domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
+                std::vector<int64_t>({0, 0}));
+            auto domain_table = ArrowTable(
+                std::move(domain_array), std::move(domain_schema));
+            if (!use_current_domain) {
+                StatusAndReason check = sdf->can_upgrade_domain(
+                    domain_table, "testing");
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing for soma_joinid: new upper < old upper "
+                    "(downsize "
+                    "is "
+                    "unsupported)");
+            } else {
+                StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                    1, "testing");
+                // Must fail since this is too small.
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: dataframe already has its domain set.");
+            }
 
-            // Check shape after write
-            sdf = open(OpenMode::read);
-            expect = dim_infos[0].dim_max + 1;
+            // Check can_upgrade_soma_joinid_shape
+            if (!use_current_domain) {
+                StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                    1, "testing");
+                REQUIRE(check.first == true);
+                REQUIRE(check.second == "");
+            } else {
+                StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                    1, "testing");
+                // Must fail since this is too small.
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: dataframe already has its domain set.");
+            }
 
-            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(actual.has_value());
-            REQUIRE(actual.value() == expect);
             sdf->close();
 
-            sdf = open(OpenMode::read);
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
-            sdf->close();
+            // Resize
+            auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
+
+            if (!use_current_domain) {
+                // Core domain is already set. The domain (not core current
+                // domain but core domain)
+                // is immutable. All we can do is check for:
+                // * throw on write beyond domain
+                // * throw on an attempt to resize.
+                REQUIRE_THROWS(
+                    write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+
+                sdf = open(OpenMode::write);
+
+                // Array not resizeable if it has not already been sized
+                if (test_upgrade_domain) {
+                    std::unique_ptr<ArrowSchema>
+                        domain_schema = create_index_cols_info_schema(
+                            dim_infos);
+                    auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                        dim_infos.size());
+                    domain_array
+                        ->children[0] = ArrowAdapter::make_arrow_array_child(
+                        std::vector<int64_t>({0, new_shape - 1}));
+                    auto domain_table = ArrowTable(
+                        std::move(domain_array), std::move(domain_schema));
+                    REQUIRE_THROWS(sdf->change_domain(domain_table, "testing"));
+                } else {
+                    REQUIRE_THROWS(
+                        sdf->resize_soma_joinid_shape(new_shape, "testing"));
+                }
 
-            sdf = open(OpenMode::write);
-            sdf->resize_soma_joinid_shape(new_shape, "testing");
-            sdf->close();
+            } else {
+                // Expect throw on write beyond current domain before resize
+                REQUIRE_THROWS(
+                    write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+
+                // Check shape after write
+                sdf = open(OpenMode::read);
+                expect = dim_infos[0].dim_max + 1;
+
+                std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(actual.has_value());
+                REQUIRE(actual.value() == expect);
+                sdf->close();
+
+                // Apply the domain change
+                if (test_upgrade_domain) {
+                    std::unique_ptr<ArrowSchema>
+                        domain_schema = create_index_cols_info_schema(
+                            dim_infos);
+                    auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                        dim_infos.size());
+                    domain_array
+                        ->children[0] = ArrowAdapter::make_arrow_array_child(
+                        std::vector<int64_t>({0, new_shape - 1}));
+                    auto domain_table = ArrowTable(
+                        std::move(domain_array), std::move(domain_schema));
+
+                    // Not open for write
+                    sdf = open(OpenMode::read);
+                    REQUIRE_THROWS(sdf->change_domain(domain_table, "testing"));
+                    sdf->close();
+
+                    // Open for write
+                    sdf = open(OpenMode::write);
+                    sdf->change_domain(domain_table, "testing");
+                    sdf->close();
+
+                } else {
+                    // Not open for write
+                    sdf = open(OpenMode::read);
+                    REQUIRE_THROWS(
+                        sdf->resize_soma_joinid_shape(new_shape, "testing"));
+                    sdf->close();
+
+                    // Open for write
+                    sdf = open(OpenMode::write);
+                    sdf->resize_soma_joinid_shape(new_shape, "testing");
+                    sdf->close();
+                }
+
+                // Check shape after resize
+                sdf = open(OpenMode::read);
+                expect = SOMA_JOINID_RESIZE_DIM_MAX + 1;
+                actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(actual.has_value());
+                REQUIRE(actual.value() == expect);
+                sdf->close();
+
+                // Implicitly we expect no throw
+                write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
+            }
 
-            // Check shape after resize
-            sdf = open(OpenMode::read);
-            expect = SOMA_JOINID_RESIZE_DIM_MAX + 1;
-            actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(actual.has_value());
-            REQUIRE(actual.value() == expect);
+            // Check domainish accessors after resize
+            sdf->open(OpenMode::read);
 
-            sdf->close();
+            non_empty_domain = sdf->get_non_empty_domain();
+            ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                int64_t>(non_empty_domain, "soma_joinid");
 
-            // Implicitly we expect no throw
-            write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
-        }
+            soma_domain = sdf->get_soma_domain();
+            dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                int64_t>(soma_domain, "soma_joinid");
 
-        // Check domainish accessors after resize
-        sdf->open(OpenMode::read);
+            soma_maxdomain = sdf->get_soma_maxdomain();
+            maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                int64_t>(soma_maxdomain, "soma_joinid");
 
-        non_empty_domain = sdf->get_non_empty_domain();
-        ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-            non_empty_domain, "soma_joinid");
+            if (!use_current_domain) {
+                REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
+                REQUIRE(
+                    dom_sjid == std::vector<int64_t>({0, SOMA_JOINID_DIM_MAX}));
+                REQUIRE(
+                    maxdom_sjid ==
+                    std::vector<int64_t>({0, SOMA_JOINID_DIM_MAX}));
+            } else {
+                REQUIRE(ned_sjid == std::vector<int64_t>({1, 101}));
+                REQUIRE(
+                    dom_sjid ==
+                    std::vector<int64_t>({0, SOMA_JOINID_RESIZE_DIM_MAX}));
+                REQUIRE(maxdom_sjid.size() == 2);
+                REQUIRE(maxdom_sjid[0] == 0);
+                REQUIRE(maxdom_sjid[1] > 2000000000);
+            }
 
-        soma_domain = sdf->get_soma_domain();
-        dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-            soma_domain, "soma_joinid");
+            // Check can_resize_soma_joinid_shape
+            StatusAndReason check = sdf->can_resize_soma_joinid_shape(
+                1, "testing");
+            if (!use_current_domain) {
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: dataframe currently has no domain set.");
 
-        soma_maxdomain = sdf->get_soma_maxdomain();
-        maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
-            int64_t>(soma_maxdomain, "soma_joinid");
+                REQUIRE(!sdf->has_current_domain());
+                sdf->close();
 
-        if (!use_current_domain) {
-            REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
-            REQUIRE(dom_sjid == std::vector<int64_t>({0, SOMA_JOINID_DIM_MAX}));
-            REQUIRE(
-                maxdom_sjid == std::vector<int64_t>({0, SOMA_JOINID_DIM_MAX}));
-        } else {
-            REQUIRE(ned_sjid == std::vector<int64_t>({1, 101}));
-            REQUIRE(
-                dom_sjid ==
-                std::vector<int64_t>({0, SOMA_JOINID_RESIZE_DIM_MAX}));
-            REQUIRE(maxdom_sjid.size() == 2);
-            REQUIRE(maxdom_sjid[0] == 0);
-            REQUIRE(maxdom_sjid[1] > 2000000000);
-        }
+                sdf = open(OpenMode::write);
+                sdf->upgrade_soma_joinid_shape(
+                    SOMA_JOINID_DIM_MAX + 1, "testing");
+                sdf->close();
 
-        // Check can_resize_soma_joinid_shape
-        StatusAndReason check = sdf->can_resize_soma_joinid_shape(1, "testing");
-        if (!use_current_domain) {
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe currently has no domain set.");
+                sdf = open(OpenMode::read);
+                REQUIRE(sdf->has_current_domain());
+                std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(actual.has_value());
+                REQUIRE(actual.value() == SOMA_JOINID_DIM_MAX + 1);
+                sdf->close();
 
-            REQUIRE(!sdf->has_current_domain());
-            sdf->close();
-
-            sdf = open(OpenMode::write);
-            sdf->upgrade_soma_joinid_shape(SOMA_JOINID_DIM_MAX + 1, "testing");
-            sdf->close();
+            } else {
+                // Must fail since this is too small.
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: new soma_joinid shape 1 < existing shape "
+                    "200");
+                check = sdf->can_resize_soma_joinid_shape(
+                    SOMA_JOINID_RESIZE_DIM_MAX + 1, "testing");
+                REQUIRE(check.first == true);
+                REQUIRE(check.second == "");
+            }
 
-            sdf = open(OpenMode::read);
-            REQUIRE(sdf->has_current_domain());
-            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(actual.has_value());
-            REQUIRE(actual.value() == SOMA_JOINID_DIM_MAX + 1);
             sdf->close();
 
-        } else {
+            // Check can_upgrade_domain
+            sdf->open(OpenMode::read);
+            domain_schema = create_index_cols_info_schema(dim_infos);
+            domain_array = ArrowAdapter::make_arrow_array_parent(
+                dim_infos.size());
+            domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
+                std::vector<int64_t>({0, 0}));
+            domain_table = ArrowTable(
+                std::move(domain_array), std::move(domain_schema));
+            // The dataframe now has a shape
+            check = sdf->can_upgrade_soma_joinid_shape(1, "testing");
             // Must fail since this is too small.
             REQUIRE(check.first == false);
             REQUIRE(
                 check.second ==
-                "testing: new soma_joinid shape 1 < existing shape "
-                "200");
-            check = sdf->can_resize_soma_joinid_shape(
-                SOMA_JOINID_RESIZE_DIM_MAX + 1, "testing");
-            REQUIRE(check.first == true);
-            REQUIRE(check.second == "");
+                "testing: dataframe already has its domain set.");
+            sdf->close();
         }
-
-        sdf->close();
-
-        // Check can_upgrade_domain
-        sdf->open(OpenMode::read);
-        domain_schema = create_index_cols_info_schema(dim_infos);
-        domain_array = ArrowAdapter::make_arrow_array_parent(dim_infos.size());
-        domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
-            std::vector<int64_t>({0, 0}));
-        domain_table = ArrowTable(
-            std::move(domain_array), std::move(domain_schema));
-        // The dataframe now has a shape
-        check = sdf->can_upgrade_soma_joinid_shape(1, "testing");
-        // Must fail since this is too small.
-        REQUIRE(check.first == false);
-        REQUIRE(
-            check.second == "testing: dataframe already has its domain set.");
-        sdf->close();
     }
 }
 
@@ -791,273 +859,342 @@ TEST_CASE_METHOD(
     std::ostringstream section;
     section << "- use_current_domain=" << use_current_domain;
     SECTION(section.str()) {
-        std::string suffix = use_current_domain ? "true" : "false";
-        set_up(
-            std::make_shared<SOMAContext>(),
-            "mem://unit-test-variant-indexed-dataframe-2-" + suffix);
-
-        std::vector<helper::DimInfo> dim_infos(
-            {u32_dim_info(use_current_domain),
-             i64_dim_info(use_current_domain)});
-        std::vector<helper::AttrInfo> attr_infos({str_attr_info()});
-
-        // Create
-        create(dim_infos, attr_infos);
+        std::string suffix1 = use_current_domain ? "true" : "false";
+        // We have these:
+        // * upgrade_domain requires the user to specify values for all
+        // index
+        //   columns. This is in the spec.
+        // * resize_soma_joinid_shape allows the user to specify only the
+        //   desired soma_joinid shape. This is crucial for experiment-level
+        //   resize as an internal method at the Python level.
+        // Both need testing. Each one adds a shape where there wasn't one
+        // before. So we need to test one or the other on a given run.
+        auto test_upgrade_domain = GENERATE(false, true);
+        std::ostringstream section2;
+        section << "- test_upgrade_domain=" << test_upgrade_domain;
+        SECTION(section2.str()) {
+            std::string suffix2 = test_upgrade_domain ? "true" : "false";
+            set_up(
+                std::make_shared<SOMAContext>(),
+                "mem://unit-test-variant-indexed-dataframe-2-" + suffix1 + "-" +
+                    suffix2);
 
-        // Check current domain
-        auto sdf = open(OpenMode::read);
+            std::vector<helper::DimInfo> dim_infos(
+                {u32_dim_info(use_current_domain),
+                 i64_dim_info(use_current_domain)});
+            std::vector<helper::AttrInfo> attr_infos({str_attr_info()});
 
-        CurrentDomain current_domain = sdf->get_current_domain_for_test();
-        if (!use_current_domain) {
-            REQUIRE(current_domain.is_empty());
-        } else {
-            REQUIRE(!current_domain.is_empty());
-            REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
-            NDRectangle ndrect = current_domain.ndrectangle();
-
-            std::array<uint32_t, 2> u32_range = ndrect.range<uint32_t>(
-                dim_infos[0].name);
-            REQUIRE(u32_range[0] == (uint32_t)0);
-            REQUIRE(u32_range[1] == (uint32_t)dim_infos[0].dim_max);
-
-            std::array<int64_t, 2> i64_range = ndrect.range<int64_t>(
-                dim_infos[1].name);
-            REQUIRE(i64_range[0] == (int64_t)0);
-            REQUIRE(i64_range[1] == (int64_t)dim_infos[1].dim_max);
-        }
+            // Create
+            create(dim_infos, attr_infos);
 
-        // Check shape before write
-        int64_t expect = dim_infos[1].dim_max + 1;
-        std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(actual.has_value());
-        REQUIRE(actual.value() == expect);
+            // Check current domain
+            auto sdf = open(OpenMode::read);
 
-        sdf->close();
+            CurrentDomain current_domain = sdf->get_current_domain_for_test();
+            if (!use_current_domain) {
+                REQUIRE(current_domain.is_empty());
+            } else {
+                REQUIRE(!current_domain.is_empty());
+                REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
+                NDRectangle ndrect = current_domain.ndrectangle();
+
+                std::array<uint32_t, 2> u32_range = ndrect.range<uint32_t>(
+                    dim_infos[0].name);
+                REQUIRE(u32_range[0] == (uint32_t)0);
+                REQUIRE(u32_range[1] == (uint32_t)dim_infos[0].dim_max);
+
+                std::array<int64_t, 2> i64_range = ndrect.range<int64_t>(
+                    dim_infos[1].name);
+                REQUIRE(i64_range[0] == (int64_t)0);
+                REQUIRE(i64_range[1] == (int64_t)dim_infos[1].dim_max);
+            }
 
-        REQUIRE(sdf->nnz() == 0);
+            // Check shape before write
+            int64_t expect = dim_infos[1].dim_max + 1;
+            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+            REQUIRE(actual.has_value());
+            REQUIRE(actual.value() == expect);
 
-        // Write
-        write_sjid_u32_str_data_from(0);
-
-        REQUIRE(sdf->nnz() == 2);
-        write_sjid_u32_str_data_from(8);
-        REQUIRE(sdf->nnz() == 4);
-
-        // Check domainish accessors before resize
-        sdf->open(OpenMode::read);
-
-        ArrowTable non_empty_domain = sdf->get_non_empty_domain();
-        std::vector<int64_t> ned_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                non_empty_domain, "soma_joinid");
-        std::vector<uint32_t> ned_u32 =
-            ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
-                non_empty_domain, "myuint32");
-
-        ArrowTable soma_domain = sdf->get_soma_domain();
-        std::vector<int64_t> dom_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                soma_domain, "soma_joinid");
-        std::vector<uint32_t> dom_u32 =
-            ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
-                soma_domain, "myuint32");
-
-        ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
-        std::vector<int64_t> maxdom_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                soma_maxdomain, "soma_joinid");
-        std::vector<uint32_t> maxdom_u32 =
-            ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
-                soma_maxdomain, "myuint32");
-
-        REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
-        REQUIRE(ned_u32 == std::vector<uint32_t>({1234, 5678}));
-
-        REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
-        REQUIRE(dom_u32 == std::vector<uint32_t>({0, 9999}));
-
-        REQUIRE(maxdom_sjid.size() == 2);
-        REQUIRE(maxdom_u32.size() == 2);
-
-        REQUIRE(maxdom_u32[0] == 0);
-        if (!use_current_domain) {
-            REQUIRE(maxdom_u32[1] == 9999);
-        } else {
-            REQUIRE(maxdom_u32[1] > 2000000000);
-        }
+            sdf->close();
 
-        sdf->close();
+            REQUIRE(sdf->nnz() == 0);
+
+            // Write
+            write_sjid_u32_str_data_from(0);
+
+            REQUIRE(sdf->nnz() == 2);
+            write_sjid_u32_str_data_from(8);
+            REQUIRE(sdf->nnz() == 4);
+
+            // Check domainish accessors before resize
+            sdf->open(OpenMode::read);
+
+            ArrowTable non_empty_domain = sdf->get_non_empty_domain();
+            std::vector<int64_t> ned_sjid =
+                ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                    non_empty_domain, "soma_joinid");
+            std::vector<uint32_t> ned_u32 =
+                ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
+                    non_empty_domain, "myuint32");
+
+            ArrowTable soma_domain = sdf->get_soma_domain();
+            std::vector<int64_t> dom_sjid =
+                ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                    soma_domain, "soma_joinid");
+            std::vector<uint32_t> dom_u32 =
+                ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
+                    soma_domain, "myuint32");
+
+            ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
+            std::vector<int64_t> maxdom_sjid =
+                ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                    soma_maxdomain, "soma_joinid");
+            std::vector<uint32_t> maxdom_u32 =
+                ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
+                    soma_maxdomain, "myuint32");
 
-        // Check shape after write
-        sdf = open(OpenMode::read);
-        expect = dim_infos[1].dim_max + 1;
-        actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(actual.has_value());
-        REQUIRE(actual.value() == expect);
-
-        // Check can_upgrade_soma_joinid_shape
-        if (!use_current_domain) {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            REQUIRE(check.first == true);
-            REQUIRE(check.second == "");
-        } else {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe already has its domain set.");
-        }
-
-        // Check can_upgrade_domain
-        std::unique_ptr<ArrowSchema>
-            domain_schema = create_index_cols_info_schema(dim_infos);
-        auto domain_array = ArrowAdapter::make_arrow_array_parent(
-            dim_infos.size());
-        domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
-            std::vector<uint32_t>({0, 0}));
-        domain_array->children[1] = ArrowAdapter::make_arrow_array_child(
-            std::vector<int64_t>({0, 0}));
-        auto domain_table = ArrowTable(
-            std::move(domain_array), std::move(domain_schema));
-
-        if (!use_current_domain) {
-            StatusAndReason check = sdf->can_upgrade_domain(
-                domain_table, "testing");
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing for myuint32: new upper < old upper (downsize is "
-                "unsupported)");
-        } else {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe already has its domain set.");
-        }
+            REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
+            REQUIRE(ned_u32 == std::vector<uint32_t>({1234, 5678}));
 
-        sdf->close();
+            REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
+            REQUIRE(dom_u32 == std::vector<uint32_t>({0, 9999}));
 
-        // Resize
-        auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
+            REQUIRE(maxdom_sjid.size() == 2);
+            REQUIRE(maxdom_u32.size() == 2);
 
-        if (!use_current_domain) {
-            // Domain is already set. The domain (not current domain but
-            // domain) is immutable. All we can do is check for:
-            // * throw on write beyond domain
-            // * throw on an attempt to resize.
-            REQUIRE_THROWS(write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+            REQUIRE(maxdom_u32[0] == 0);
+            if (!use_current_domain) {
+                REQUIRE(maxdom_u32[1] == 9999);
+            } else {
+                REQUIRE(maxdom_u32[1] > 2000000000);
+            }
 
-            sdf = open(OpenMode::write);
-            // Array not resizeable if it has not already been sized
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
             sdf->close();
 
-        } else {
-            // Expect throw on write beyond current domain before resize
-            REQUIRE_THROWS(write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
-
             // Check shape after write
             sdf = open(OpenMode::read);
             expect = dim_infos[1].dim_max + 1;
-            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+            actual = sdf->maybe_soma_joinid_shape();
             REQUIRE(actual.has_value());
             REQUIRE(actual.value() == expect);
-            sdf->close();
 
-            sdf = open(OpenMode::read);
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
-            sdf->close();
+            // Check can_upgrade_soma_joinid_shape
+            if (!use_current_domain) {
+                StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                    1, "testing");
+                REQUIRE(check.first == true);
+                REQUIRE(check.second == "");
+            } else {
+                StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                    1, "testing");
+                // Must fail since this is too small.
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: dataframe already has its domain set.");
+            }
 
-            sdf = open(OpenMode::write);
-            sdf->resize_soma_joinid_shape(new_shape, "testing");
-            sdf->close();
+            // Check can_upgrade_domain
+            std::unique_ptr<ArrowSchema>
+                domain_schema = create_index_cols_info_schema(dim_infos);
+            auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                dim_infos.size());
+            domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
+                std::vector<uint32_t>({0, 0}));
+            domain_array->children[1] = ArrowAdapter::make_arrow_array_child(
+                std::vector<int64_t>({0, 0}));
+            auto domain_table = ArrowTable(
+                std::move(domain_array), std::move(domain_schema));
+
+            if (!use_current_domain) {
+                StatusAndReason check = sdf->can_upgrade_domain(
+                    domain_table, "testing");
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing for myuint32: new upper < old upper (downsize "
+                    "is "
+                    "unsupported)");
+            } else {
+                StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                    1, "testing");
+                // Must fail since this is too small.
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: dataframe already has its domain set.");
+            }
 
-            // Check shape after resize
-            sdf = open(OpenMode::read);
-            expect = SOMA_JOINID_RESIZE_DIM_MAX + 1;
-            actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(actual.has_value());
-            REQUIRE(actual.value() == expect);
             sdf->close();
 
-            // Implicitly we expect no throw
-            write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
-        }
-
-        // Check domainish accessors after resize
-        sdf->open(OpenMode::read);
+            // Resize
+            auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
+            uint32_t new_u32_dim_max = (uint32_t)u32_dim_max * 2 + 1;
+
+            if (!use_current_domain) {
+                // Domain is already set. The domain (not current domain but
+                // domain) is immutable. All we can do is check for:
+                // * throw on write beyond domain
+                // * throw on an attempt to resize.
+                REQUIRE_THROWS(
+                    write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+
+                sdf = open(OpenMode::write);
+
+                // Array not resizeable if it has not already been sized
+                if (test_upgrade_domain) {
+                    std::unique_ptr<ArrowSchema>
+                        domain_schema = create_index_cols_info_schema(
+                            dim_infos);
+                    auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                        dim_infos.size());
+                    domain_array
+                        ->children[0] = ArrowAdapter::make_arrow_array_child(
+                        std::vector<uint32_t>({0, new_u32_dim_max}));
+                    domain_array
+                        ->children[1] = ArrowAdapter::make_arrow_array_child(
+                        std::vector<int64_t>({0, new_shape - 1}));
+                    auto domain_table = ArrowTable(
+                        std::move(domain_array), std::move(domain_schema));
+                    REQUIRE_THROWS(sdf->change_domain(domain_table, "testing"));
+                } else {
+                    REQUIRE_THROWS(
+                        sdf->resize_soma_joinid_shape(new_shape, "testing"));
+                }
+
+                sdf->close();
 
-        non_empty_domain = sdf->get_non_empty_domain();
-        ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-            non_empty_domain, "soma_joinid");
-        ned_u32 = ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
-            non_empty_domain, "myuint32");
+            } else {
+                // Expect throw on write beyond current domain before resize
+                REQUIRE_THROWS(
+                    write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+
+                // Check shape after write
+                sdf = open(OpenMode::read);
+                expect = dim_infos[1].dim_max + 1;
+                std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(actual.has_value());
+                REQUIRE(actual.value() == expect);
+                sdf->close();
+
+                // Apply the domain change
+                if (test_upgrade_domain) {
+                    std::unique_ptr<ArrowSchema>
+                        domain_schema = create_index_cols_info_schema(
+                            dim_infos);
+                    auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                        dim_infos.size());
+                    domain_array
+                        ->children[0] = ArrowAdapter::make_arrow_array_child(
+                        std::vector<uint32_t>({0, new_u32_dim_max}));
+                    domain_array
+                        ->children[1] = ArrowAdapter::make_arrow_array_child(
+                        std::vector<int64_t>({0, new_shape - 1}));
+                    auto domain_table = ArrowTable(
+                        std::move(domain_array), std::move(domain_schema));
+
+                    // Not open for write
+                    sdf = open(OpenMode::read);
+                    REQUIRE_THROWS(sdf->change_domain(domain_table, "testing"));
+                    sdf->close();
+
+                    // Open for write
+                    sdf = open(OpenMode::write);
+                    sdf->change_domain(domain_table, "testing");
+                    sdf->close();
+
+                } else {
+                    // Not open for write
+                    sdf = open(OpenMode::read);
+                    REQUIRE_THROWS(
+                        sdf->resize_soma_joinid_shape(new_shape, "testing"));
+                    sdf->close();
+
+                    // Open for write
+                    sdf = open(OpenMode::write);
+                    sdf->resize_soma_joinid_shape(new_shape, "testing");
+                    sdf->close();
+                }
+                sdf->close();
+
+                // Implicitly we expect no throw
+                write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
+            }
 
-        soma_domain = sdf->get_soma_domain();
-        dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-            soma_domain, "soma_joinid");
-        dom_u32 = ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
-            soma_domain, "myuint32");
+            // Check domainish accessors after resize
+            sdf->open(OpenMode::read);
 
-        soma_maxdomain = sdf->get_soma_maxdomain();
-        maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
-            int64_t>(soma_maxdomain, "soma_joinid");
-        maxdom_u32 = ArrowAdapter::get_table_non_string_column_by_name<
-            uint32_t>(soma_maxdomain, "myuint32");
+            non_empty_domain = sdf->get_non_empty_domain();
+            ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                int64_t>(non_empty_domain, "soma_joinid");
+            ned_u32 = ArrowAdapter::get_table_non_string_column_by_name<
+                uint32_t>(non_empty_domain, "myuint32");
 
-        if (!use_current_domain) {
-            REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
-            REQUIRE(ned_u32 == std::vector<uint32_t>({1234, 5678}));
+            soma_domain = sdf->get_soma_domain();
+            dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                int64_t>(soma_domain, "soma_joinid");
+            dom_u32 = ArrowAdapter::get_table_non_string_column_by_name<
+                uint32_t>(soma_domain, "myuint32");
 
-            REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
-            REQUIRE(dom_u32 == std::vector<uint32_t>({0, 9999}));
+            soma_maxdomain = sdf->get_soma_maxdomain();
+            maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                int64_t>(soma_maxdomain, "soma_joinid");
+            maxdom_u32 = ArrowAdapter::get_table_non_string_column_by_name<
+                uint32_t>(soma_maxdomain, "myuint32");
 
-            REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
-            REQUIRE(maxdom_u32 == std::vector<uint32_t>({0, 9999}));
+            if (!use_current_domain) {
+                REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
+                REQUIRE(ned_u32 == std::vector<uint32_t>({1234, 5678}));
 
-        } else {
-            REQUIRE(ned_sjid == std::vector<int64_t>({1, 101}));
-            REQUIRE(ned_u32 == std::vector<uint32_t>({1234, 5678}));
+                REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
+                REQUIRE(dom_u32 == std::vector<uint32_t>({0, 9999}));
 
-            REQUIRE(dom_sjid == std::vector<int64_t>({0, 199}));
-            REQUIRE(dom_u32 == std::vector<uint32_t>({0, 9999}));
+                REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+                REQUIRE(maxdom_u32 == std::vector<uint32_t>({0, 9999}));
 
-            REQUIRE(maxdom_sjid.size() == 2);
-            REQUIRE(maxdom_sjid[0] == 0);
-            REQUIRE(maxdom_sjid[1] > 2000000000);
+            } else {
+                REQUIRE(ned_sjid == std::vector<int64_t>({1, 101}));
+                REQUIRE(ned_u32 == std::vector<uint32_t>({1234, 5678}));
+
+                REQUIRE(dom_sjid == std::vector<int64_t>({0, 199}));
+                if (test_upgrade_domain) {
+                    REQUIRE(dom_u32 == std::vector<uint32_t>({0, 19999}));
+                } else {
+                    REQUIRE(dom_u32 == std::vector<uint32_t>({0, 9999}));
+                }
+
+                REQUIRE(maxdom_sjid.size() == 2);
+                REQUIRE(maxdom_sjid[0] == 0);
+                REQUIRE(maxdom_sjid[1] > 2000000000);
+
+                REQUIRE(maxdom_u32.size() == 2);
+                REQUIRE(maxdom_u32[0] == 0);
+                REQUIRE(maxdom_u32[1] > 2000000000);
+            }
 
-            REQUIRE(maxdom_u32.size() == 2);
-            REQUIRE(maxdom_u32[0] == 0);
-            REQUIRE(maxdom_u32[1] > 2000000000);
-        }
+            // Check can_resize_soma_joinid_shape
+            StatusAndReason check = sdf->can_resize_soma_joinid_shape(
+                1, "testing");
+            if (!use_current_domain) {
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: dataframe currently has no domain set.");
+            } else {
+                // Must fail since this is too small.
+                REQUIRE(check.first == false);
+                REQUIRE(
+                    check.second ==
+                    "testing: new soma_joinid shape 1 < existing shape "
+                    "200");
+                check = sdf->can_resize_soma_joinid_shape(
+                    SOMA_JOINID_RESIZE_DIM_MAX + 1, "testing");
+                REQUIRE(check.first == true);
+                REQUIRE(check.second == "");
+            }
 
-        // Check can_resize_soma_joinid_shape
-        StatusAndReason check = sdf->can_resize_soma_joinid_shape(1, "testing");
-        if (!use_current_domain) {
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe currently has no domain set.");
-        } else {
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: new soma_joinid shape 1 < existing shape "
-                "200");
-            check = sdf->can_resize_soma_joinid_shape(
-                SOMA_JOINID_RESIZE_DIM_MAX + 1, "testing");
-            REQUIRE(check.first == true);
-            REQUIRE(check.second == "");
+            sdf->close();
         }
-
-        sdf->close();
     }
 }
 
@@ -1072,270 +1209,343 @@ TEST_CASE_METHOD(
         auto specify_domain = GENERATE(false, true);
         std::ostringstream section2;
         section2 << "- specify_domain=" << specify_domain;
-
-        std::string suffix1 = use_current_domain ? "true" : "false";
-        std::string suffix2 = specify_domain ? "true" : "false";
-        set_up(
-            std::make_shared<SOMAContext>(),
-            "mem://unit-test-variant-indexed-dataframe-3-" + suffix1 + "-" +
-                suffix2);
-
-        std::string string_lo = specify_domain ? "apple" : "";
-        std::string string_hi = specify_domain ? "zebra" : "";
-        std::vector<helper::DimInfo> dim_infos(
-            {i64_dim_info(use_current_domain),
-             str_dim_info(use_current_domain, string_lo, string_hi)});
-        std::vector<helper::AttrInfo> attr_infos({u32_attr_info()});
-
-        // Create
-        create(dim_infos, attr_infos);
-
-        // Check current domain
-        auto sdf = open(OpenMode::read);
-
-        CurrentDomain current_domain = sdf->get_current_domain_for_test();
-        if (!use_current_domain) {
-            REQUIRE(current_domain.is_empty());
-        } else {
-            REQUIRE(!current_domain.is_empty());
-            REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
-            NDRectangle ndrect = current_domain.ndrectangle();
-
-            std::array<int64_t, 2> i64_range = ndrect.range<int64_t>(
-                dim_infos[0].name);
-            REQUIRE(i64_range[0] == (int64_t)0);
-            REQUIRE(i64_range[1] == (int64_t)dim_infos[0].dim_max);
-
-            std::array<std::string, 2> str_range = ndrect.range<std::string>(
-                dim_infos[1].name);
-            if (specify_domain) {
-                REQUIRE(str_range[0] == dim_infos[1].string_lo);
-                REQUIRE(str_range[1] == dim_infos[1].string_hi);
-            } else {
-                // Can we write empty strings in this range?
-                REQUIRE(str_range[0] <= "");
-                REQUIRE(str_range[1] >= "");
-                // Can we write ASCII values in this range?
-                REQUIRE(str_range[0] < " ");
-                REQUIRE(str_range[1] > "~");
-            }
-        }
-
-        // Check shape before write
-        int64_t expect = dim_infos[0].dim_max + 1;
-        std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(actual.has_value());
-        REQUIRE(actual.value() == expect);
-        sdf->close();
-
-        REQUIRE(sdf->nnz() == 0);
-
-        // Write
-        write_sjid_u32_str_data_from(0);
-
-        REQUIRE(sdf->nnz() == 2);
-        write_sjid_u32_str_data_from(8);
-        REQUIRE(sdf->nnz() == 4);
-
-        // Check shape after write
-        sdf = open(OpenMode::read);
-        expect = dim_infos[0].dim_max + 1;
-        actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(actual.has_value());
-        REQUIRE(actual.value() == expect);
-
-        // Check domainish accessors before resize
-        ArrowTable non_empty_domain = sdf->get_non_empty_domain();
-        std::vector<int64_t> ned_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                non_empty_domain, "soma_joinid");
-        std::vector<std::string>
-            ned_str = ArrowAdapter::get_table_string_column_by_name(
-                non_empty_domain, "mystring");
-
-        ArrowTable soma_domain = sdf->get_soma_domain();
-        std::vector<int64_t> dom_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                soma_domain, "soma_joinid");
-        std::vector<std::string>
-            dom_str = ArrowAdapter::get_table_string_column_by_name(
-                soma_domain, "mystring");
-
-        ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
-        std::vector<int64_t> maxdom_sjid =
-            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-                soma_maxdomain, "soma_joinid");
-        std::vector<std::string>
-            maxdom_str = ArrowAdapter::get_table_string_column_by_name(
-                soma_maxdomain, "mystring");
-
-        REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
-        REQUIRE(ned_str == std::vector<std::string>({"apple", "bat"}));
-
-        REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
-
-        if (!use_current_domain) {
-            REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
-        } else {
-            if (specify_domain) {
-                REQUIRE(dom_str[0] == dim_infos[1].string_lo);
-                REQUIRE(dom_str[1] == dim_infos[1].string_hi);
-            } else {
-                REQUIRE(dom_str[0] == "");
-                REQUIRE(dom_str[1] == "");
-            }
-
-            REQUIRE(maxdom_sjid[0] == 0);
-            REQUIRE(maxdom_sjid[1] > 2000000000);
-        }
-        REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
-
-        sdf->close();
-
-        // Check can_upgrade_domain
-        sdf = open(OpenMode::read);
-        std::unique_ptr<ArrowSchema>
-            domain_schema = create_index_cols_info_schema(dim_infos);
-        auto domain_array = ArrowAdapter::make_arrow_array_parent(
-            dim_infos.size());
-        domain_array->children[0] = ArrowAdapter::make_arrow_array_child(
-            std::vector<int64_t>({0, 0}));
-        domain_array->children[1] = ArrowAdapter::make_arrow_array_child_string(
-            std::vector<std::string>({"a", "z"}));
-        auto domain_table = ArrowTable(
-            std::move(domain_array), std::move(domain_schema));
-        if (!use_current_domain) {
-            StatusAndReason check = sdf->can_upgrade_domain(
-                domain_table, "testing");
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing for soma_joinid: new upper < old upper (downsize is "
-                "unsupported)");
-        } else {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe already has its domain set.");
-        }
-        sdf->close();
-
-        // Resize
-        auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
-
-        if (!use_current_domain) {
-            // Domain is already set. The domain (not current domain but
-            // domain) is immutable. All we can do is check for:
-            // * throw on write beyond domain
-            // * throw on an attempt to resize.
-            REQUIRE_THROWS(write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
-
-            sdf = open(OpenMode::write);
-            // Array not resizeable if it has not already been sized
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
-            sdf->close();
-
-        } else {
-            // Expect throw on write beyond current domain before resize
-            REQUIRE_THROWS(write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
-
-            // Check shape after write
-            sdf = open(OpenMode::read);
-            expect = dim_infos[0].dim_max + 1;
-            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(actual.has_value());
-            REQUIRE(actual.value() == expect);
-            sdf->close();
-
-            sdf = open(OpenMode::read);
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
-            sdf->close();
-
-            sdf = open(OpenMode::write);
-            sdf->resize_soma_joinid_shape(new_shape, "testing");
-            sdf->close();
-
-            // Check shape after resize
-            sdf = open(OpenMode::read);
-            expect = SOMA_JOINID_RESIZE_DIM_MAX + 1;
-            actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(actual.has_value());
-            REQUIRE(actual.value() == expect);
-            sdf->close();
-
-            // Implicitly we expect no throw
-            write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
-        }
-
-        // Check domainish accessors after resize
-        sdf->open(OpenMode::read, TimestampRange(0, 2));
-
-        non_empty_domain = sdf->get_non_empty_domain();
-        ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-            non_empty_domain, "soma_joinid");
-        ned_str = ArrowAdapter::get_table_string_column_by_name(
-            non_empty_domain, "mystring");
-
-        soma_domain = sdf->get_soma_domain();
-        dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
-            soma_domain, "soma_joinid");
-        dom_str = ArrowAdapter::get_table_string_column_by_name(
-            soma_domain, "mystring");
-
-        soma_maxdomain = sdf->get_soma_maxdomain();
-        maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
-            int64_t>(soma_maxdomain, "soma_joinid");
-        maxdom_str = ArrowAdapter::get_table_string_column_by_name(
-            soma_maxdomain, "mystring");
-
-        REQUIRE(ned_sjid == std::vector<int64_t>({0, 0}));
-        REQUIRE(ned_str == std::vector<std::string>({"", ""}));
-
-        REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
-
-        if (!use_current_domain) {
-            REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
-            REQUIRE(dom_str == std::vector<std::string>({"", ""}));
-        } else {
-            if (specify_domain) {
-                REQUIRE(dom_str[0] == dim_infos[1].string_lo);
-                REQUIRE(dom_str[1] == dim_infos[1].string_hi);
-            } else {
-                REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+        SECTION(section2.str()) {
+            auto test_upgrade_domain = GENERATE(false, true);
+            std::ostringstream section3;
+            section << "- test_upgrade_domain=" << test_upgrade_domain;
+            SECTION(section3.str()) {
+                std::string suffix1 = use_current_domain ? "true" : "false";
+                std::string suffix2 = specify_domain ? "true" : "false";
+                std::string suffix3 = test_upgrade_domain ? "true" : "false";
+                set_up(
+                    std::make_shared<SOMAContext>(),
+                    "mem://unit-test-variant-indexed-dataframe-3-" + suffix1 +
+                        "-" + suffix2 + "-" + suffix3);
+
+                std::string string_lo = specify_domain ? "apple" : "";
+                std::string string_hi = specify_domain ? "zebra" : "";
+                std::vector<helper::DimInfo> dim_infos(
+                    {i64_dim_info(use_current_domain),
+                     str_dim_info(use_current_domain, string_lo, string_hi)});
+                std::vector<helper::AttrInfo> attr_infos({u32_attr_info()});
+
+                // Create
+                create(dim_infos, attr_infos);
+
+                // Check current domain
+                auto sdf = open(OpenMode::read);
+
+                CurrentDomain
+                    current_domain = sdf->get_current_domain_for_test();
+                if (!use_current_domain) {
+                    REQUIRE(current_domain.is_empty());
+                } else {
+                    REQUIRE(!current_domain.is_empty());
+                    REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
+                    NDRectangle ndrect = current_domain.ndrectangle();
+
+                    std::array<int64_t, 2> i64_range = ndrect.range<int64_t>(
+                        dim_infos[0].name);
+                    REQUIRE(i64_range[0] == (int64_t)0);
+                    REQUIRE(i64_range[1] == (int64_t)dim_infos[0].dim_max);
+
+                    std::array<std::string, 2>
+                        str_range = ndrect.range<std::string>(
+                            dim_infos[1].name);
+                    if (specify_domain) {
+                        REQUIRE(str_range[0] == dim_infos[1].string_lo);
+                        REQUIRE(str_range[1] == dim_infos[1].string_hi);
+                    } else {
+                        // Can we write empty strings in this range?
+                        REQUIRE(str_range[0] <= "");
+                        REQUIRE(str_range[1] >= "");
+                        // Can we write ASCII values in this range?
+                        REQUIRE(str_range[0] < " ");
+                        REQUIRE(str_range[1] > "~");
+                    }
+                }
+
+                // Check shape before write
+                int64_t expect = dim_infos[0].dim_max + 1;
+                std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(actual.has_value());
+                REQUIRE(actual.value() == expect);
+                sdf->close();
+
+                REQUIRE(sdf->nnz() == 0);
+
+                // Write
+                write_sjid_u32_str_data_from(0);
+
+                REQUIRE(sdf->nnz() == 2);
+                write_sjid_u32_str_data_from(8);
+                REQUIRE(sdf->nnz() == 4);
+
+                // Check shape after write
+                sdf = open(OpenMode::read);
+                expect = dim_infos[0].dim_max + 1;
+                actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(actual.has_value());
+                REQUIRE(actual.value() == expect);
+
+                // Check domainish accessors before resize
+                ArrowTable non_empty_domain = sdf->get_non_empty_domain();
+                std::vector<int64_t> ned_sjid =
+                    ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                        non_empty_domain, "soma_joinid");
+                std::vector<std::string>
+                    ned_str = ArrowAdapter::get_table_string_column_by_name(
+                        non_empty_domain, "mystring");
+
+                ArrowTable soma_domain = sdf->get_soma_domain();
+                std::vector<int64_t> dom_sjid =
+                    ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                        soma_domain, "soma_joinid");
+                std::vector<std::string>
+                    dom_str = ArrowAdapter::get_table_string_column_by_name(
+                        soma_domain, "mystring");
+
+                ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
+                std::vector<int64_t> maxdom_sjid =
+                    ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
+                        soma_maxdomain, "soma_joinid");
+                std::vector<std::string>
+                    maxdom_str = ArrowAdapter::get_table_string_column_by_name(
+                        soma_maxdomain, "mystring");
+
+                REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
+                REQUIRE(ned_str == std::vector<std::string>({"apple", "bat"}));
+
+                REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
+
+                if (!use_current_domain) {
+                    REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+                } else {
+                    if (specify_domain) {
+                        REQUIRE(dom_str[0] == dim_infos[1].string_lo);
+                        REQUIRE(dom_str[1] == dim_infos[1].string_hi);
+                    } else {
+                        REQUIRE(dom_str[0] == "");
+                        REQUIRE(dom_str[1] == "");
+                    }
+
+                    REQUIRE(maxdom_sjid[0] == 0);
+                    REQUIRE(maxdom_sjid[1] > 2000000000);
+                }
+                REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
+
+                sdf->close();
+
+                // Check can_upgrade_domain
+                sdf = open(OpenMode::read);
+                std::unique_ptr<ArrowSchema>
+                    domain_schema = create_index_cols_info_schema(dim_infos);
+                auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                    dim_infos.size());
+                domain_array
+                    ->children[0] = ArrowAdapter::make_arrow_array_child(
+                    std::vector<int64_t>({0, 0}));
+                domain_array
+                    ->children[1] = ArrowAdapter::make_arrow_array_child_string(
+                    std::vector<std::string>({"a", "z"}));
+                auto domain_table = ArrowTable(
+                    std::move(domain_array), std::move(domain_schema));
+                if (!use_current_domain) {
+                    StatusAndReason check = sdf->can_upgrade_domain(
+                        domain_table, "testing");
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing for soma_joinid: new upper < old upper "
+                        "(downsize is "
+                        "unsupported)");
+                } else {
+                    StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                        1, "testing");
+                    // Must fail since this is too small.
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing: dataframe already has its domain set.");
+                }
+                sdf->close();
+
+                // Resize
+
+                auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
+
+                if (!use_current_domain) {
+                    // Domain is already set. The domain (not current domain
+                    // but domain) is immutable. All we can do is check for:
+                    // * throw on write beyond domain
+                    // * throw on an attempt to resize.
+                    REQUIRE_THROWS(
+                        write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+
+                    sdf = open(OpenMode::write);
+
+                    // Array not resizeable if it has not already been sized
+                    if (test_upgrade_domain) {
+                        std::unique_ptr<ArrowSchema>
+                            domain_schema = create_index_cols_info_schema(
+                                dim_infos);
+                        auto domain_array =
+                            ArrowAdapter::make_arrow_array_parent(
+                                dim_infos.size());
+                        domain_array->children[0] =
+                            ArrowAdapter::make_arrow_array_child(
+                                std::vector<int64_t>({0, new_shape - 1}));
+                        domain_array->children[1] =
+                            ArrowAdapter::make_arrow_array_child_string(
+                                std::vector<std::string>({"", ""}));
+                        auto domain_table = ArrowTable(
+                            std::move(domain_array), std::move(domain_schema));
+                        REQUIRE_THROWS(
+                            sdf->change_domain(domain_table, "testing"));
+                    } else {
+                        REQUIRE_THROWS(sdf->resize_soma_joinid_shape(
+                            new_shape, "testing"));
+                    }
+
+                    sdf->close();
+
+                } else {
+                    // Expect throw on write beyond current domain before
+                    // resize
+                    REQUIRE_THROWS(
+                        write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+
+                    // Check shape after write
+                    sdf = open(OpenMode::read);
+                    expect = dim_infos[0].dim_max + 1;
+                    std::optional<int64_t>
+                        actual = sdf->maybe_soma_joinid_shape();
+                    REQUIRE(actual.has_value());
+                    REQUIRE(actual.value() == expect);
+                    sdf->close();
+
+                    // Apply the domain change
+                    if (test_upgrade_domain) {
+                        std::unique_ptr<ArrowSchema>
+                            domain_schema = create_index_cols_info_schema(
+                                dim_infos);
+                        auto domain_array =
+                            ArrowAdapter::make_arrow_array_parent(
+                                dim_infos.size());
+                        domain_array->children[0] =
+                            ArrowAdapter::make_arrow_array_child(
+                                std::vector<int64_t>({0, new_shape - 1}));
+                        domain_array->children[1] =
+                            ArrowAdapter::make_arrow_array_child_string(
+                                std::vector<std::string>({"", ""}));
+                        auto domain_table = ArrowTable(
+                            std::move(domain_array), std::move(domain_schema));
+
+                        // Not open for write
+                        sdf = open(OpenMode::read);
+                        REQUIRE_THROWS(
+                            sdf->change_domain(domain_table, "testing"));
+                        sdf->close();
+
+                        // Open for write
+                        sdf = open(OpenMode::write);
+                        sdf->change_domain(domain_table, "testing");
+                        sdf->close();
+
+                    } else {
+                        // Not open for write
+                        sdf = open(OpenMode::read);
+                        REQUIRE_THROWS(sdf->resize_soma_joinid_shape(
+                            new_shape, "testing"));
+                        sdf->close();
+
+                        // Open for write
+                        sdf = open(OpenMode::write);
+                        sdf->resize_soma_joinid_shape(new_shape, "testing");
+
+                        sdf->close();
+                    }
+                }
+
+                sdf->open(OpenMode::write);
+                if (!use_current_domain) {
+                    REQUIRE_THROWS(
+                        write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX));
+                } else {
+                    // Implicitly we expect no throw
+                    write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
+                }
+                sdf->close();
+
+                // Check domainish accessors after resize
+                sdf->open(OpenMode::read, TimestampRange(0, 2));
+
+                non_empty_domain = sdf->get_non_empty_domain();
+                ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                    int64_t>(non_empty_domain, "soma_joinid");
+                ned_str = ArrowAdapter::get_table_string_column_by_name(
+                    non_empty_domain, "mystring");
+
+                soma_domain = sdf->get_soma_domain();
+                dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                    int64_t>(soma_domain, "soma_joinid");
+                dom_str = ArrowAdapter::get_table_string_column_by_name(
+                    soma_domain, "mystring");
+
+                soma_maxdomain = sdf->get_soma_maxdomain();
+                maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+                    int64_t>(soma_maxdomain, "soma_joinid");
+                maxdom_str = ArrowAdapter::get_table_string_column_by_name(
+                    soma_maxdomain, "mystring");
+
+                REQUIRE(ned_sjid == std::vector<int64_t>({0, 0}));
+                REQUIRE(ned_str == std::vector<std::string>({"", ""}));
+
+                REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
+
+                if (!use_current_domain) {
+                    REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+                    REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+                } else {
+                    if (specify_domain) {
+                        REQUIRE(dom_str[0] == dim_infos[1].string_lo);
+                        REQUIRE(dom_str[1] == dim_infos[1].string_hi);
+                    } else {
+                        REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+                    }
+
+                    REQUIRE(maxdom_sjid[0] == 0);
+                    REQUIRE(maxdom_sjid[1] > 2000000000);
+                }
+
+                REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
+
+                REQUIRE(ned_str == std::vector<std::string>({"", ""}));
+
+                // Check can_resize_soma_joinid_shape
+                StatusAndReason check = sdf->can_resize_soma_joinid_shape(
+                    1, "testing");
+                if (!use_current_domain) {
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing: dataframe currently has no domain set.");
+                } else {
+                    // Must fail since this is too small.
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing: new soma_joinid shape 1 < existing shape "
+                        "100");
+                    check = sdf->can_resize_soma_joinid_shape(
+                        SOMA_JOINID_RESIZE_DIM_MAX + 1, "testing");
+                    REQUIRE(check.first == true);
+                    REQUIRE(check.second == "");
+                }
+
+                sdf->close();
             }
-
-            REQUIRE(maxdom_sjid[0] == 0);
-            REQUIRE(maxdom_sjid[1] > 2000000000);
         }
-
-        REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
-
-        REQUIRE(ned_str == std::vector<std::string>({"", ""}));
-
-        // Check can_resize_soma_joinid_shape
-        StatusAndReason check = sdf->can_resize_soma_joinid_shape(1, "testing");
-        if (!use_current_domain) {
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe currently has no domain set.");
-        } else {
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: new soma_joinid shape 1 < existing shape "
-                "100");
-            check = sdf->can_resize_soma_joinid_shape(
-                SOMA_JOINID_RESIZE_DIM_MAX + 1, "testing");
-            REQUIRE(check.first == true);
-            REQUIRE(check.second == "");
-        }
-
-        sdf->close();
     }
 }
 
@@ -1350,225 +1560,288 @@ TEST_CASE_METHOD(
         auto specify_domain = GENERATE(false, true);
         std::ostringstream section2;
         section2 << "- specify_domain=" << specify_domain;
-
-        std::string suffix1 = use_current_domain ? "true" : "false";
-        std::string suffix2 = specify_domain ? "true" : "false";
-        set_up(
-            std::make_shared<SOMAContext>(),
-            "mem://unit-test-variant-indexed-dataframe-4-" + suffix1 + "-" +
-                suffix2);
-
-        std::string string_lo = specify_domain ? "apple" : "";
-        std::string string_hi = specify_domain ? "zebra" : "";
-        std::vector<helper::DimInfo> dim_infos(
-            {str_dim_info(use_current_domain, string_lo, string_hi),
-             u32_dim_info(use_current_domain)});
-        std::vector<helper::AttrInfo> attr_infos({i64_attr_info()});
-
-        // Create
-        create(dim_infos, attr_infos);
-
-        // Check current domain
-        auto sdf = open(OpenMode::read);
-
-        CurrentDomain current_domain = sdf->get_current_domain_for_test();
-        if (!use_current_domain) {
-            REQUIRE(current_domain.is_empty());
-        } else {
-            REQUIRE(!current_domain.is_empty());
-            REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
-            NDRectangle ndrect = current_domain.ndrectangle();
-
-            std::array<std::string, 2> str_range = ndrect.range<std::string>(
-                dim_infos[0].name);
-            if (specify_domain) {
-                REQUIRE(str_range[0] == dim_infos[0].string_lo);
-                REQUIRE(str_range[1] == dim_infos[0].string_hi);
-            } else {
-                // Can we write empty strings in this range?
-                REQUIRE(str_range[0] <= "");
-                REQUIRE(str_range[1] >= "");
-                // Can we write ASCII values in this range?
-                REQUIRE(str_range[0] < " ");
-                REQUIRE(str_range[1] > "~");
-            }
-
-            std::array<uint32_t, 2> u32_range = ndrect.range<uint32_t>(
-                dim_infos[1].name);
-            REQUIRE(u32_range[0] == (uint32_t)0);
-            REQUIRE(u32_range[1] == (uint32_t)dim_infos[1].dim_max);
-        }
-
-        // Check shape before write
-        std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(!actual.has_value());
-
-        // Check domainish accessors before resize
-        ArrowTable non_empty_domain = sdf->get_non_empty_domain();
-        std::vector<std::string>
-            ned_str = ArrowAdapter::get_table_string_column_by_name(
-                non_empty_domain, "mystring");
-
-        ArrowTable soma_domain = sdf->get_soma_domain();
-        std::vector<std::string>
-            dom_str = ArrowAdapter::get_table_string_column_by_name(
-                soma_domain, "mystring");
-
-        ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
-        std::vector<std::string>
-            maxdom_str = ArrowAdapter::get_table_string_column_by_name(
-                soma_maxdomain, "mystring");
-
-        REQUIRE(ned_str == std::vector<std::string>({"", ""}));
-
-        if (!use_current_domain) {
-            REQUIRE(dom_str == std::vector<std::string>({"", ""}));
-            REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
-        } else {
-            if (specify_domain) {
-                REQUIRE(dom_str[0] == dim_infos[0].string_lo);
-                REQUIRE(dom_str[1] == dim_infos[0].string_hi);
-            } else {
-                REQUIRE(dom_str == std::vector<std::string>({"", ""}));
-            }
-            REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
-        }
-
-        sdf->close();
-
-        REQUIRE(sdf->nnz() == 0);
-
-        // Write
-        write_sjid_u32_str_data_from(0);
-
-        REQUIRE(sdf->nnz() == 2);
-        write_sjid_u32_str_data_from(8);
-        // soma_joinid is not a dim here and so the second write is an
-        // overwrite of the first here
-        REQUIRE(sdf->nnz() == 2);
-
-        // Check shape after write
-        sdf = open(OpenMode::read);
-        actual = sdf->maybe_soma_joinid_shape();
-        REQUIRE(!actual.has_value());
-        sdf->close();
-
-        // Check can_upgrade_domain
-        sdf = open(OpenMode::read);
-        std::unique_ptr<ArrowSchema>
-            domain_schema = create_index_cols_info_schema(dim_infos);
-        auto domain_array = ArrowAdapter::make_arrow_array_parent(
-            dim_infos.size());
-        domain_array->children[0] = ArrowAdapter::make_arrow_array_child_string(
-            std::vector<std::string>({"a", "z"}));
-        domain_array->children[1] = ArrowAdapter::make_arrow_array_child(
-            std::vector<uint32_t>({0, 0}));
-        auto domain_table = ArrowTable(
-            std::move(domain_array), std::move(domain_schema));
-        if (!use_current_domain) {
-            StatusAndReason check = sdf->can_upgrade_domain(
-                domain_table, "testing");
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing for mystring: new lower > old lower (downsize is "
-                "unsupported)");
-        } else {
-            StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
-                1, "testing");
-            // Must fail since this is too small.
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe already has its domain set.");
-        }
-        sdf->close();
-
-        // Resize
-        auto new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
-
-        if (!use_current_domain) {
-            // Domain is already set. The domain (not current domain but
-            // domain) is immutable. All we can do is check for:
-            // * throw on write beyond domain -- except here,
-            // soma_joinid is not
-            //   a dim, so no throw
-            // * throw on an attempt to resize.
-
-            sdf = open(OpenMode::write);
-            // Array not resizeable if it has not already been sized
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
-            sdf->close();
-
-        } else {
-            // Check shape after write
-            sdf = open(OpenMode::read);
-            std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(!actual.has_value());
-            sdf->close();
-
-            sdf = open(OpenMode::read);
-            REQUIRE_THROWS(sdf->resize_soma_joinid_shape(new_shape, "testing"));
-            sdf->close();
-
-            sdf = open(OpenMode::write);
-            sdf->resize_soma_joinid_shape(new_shape, "testing");
-            sdf->close();
-
-            // Check shape after resize -- noting soma_joinid is not a
-            // dim here
-            sdf = open(OpenMode::read);
-            actual = sdf->maybe_soma_joinid_shape();
-            REQUIRE(!actual.has_value());
-            sdf->close();
-
-            // Implicitly we expect no throw
-            write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
-        }
-
-        // Check domainish accessors after resize
-        sdf->open(OpenMode::read, TimestampRange(0, 2));
-
-        non_empty_domain = sdf->get_non_empty_domain();
-        ned_str = ArrowAdapter::get_table_string_column_by_name(
-            non_empty_domain, "mystring");
-
-        soma_domain = sdf->get_soma_domain();
-        dom_str = ArrowAdapter::get_table_string_column_by_name(
-            soma_domain, "mystring");
-
-        soma_maxdomain = sdf->get_soma_maxdomain();
-        maxdom_str = ArrowAdapter::get_table_string_column_by_name(
-            soma_maxdomain, "mystring");
-
-        REQUIRE(ned_str == std::vector<std::string>({"", ""}));
-
-        if (!use_current_domain) {
-            REQUIRE(dom_str == std::vector<std::string>({"", ""}));
-            REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
-        } else {
-            if (specify_domain) {
-                REQUIRE(dom_str[0] == dim_infos[0].string_lo);
-                REQUIRE(dom_str[1] == dim_infos[0].string_hi);
-            } else {
-                REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+        SECTION(section2.str()) {
+            auto test_upgrade_domain = GENERATE(false, true);
+            std::ostringstream section3;
+            section << "- test_upgrade_domain=" << test_upgrade_domain;
+            SECTION(section3.str()) {
+                std::string suffix1 = use_current_domain ? "true" : "false";
+                std::string suffix2 = specify_domain ? "true" : "false";
+                std::string suffix3 = test_upgrade_domain ? "true" : "false";
+                set_up(
+                    std::make_shared<SOMAContext>(),
+                    "mem://unit-test-variant-indexed-dataframe-4-" + suffix1 +
+                        "-" + suffix2 + "-" + suffix3);
+
+                std::string string_lo = specify_domain ? "apple" : "";
+                std::string string_hi = specify_domain ? "zebra" : "";
+                std::vector<helper::DimInfo> dim_infos(
+                    {str_dim_info(use_current_domain, string_lo, string_hi),
+                     u32_dim_info(use_current_domain)});
+                std::vector<helper::AttrInfo> attr_infos({i64_attr_info()});
+
+                // Create
+                create(dim_infos, attr_infos);
+
+                // Check current domain
+                auto sdf = open(OpenMode::read);
+
+                CurrentDomain
+                    current_domain = sdf->get_current_domain_for_test();
+                if (!use_current_domain) {
+                    REQUIRE(current_domain.is_empty());
+                } else {
+                    REQUIRE(!current_domain.is_empty());
+                    REQUIRE(current_domain.type() == TILEDB_NDRECTANGLE);
+                    NDRectangle ndrect = current_domain.ndrectangle();
+
+                    std::array<std::string, 2>
+                        str_range = ndrect.range<std::string>(
+                            dim_infos[0].name);
+                    if (specify_domain) {
+                        REQUIRE(str_range[0] == dim_infos[0].string_lo);
+                        REQUIRE(str_range[1] == dim_infos[0].string_hi);
+                    } else {
+                        // Can we write empty strings in this range?
+                        REQUIRE(str_range[0] <= "");
+                        REQUIRE(str_range[1] >= "");
+                        // Can we write ASCII values in this range?
+                        REQUIRE(str_range[0] < " ");
+                        REQUIRE(str_range[1] > "~");
+                    }
+
+                    std::array<uint32_t, 2> u32_range = ndrect.range<uint32_t>(
+                        dim_infos[1].name);
+                    REQUIRE(u32_range[0] == (uint32_t)0);
+                    REQUIRE(u32_range[1] == (uint32_t)dim_infos[1].dim_max);
+                }
+
+                // Check shape before write
+                std::optional<int64_t> actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(!actual.has_value());
+
+                // Check domainish accessors before resize
+                ArrowTable non_empty_domain = sdf->get_non_empty_domain();
+                std::vector<std::string>
+                    ned_str = ArrowAdapter::get_table_string_column_by_name(
+                        non_empty_domain, "mystring");
+
+                ArrowTable soma_domain = sdf->get_soma_domain();
+                std::vector<std::string>
+                    dom_str = ArrowAdapter::get_table_string_column_by_name(
+                        soma_domain, "mystring");
+
+                ArrowTable soma_maxdomain = sdf->get_soma_maxdomain();
+                std::vector<std::string>
+                    maxdom_str = ArrowAdapter::get_table_string_column_by_name(
+                        soma_maxdomain, "mystring");
+
+                REQUIRE(ned_str == std::vector<std::string>({"", ""}));
+
+                if (!use_current_domain) {
+                    REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+                    REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
+                } else {
+                    if (specify_domain) {
+                        REQUIRE(dom_str[0] == dim_infos[0].string_lo);
+                        REQUIRE(dom_str[1] == dim_infos[0].string_hi);
+                    } else {
+                        REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+                    }
+                    REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
+                }
+
+                sdf->close();
+
+                REQUIRE(sdf->nnz() == 0);
+
+                // Write
+                write_sjid_u32_str_data_from(0);
+
+                REQUIRE(sdf->nnz() == 2);
+                write_sjid_u32_str_data_from(8);
+                // soma_joinid is not a dim here and so the second write is
+                // an overwrite of the first here
+                REQUIRE(sdf->nnz() == 2);
+
+                // Check shape after write
+                sdf = open(OpenMode::read);
+                actual = sdf->maybe_soma_joinid_shape();
+                REQUIRE(!actual.has_value());
+                sdf->close();
+
+                // Check can_upgrade_domain
+                sdf = open(OpenMode::read);
+                std::unique_ptr<ArrowSchema>
+                    domain_schema = create_index_cols_info_schema(dim_infos);
+                auto domain_array = ArrowAdapter::make_arrow_array_parent(
+                    dim_infos.size());
+                domain_array
+                    ->children[0] = ArrowAdapter::make_arrow_array_child_string(
+                    std::vector<std::string>({"a", "z"}));
+                domain_array
+                    ->children[1] = ArrowAdapter::make_arrow_array_child(
+                    std::vector<uint32_t>({0, 0}));
+                auto domain_table = ArrowTable(
+                    std::move(domain_array), std::move(domain_schema));
+                if (!use_current_domain) {
+                    StatusAndReason check = sdf->can_upgrade_domain(
+                        domain_table, "testing");
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing for mystring: domain cannot be set for string "
+                        "index columns: please use (\"\", \"\")");
+                } else {
+                    StatusAndReason check = sdf->can_upgrade_soma_joinid_shape(
+                        1, "testing");
+                    // Must fail since this is too small.
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing: dataframe already has its domain set.");
+                }
+                sdf->close();
+
+                // Resize
+                int64_t new_shape = int64_t{SOMA_JOINID_RESIZE_DIM_MAX + 1};
+                uint32_t new_u32_dim_max = u32_dim_max * 2 + 1;
+
+                if (!use_current_domain) {
+                    // Domain is already set. The domain (not current domain
+                    // but domain) is immutable. All we can do is check for:
+                    // * throw on write beyond domain -- except here,
+                    // soma_joinid is not
+                    //   a dim, so no throw
+                    // * throw on an attempt to resize.
+
+                    sdf = open(OpenMode::write);
+
+                    // Array not resizeable if it has not already been sized
+                    if (test_upgrade_domain) {
+                        std::unique_ptr<ArrowSchema>
+                            domain_schema = create_index_cols_info_schema(
+                                dim_infos);
+                        auto domain_array =
+                            ArrowAdapter::make_arrow_array_parent(
+                                dim_infos.size());
+                        domain_array->children[0] =
+                            ArrowAdapter::make_arrow_array_child_string(
+                                std::vector<std::string>({"", ""}));
+                        domain_array->children[1] =
+                            ArrowAdapter::make_arrow_array_child(
+                                std::vector<uint32_t>({0, new_u32_dim_max}));
+                        auto domain_table = ArrowTable(
+                            std::move(domain_array), std::move(domain_schema));
+                        REQUIRE_THROWS(
+                            sdf->change_domain(domain_table, "testing"));
+                    } else {
+                        REQUIRE_THROWS(sdf->resize_soma_joinid_shape(
+                            new_shape, "testing"));
+                    }
+
+                    sdf->close();
+
+                } else {
+                    // Check shape after write
+                    sdf = open(OpenMode::read);
+                    std::optional<int64_t>
+                        actual = sdf->maybe_soma_joinid_shape();
+                    REQUIRE(!actual.has_value());
+                    sdf->close();
+
+                    // Apply the domain change
+                    if (test_upgrade_domain) {
+                        std::unique_ptr<ArrowSchema>
+                            domain_schema = create_index_cols_info_schema(
+                                dim_infos);
+                        auto domain_array =
+                            ArrowAdapter::make_arrow_array_parent(
+                                dim_infos.size());
+                        domain_array->children[0] =
+                            ArrowAdapter::make_arrow_array_child_string(
+                                std::vector<std::string>({"", ""}));
+                        domain_array->children[1] =
+                            ArrowAdapter::make_arrow_array_child(
+                                std::vector<uint32_t>({0, new_u32_dim_max}));
+                        auto domain_table = ArrowTable(
+                            std::move(domain_array), std::move(domain_schema));
+
+                        // Not open for write
+                        sdf = open(OpenMode::read);
+                        REQUIRE_THROWS(
+                            sdf->change_domain(domain_table, "testing"));
+                        sdf->close();
+
+                        // Open for write
+                        sdf = open(OpenMode::write);
+                        sdf->change_domain(domain_table, "testing");
+                        sdf->close();
+
+                    } else {
+                        // Not open for write
+                        sdf = open(OpenMode::read);
+                        REQUIRE_THROWS(sdf->resize_soma_joinid_shape(
+                            new_shape, "testing"));
+                        sdf->close();
+
+                        // Open for write
+                        sdf = open(OpenMode::write);
+                        sdf->resize_soma_joinid_shape(new_shape, "testing");
+                        sdf->close();
+                    }
+                }
+
+                sdf = open(OpenMode::write);
+                write_sjid_u32_str_data_from(SOMA_JOINID_DIM_MAX);
+                sdf->close();
+
+                // Check domainish accessors after resize
+                sdf->open(OpenMode::read, TimestampRange(0, 2));
+
+                non_empty_domain = sdf->get_non_empty_domain();
+                ned_str = ArrowAdapter::get_table_string_column_by_name(
+                    non_empty_domain, "mystring");
+
+                soma_domain = sdf->get_soma_domain();
+                dom_str = ArrowAdapter::get_table_string_column_by_name(
+                    soma_domain, "mystring");
+
+                soma_maxdomain = sdf->get_soma_maxdomain();
+                maxdom_str = ArrowAdapter::get_table_string_column_by_name(
+                    soma_maxdomain, "mystring");
+
+                REQUIRE(ned_str == std::vector<std::string>({"", ""}));
+
+                if (!use_current_domain) {
+                    REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+                    REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
+                } else {
+                    if (specify_domain) {
+                        REQUIRE(dom_str[0] == dim_infos[0].string_lo);
+                        REQUIRE(dom_str[1] == dim_infos[0].string_hi);
+                    } else {
+                        REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+                    }
+                    REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
+                }
+
+                // Check can_resize_soma_joinid_shape
+                StatusAndReason check = sdf->can_resize_soma_joinid_shape(
+                    0, "testing");
+                if (!use_current_domain) {
+                    REQUIRE(check.first == false);
+                    REQUIRE(
+                        check.second ==
+                        "testing: dataframe currently has no domain set.");
+                } else {
+                    // Must pass since soma_joinid isn't a dim in this case.
+                    REQUIRE(check.first == true);
+                    REQUIRE(check.second == "");
+                }
+
+                sdf->close();
             }
-            REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
-        }
-
-        // Check can_resize_soma_joinid_shape
-        StatusAndReason check = sdf->can_resize_soma_joinid_shape(0, "testing");
-        if (!use_current_domain) {
-            REQUIRE(check.first == false);
-            REQUIRE(
-                check.second ==
-                "testing: dataframe currently has no domain set.");
-        } else {
-            // Must pass since soma_joinid isn't a dim in this case.
-            REQUIRE(check.first == true);
-            REQUIRE(check.second == "");
         }
-
-        sdf->close();
     }
 }