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



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.

Fix patch:
diff --git a/apis/python/src/tiledbsoma/_common_nd_array.py b/apis/python/src/tiledbsoma/_common_nd_array.py
index 28632c5503..f64c64e7c2 100644
--- a/apis/python/src/tiledbsoma/_common_nd_array.py
+++ b/apis/python/src/tiledbsoma/_common_nd_array.py
@@ -107,15 +107,15 @@ def maxshape(self) -> Tuple[int, ...]:
         return cast(Tuple[int, ...], tuple(self._handle.maxshape))
 
     @property
-    def has_upgraded_shape(self) -> bool:
+    def tiledbsoma_has_upgraded_shape(self) -> bool:
         """Returns true if the array has the upgraded resizeable shape feature
         from TileDB-SOMA 1.14: the array was created with this support, or it has
-        had ``.upgrade_shape`` applied to it.
+        had ``.tiledbsoma_upgrade_shape`` applied to it.
 
         Lifecycle:
             Maturing.
         """
-        return self._handle.has_upgraded_shape
+        return self._handle.tiledbsoma_has_upgraded_shape
 
     @classmethod
     def _dim_capacity_and_extent(
diff --git a/apis/python/src/tiledbsoma/_dataframe.py b/apis/python/src/tiledbsoma/_dataframe.py
index efa2a4cc5a..cc41ee4cae 100644
--- a/apis/python/src/tiledbsoma/_dataframe.py
+++ b/apis/python/src/tiledbsoma/_dataframe.py
@@ -407,15 +407,15 @@ def _maybe_soma_joinid_maxshape(self) -> Optional[int]:
         return self._handle.maybe_soma_joinid_maxshape
 
     @property
-    def has_upgraded_domain(self) -> bool:
+    def tiledbsoma_has_upgraded_domain(self) -> bool:
         """Returns true if the array has the upgraded resizeable domain feature
         from TileDB-SOMA 1.14: the array was created with this support, or it has
-        had ``.upgrade_domain`` applied to it.
+        had ``.tiledbsoma_upgrade_domain`` applied to it.
 
         Lifecycle:
             Maturing.
         """
-        return self._handle.has_upgraded_domain
+        return self._handle.tiledbsoma_has_upgraded_domain
 
     def __len__(self) -> int:
         """Returns the number of rows in the dataframe. Same as ``df.count``."""
diff --git a/apis/python/src/tiledbsoma/_dense_nd_array.py b/apis/python/src/tiledbsoma/_dense_nd_array.py
index f35b27a1ec..12daea0677 100644
--- a/apis/python/src/tiledbsoma/_dense_nd_array.py
+++ b/apis/python/src/tiledbsoma/_dense_nd_array.py
@@ -296,6 +296,14 @@ def write(
             clib_dense_array.consolidate_and_vacuum()
         return self
 
+    def resize(self, newshape: Sequence[Union[int, None]]) -> None:
+        """Supported for ``SparseNDArray``; scheduled for implementation for
+        ``DenseNDArray`` in TileDB-SOMA 1.15
+        """
+        # TODO: support current domain for dense arrays once we have core support.
+        # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+        raise NotImplementedError()
+
     @classmethod
     def _dim_capacity_and_extent(
         cls,
diff --git a/apis/python/src/tiledbsoma/_sparse_nd_array.py b/apis/python/src/tiledbsoma/_sparse_nd_array.py
index 46fa56a0ad..a7952a91cb 100644
--- a/apis/python/src/tiledbsoma/_sparse_nd_array.py
+++ b/apis/python/src/tiledbsoma/_sparse_nd_array.py
@@ -296,6 +296,22 @@ def read(
 
         return SparseNDArrayRead(sr, self, coords)
 
+    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.
+        """
+        self._handle.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.
+        """
+        self._handle.tiledbsoma_upgrade_shape(newshape)
+
     def write(
         self,
         values: Union[
diff --git a/apis/python/src/tiledbsoma/_tdb_handles.py b/apis/python/src/tiledbsoma/_tdb_handles.py
index cbe61f349d..b9585dbcee 100644
--- a/apis/python/src/tiledbsoma/_tdb_handles.py
+++ b/apis/python/src/tiledbsoma/_tdb_handles.py
@@ -19,6 +19,7 @@
     Mapping,
     MutableMapping,
     Optional,
+    Sequence,
     Tuple,
     Type,
     TypeVar,
@@ -429,15 +430,23 @@ def maybe_soma_joinid_maxshape(self) -> Optional[int]:
         raise NotImplementedError
 
     @property
-    def has_upgraded_shape(self) -> bool:
+    def tiledbsoma_has_upgraded_shape(self) -> bool:
         """Not implemented for DataFrame."""
         raise NotImplementedError
 
     @property
-    def has_upgraded_domain(self) -> bool:
+    def tiledbsoma_has_upgraded_domain(self) -> bool:
         """Only implemented for DataFrame."""
         raise NotImplementedError
 
+    def resize(self, newshape: Sequence[Union[int, None]]) -> None:
+        """Not implemented for DataFrame."""
+        raise NotImplementedError
+
+    def tiledbsoma_upgrade_shape(self, newshape: Sequence[Union[int, None]]) -> None:
+        """Not implemented for DataFrame."""
+        raise NotImplementedError
+
 
 class DataFrameWrapper(SOMAArrayWrapper[clib.SOMADataFrame]):
     """Wrapper around a Pybind11 SOMADataFrame handle."""
@@ -474,15 +483,15 @@ def maybe_soma_joinid_maxshape(self) -> Optional[int]:
         return cast(Optional[int], self._handle.maybe_soma_joinid_maxshape)
 
     @property
-    def has_upgraded_domain(self) -> bool:
+    def tiledbsoma_has_upgraded_domain(self) -> bool:
         """Returns true if the array has the upgraded resizeable domain feature
         from TileDB-SOMA 1.14: the array was created with this support, or it has
-        had ``.upgrade_domain`` applied to it.
+        had ``.tiledbsoma_upgrade_domain`` applied to it.
 
         Lifecycle:
             Maturing.
         """
-        return cast(bool, self._handle.has_upgraded_domain)
+        return cast(bool, self._handle.tiledbsoma_has_upgraded_domain)
 
 
 class DenseNDArrayWrapper(SOMAArrayWrapper[clib.SOMADenseNDArray]):
@@ -491,15 +500,23 @@ class DenseNDArrayWrapper(SOMAArrayWrapper[clib.SOMADenseNDArray]):
     _ARRAY_WRAPPED_TYPE = clib.SOMADenseNDArray
 
     @property
-    def has_upgraded_shape(self) -> bool:
+    def tiledbsoma_has_upgraded_shape(self) -> bool:
         """Returns true if the array has the upgraded resizeable shape feature
         from TileDB-SOMA 1.14: the array was created with this support, or it has
-        had ``.upgrade_shape`` applied to it.
+        had ``.tiledbsoma_upgrade_shape`` applied to it.
 
         Lifecycle:
             Maturing.
         """
-        return cast(bool, self._handle.has_upgraded_shape)
+        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
+        """
+        # TODO: support current domain for dense arrays once we have core support.
+        # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+        raise NotImplementedError()
 
 
 class SparseNDArrayWrapper(SOMAArrayWrapper[clib.SOMASparseNDArray]):
@@ -512,15 +529,31 @@ def nnz(self) -> int:
         return int(self._handle.nnz())
 
     @property
-    def has_upgraded_shape(self) -> bool:
+    def tiledbsoma_has_upgraded_shape(self) -> bool:
         """Returns true if the array has the upgraded resizeable shape feature
         from TileDB-SOMA 1.14: the array was created with this support, or it has
-        had ``.upgrade_shape`` applied to it.
+        had ``.tiledbsoma_upgrade_shape`` applied to it.
 
         Lifecycle:
             Maturing.
         """
-        return cast(bool, self._handle.has_upgraded_shape)
+        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.
+        """
+        self._handle.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.
+        """
+        self._handle.tiledbsoma_upgrade_shape(newshape)
 
 
 class _DictMod(enum.Enum):
diff --git a/apis/python/src/tiledbsoma/soma_dataframe.cc b/apis/python/src/tiledbsoma/soma_dataframe.cc
index 0716a419f5..e7f7ad84d9 100644
--- a/apis/python/src/tiledbsoma/soma_dataframe.cc
+++ b/apis/python/src/tiledbsoma/soma_dataframe.cc
@@ -155,6 +155,6 @@ void load_soma_dataframe(py::module& m) {
             "maybe_soma_joinid_maxshape",
             &SOMADataFrame::maybe_soma_joinid_maxshape)
         .def_property_readonly(
-            "has_upgraded_domain", &SOMAArray::has_current_domain);
+            "tiledbsoma_has_upgraded_domain", &SOMAArray::has_current_domain);
 }
 }  // namespace libtiledbsomacpp
diff --git a/apis/python/src/tiledbsoma/soma_dense_ndarray.cc b/apis/python/src/tiledbsoma/soma_dense_ndarray.cc
index bd5a3b42b4..e641626e27 100644
--- a/apis/python/src/tiledbsoma/soma_dense_ndarray.cc
+++ b/apis/python/src/tiledbsoma/soma_dense_ndarray.cc
@@ -129,6 +129,6 @@ void load_soma_dense_ndarray(py::module& m) {
         .def_property_readonly("shape", &SOMADenseNDArray::shape)
         .def_property_readonly("maxshape", &SOMADenseNDArray::maxshape)
         .def_property_readonly(
-            "has_upgraded_shape", &SOMAArray::has_current_domain);
+            "tiledbsoma_has_upgraded_shape", &SOMAArray::has_current_domain);
 }
 }  // namespace libtiledbsomacpp
diff --git a/apis/python/src/tiledbsoma/soma_sparse_ndarray.cc b/apis/python/src/tiledbsoma/soma_sparse_ndarray.cc
index bb45120f4e..3b38860ba6 100644
--- a/apis/python/src/tiledbsoma/soma_sparse_ndarray.cc
+++ b/apis/python/src/tiledbsoma/soma_sparse_ndarray.cc
@@ -115,6 +115,28 @@ void load_soma_sparse_ndarray(py::module& m) {
         .def_property_readonly("shape", &SOMASparseNDArray::shape)
         .def_property_readonly("maxshape", &SOMASparseNDArray::maxshape)
         .def_property_readonly(
-            "has_upgraded_shape", &SOMAArray::has_current_domain);
+            "tiledbsoma_has_upgraded_shape", &SOMAArray::has_current_domain)
+
+        .def(
+            "resize",
+            [](SOMAArray& array, const std::vector<int64_t>& newshape) {
+                try {
+                    array.resize(newshape);
+                } catch (const std::exception& e) {
+                    throw TileDBSOMAError(e.what());
+                }
+            },
+            "newshape"_a)
+
+        .def(
+            "tiledbsoma_upgrade_shape",
+            [](SOMAArray& array, const std::vector<int64_t>& newshape) {
+                try {
+                    array.upgrade_shape(newshape);
+                } catch (const std::exception& e) {
+                    throw TileDBSOMAError(e.what());
+                }
+            },
+            "newshape"_a);
 }
 }  // namespace libtiledbsomacpp
diff --git a/apis/r/.Rbuildignore b/apis/r/.Rbuildignore
index ed2460f60e..3ef2789a90 100644
--- a/apis/r/.Rbuildignore
+++ b/apis/r/.Rbuildignore
@@ -33,3 +33,6 @@ src/libtiledbsoma/.editorconfig
 ^data-raw$
 ^doc$
 ^Meta$
+^travis-tool\.sh$
+^run\.sh$
+^travis_wait_.*\.log$
diff --git a/apis/r/DESCRIPTION b/apis/r/DESCRIPTION
index c6650dd601..7b1840ae7c 100644
--- a/apis/r/DESCRIPTION
+++ b/apis/r/DESCRIPTION
@@ -6,7 +6,7 @@ Description: Interface for working with 'TileDB'-based Stack of Matrices,
     like those commonly used for single cell data analysis. It is documented at
     <https://github.com/single-cell-data>; a formal specification available is at
     <https://github.com/single-cell-data/SOMA/blob/main/abstract_specification.md>.
-Version: 1.13.99.6
+Version: 1.13.99.7
 Authors@R: c(
     person(given = "Aaron", family = "Wolen",
            role = c("cre", "aut"), email = "aaron@tiledb.com",
diff --git a/apis/r/NEWS.md b/apis/r/NEWS.md
index d493b7f0e4..1ea8b19df5 100644
--- a/apis/r/NEWS.md
+++ b/apis/r/NEWS.md
@@ -2,6 +2,7 @@
 
 ## Changes
 
+* New `resize` and `tiledbsoma_upgrade_shape` accessors as part of the [new-shape project](https://github.com/single-cell-data/TileDB-SOMA/issues/2407)
 * Make use of timestamp ranges in libtiledbsoma
 * Simplify timestamp ranges; strengthen assumptions about `tiledb_timestamp`
 * Use cached timestamps in `$write()` and `$create()`
diff --git a/apis/r/R/Init.R b/apis/r/R/Init.R
index 723c832ee7..54be414351 100644
--- a/apis/r/R/Init.R
+++ b/apis/r/R/Init.R
@@ -50,6 +50,13 @@
     }
 }
 
+# This is temporary only. Please see:
+# * https://github.com/single-cell-data/TileDB-SOMA/issues/2407
+# * https://github.com/single-cell-data/TileDB-SOMA/pull/2950
+.new_shape_feature_flag_is_enabled <- function() {
+    .pkgenv[["use_current_domain_transitional_internal_only"]]
+}
+
 #' Create and cache a SOMA Context Object
 #'
 #' @param config A named character vector with \sQuote{key} and \sQuote{value} pairs defining the
diff --git a/apis/r/R/RcppExports.R b/apis/r/R/RcppExports.R
index 258b06eda0..be6c5dcb4e 100644
--- a/apis/r/R/RcppExports.R
+++ b/apis/r/R/RcppExports.R
@@ -187,6 +187,14 @@ has_current_domain <- function(uri, config = NULL) {
     .Call(`_tiledbsoma_has_current_domain`, uri, config)
 }
 
+resize <- function(uri, new_shape, config = NULL) {
+    invisible(.Call(`_tiledbsoma_resize`, uri, new_shape, config))
+}
+
+tiledbsoma_upgrade_shape <- function(uri, new_shape, config = NULL) {
+    invisible(.Call(`_tiledbsoma_tiledbsoma_upgrade_shape`, uri, new_shape, config))
+}
+
 #' Iterator-Style Access to SOMA Array via SOMAArray
 #'
 #' The `sr_*` functions provide low-level access to an instance of the SOMAArray
diff --git a/apis/r/R/SOMADataFrame.R b/apis/r/R/SOMADataFrame.R
index 3ac205f9d1..8f5b0ea7d0 100644
--- a/apis/r/R/SOMADataFrame.R
+++ b/apis/r/R/SOMADataFrame.R
@@ -346,7 +346,7 @@ SOMADataFrame <- R6::R6Class(
     #' or it has had ``upgrade_domain`` applied to it.
     #' (lifecycle: maturing)
     #' @return Logical
-    has_upgraded_domain = function() {
+    tiledbsoma_has_upgraded_domain = function() {
       has_current_domain(
         self$uri,
         config=as.character(tiledb::config(self$tiledbsoma_ctx$context()))
diff --git a/apis/r/R/SOMANDArrayBase.R b/apis/r/R/SOMANDArrayBase.R
index 4ce5d00372..3de38e7b37 100644
--- a/apis/r/R/SOMANDArrayBase.R
+++ b/apis/r/R/SOMANDArrayBase.R
@@ -91,7 +91,7 @@ SOMANDArrayBase <- R6::R6Class(
     #' or it has had ``upgrade_domain`` applied to it.
     #' (lifecycle: maturing)
     #' @return Logical
-    has_upgraded_shape = function() {
+    tiledbsoma_has_upgraded_shape = function() {
       has_current_domain(
         self$uri,
         config=as.character(tiledb::config(self$tiledbsoma_ctx$context()))
diff --git a/apis/r/R/SOMASparseNDArray.R b/apis/r/R/SOMASparseNDArray.R
index 1144780cde..02caf95988 100644
--- a/apis/r/R/SOMASparseNDArray.R
+++ b/apis/r/R/SOMASparseNDArray.R
@@ -189,6 +189,42 @@ SOMASparseNDArray <- R6::R6Class(
     #' @return A scalar with the number of non-zero elements
     nnz = function() {
       nnz(self$uri, config=as.character(tiledb::config(self$tiledbsoma_ctx$context())))
+    },
+
+    #' @description 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.
+    #' @param new_shape A vector of integerish, of the same length as the array's `ndim`.
+    #' @return No return value
+    resize = function(new_shape) {
+      # TODO: move this to SOMANDArrayBase.R once core offers current-domain support for dense arrays.
+      # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+
+      stopifnot("'new_shape' must be a vector of integerish values, of the same length as maxshape" = rlang::is_integerish(new_shape, n = self$ndim()) ||
+        (bit64::is.integer64(new_shape) && length(new_shape) == self$ndim())
+      )
+      # Checking slotwise new shape >= old shape, and <= max_shape, is already done in libtiledbsoma
+
+      resize(self$uri, new_shape, config=as.character(tiledb::config(self$tiledbsoma_ctx$context())))
+    },
+
+    #' @description Allows the array to have a resizeable shape as described in the
+    #' TileDB-SOMA 1.15 release notes.  Raises an error if the shape exceeds maxshape in any
+    #' dimension. Raises an error if the array already has a shape.
+    #' @param shape A vector of integerish, of the same length as the array's `ndim`.
+    #' @return No return value
+    tiledbsoma_upgrade_shape = function(shape) {
+      # TODO: move this to SOMANDArrayBase.R once core offers current-domain support for dense arrays.
+      # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+
+      stopifnot("'shape' must be a vector of integerish values, of the same length as maxshape" = rlang::is_integerish(shape, n = self$ndim()) ||
+        (bit64::is.integer64(shape) && length(shape) == self$ndim())
+      )
+      # Checking slotwise new shape >= old shape, and <= max_shape, is already done in libtiledbsoma
+
+      tiledbsoma_upgrade_shape(self$uri, shape, config=as.character(tiledb::config(self$tiledbsoma_ctx$context())))
     }
 
   ),
diff --git a/apis/r/man/SOMADataFrame.Rd b/apis/r/man/SOMADataFrame.Rd
index 64fbfd6c28..9a61999f24 100644
--- a/apis/r/man/SOMADataFrame.Rd
+++ b/apis/r/man/SOMADataFrame.Rd
@@ -21,7 +21,7 @@ row and is intended to act as a join key for other objects, such as
 \item \href{#method-SOMADataFrame-update}{\code{SOMADataFrame$update()}}
 \item \href{#method-SOMADataFrame-shape}{\code{SOMADataFrame$shape()}}
 \item \href{#method-SOMADataFrame-maxshape}{\code{SOMADataFrame$maxshape()}}
-\item \href{#method-SOMADataFrame-has_upgraded_domain}{\code{SOMADataFrame$has_upgraded_domain()}}
+\item \href{#method-SOMADataFrame-tiledbsoma_has_upgraded_domain}{\code{SOMADataFrame$tiledbsoma_has_upgraded_domain()}}
 \item \href{#method-SOMADataFrame-clone}{\code{SOMADataFrame$clone()}}
 }
 }
@@ -220,15 +220,15 @@ None, instead a \code{\link{.NotYetImplemented}()} error is raised
 }
 }
 \if{html}{\out{<hr>}}
-\if{html}{\out{<a id="method-SOMADataFrame-has_upgraded_domain"></a>}}
-\if{latex}{\out{\hypertarget{method-SOMADataFrame-has_upgraded_domain}{}}}
-\subsection{Method \code{has_upgraded_domain()}}{
+\if{html}{\out{<a id="method-SOMADataFrame-tiledbsoma_has_upgraded_domain"></a>}}
+\if{latex}{\out{\hypertarget{method-SOMADataFrame-tiledbsoma_has_upgraded_domain}{}}}
+\subsection{Method \code{tiledbsoma_has_upgraded_domain()}}{
 Returns TRUE if the array has the upgraded resizeable domain
 feature from TileDB-SOMA 1.14: the array was created with this support,
 or it has had \code{upgrade_domain} applied to it.
 (lifecycle: maturing)
 \subsection{Usage}{
-\if{html}{\out{<div class="r">}}\preformatted{SOMADataFrame$has_upgraded_domain()}\if{html}{\out{</div>}}
+\if{html}{\out{<div class="r">}}\preformatted{SOMADataFrame$tiledbsoma_has_upgraded_domain()}\if{html}{\out{</div>}}
 }
 
 \subsection{Returns}{
diff --git a/apis/r/man/SOMADenseNDArray.Rd b/apis/r/man/SOMADenseNDArray.Rd
index 71e89377f1..e2156ec384 100644
--- a/apis/r/man/SOMADenseNDArray.Rd
+++ b/apis/r/man/SOMADenseNDArray.Rd
@@ -66,8 +66,8 @@ The \code{write} method is currently limited to writing from 2-d matrices.
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="TileDBArray" data-id="tiledb_schema"><a href='../../tiledbsoma/html/TileDBArray.html#method-TileDBArray-tiledb_schema'><code>tiledbsoma::TileDBArray$tiledb_schema()</code></a></span></li>
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="TileDBArray" data-id="used_shape"><a href='../../tiledbsoma/html/TileDBArray.html#method-TileDBArray-used_shape'><code>tiledbsoma::TileDBArray$used_shape()</code></a></span></li>
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="create"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-create'><code>tiledbsoma::SOMANDArrayBase$create()</code></a></span></li>
-<li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="has_upgraded_shape"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-has_upgraded_shape'><code>tiledbsoma::SOMANDArrayBase$has_upgraded_shape()</code></a></span></li>
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="set_data_type"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-set_data_type'><code>tiledbsoma::SOMANDArrayBase$set_data_type()</code></a></span></li>
+<li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="tiledbsoma_has_upgraded_shape"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-tiledbsoma_has_upgraded_shape'><code>tiledbsoma::SOMANDArrayBase$tiledbsoma_has_upgraded_shape()</code></a></span></li>
 </ul>
 </details>
 }}
diff --git a/apis/r/man/SOMANDArrayBase.Rd b/apis/r/man/SOMANDArrayBase.Rd
index 9c4fbe559a..cb522fe5c9 100644
--- a/apis/r/man/SOMANDArrayBase.Rd
+++ b/apis/r/man/SOMANDArrayBase.Rd
@@ -16,7 +16,7 @@ Adds NDArray-specific functionality to the \code{\link{SOMAArrayBase}} class.
 \itemize{
 \item \href{#method-SOMANDArrayBase-create}{\code{SOMANDArrayBase$create()}}
 \item \href{#method-SOMANDArrayBase-set_data_type}{\code{SOMANDArrayBase$set_data_type()}}
-\item \href{#method-SOMANDArrayBase-has_upgraded_shape}{\code{SOMANDArrayBase$has_upgraded_shape()}}
+\item \href{#method-SOMANDArrayBase-tiledbsoma_has_upgraded_shape}{\code{SOMANDArrayBase$tiledbsoma_has_upgraded_shape()}}
 \item \href{#method-SOMANDArrayBase-clone}{\code{SOMANDArrayBase$clone()}}
 }
 }
@@ -103,15 +103,15 @@ Sets a cache value for the datatype (lifecycle: experimental)
 }
 }
 \if{html}{\out{<hr>}}
-\if{html}{\out{<a id="method-SOMANDArrayBase-has_upgraded_shape"></a>}}
-\if{latex}{\out{\hypertarget{method-SOMANDArrayBase-has_upgraded_shape}{}}}
-\subsection{Method \code{has_upgraded_shape()}}{
+\if{html}{\out{<a id="method-SOMANDArrayBase-tiledbsoma_has_upgraded_shape"></a>}}
+\if{latex}{\out{\hypertarget{method-SOMANDArrayBase-tiledbsoma_has_upgraded_shape}{}}}
+\subsection{Method \code{tiledbsoma_has_upgraded_shape()}}{
 Returns TRUE if the array has the upgraded resizeable shape
 feature from TileDB-SOMA 1.14: the array was created with this support,
 or it has had \code{upgrade_domain} applied to it.
 (lifecycle: maturing)
 \subsection{Usage}{
-\if{html}{\out{<div class="r">}}\preformatted{SOMANDArrayBase$has_upgraded_shape()}\if{html}{\out{</div>}}
+\if{html}{\out{<div class="r">}}\preformatted{SOMANDArrayBase$tiledbsoma_has_upgraded_shape()}\if{html}{\out{</div>}}
 }
 
 \subsection{Returns}{
diff --git a/apis/r/man/SOMASparseNDArray.Rd b/apis/r/man/SOMASparseNDArray.Rd
index 9bb87ceac7..7a030c04f0 100644
--- a/apis/r/man/SOMASparseNDArray.Rd
+++ b/apis/r/man/SOMASparseNDArray.Rd
@@ -31,6 +31,8 @@ the object are overwritten and new index values are added. (lifecycle: maturing)
 \item \href{#method-SOMASparseNDArray-read}{\code{SOMASparseNDArray$read()}}
 \item \href{#method-SOMASparseNDArray-write}{\code{SOMASparseNDArray$write()}}
 \item \href{#method-SOMASparseNDArray-nnz}{\code{SOMASparseNDArray$nnz()}}
+\item \href{#method-SOMASparseNDArray-resize}{\code{SOMASparseNDArray$resize()}}
+\item \href{#method-SOMASparseNDArray-tiledbsoma_upgrade_shape}{\code{SOMASparseNDArray$tiledbsoma_upgrade_shape()}}
 \item \href{#method-SOMASparseNDArray-clone}{\code{SOMASparseNDArray$clone()}}
 }
 }
@@ -64,8 +66,8 @@ the object are overwritten and new index values are added. (lifecycle: maturing)
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="TileDBArray" data-id="tiledb_schema"><a href='../../tiledbsoma/html/TileDBArray.html#method-TileDBArray-tiledb_schema'><code>tiledbsoma::TileDBArray$tiledb_schema()</code></a></span></li>
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="TileDBArray" data-id="used_shape"><a href='../../tiledbsoma/html/TileDBArray.html#method-TileDBArray-used_shape'><code>tiledbsoma::TileDBArray$used_shape()</code></a></span></li>
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="create"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-create'><code>tiledbsoma::SOMANDArrayBase$create()</code></a></span></li>
-<li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="has_upgraded_shape"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-has_upgraded_shape'><code>tiledbsoma::SOMANDArrayBase$has_upgraded_shape()</code></a></span></li>
 <li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="set_data_type"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-set_data_type'><code>tiledbsoma::SOMANDArrayBase$set_data_type()</code></a></span></li>
+<li><span class="pkg-link" data-pkg="tiledbsoma" data-topic="SOMANDArrayBase" data-id="tiledbsoma_has_upgraded_shape"><a href='../../tiledbsoma/html/SOMANDArrayBase.html#method-SOMANDArrayBase-tiledbsoma_has_upgraded_shape'><code>tiledbsoma::SOMANDArrayBase$tiledbsoma_has_upgraded_shape()</code></a></span></li>
 </ul>
 </details>
 }}
@@ -139,6 +141,52 @@ A scalar with the number of non-zero elements
 }
 }
 \if{html}{\out{<hr>}}
+\if{html}{\out{<a id="method-SOMASparseNDArray-resize"></a>}}
+\if{latex}{\out{\hypertarget{method-SOMASparseNDArray-resize}{}}}
+\subsection{Method \code{resize()}}{
+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.
+\subsection{Usage}{
+\if{html}{\out{<div class="r">}}\preformatted{SOMASparseNDArray$resize(new_shape)}\if{html}{\out{</div>}}
+}
+
+\subsection{Arguments}{
+\if{html}{\out{<div class="arguments">}}
+\describe{
+\item{\code{new_shape}}{A vector of integerish, of the same length as the array's \code{ndim}.}
+}
+\if{html}{\out{</div>}}
+}
+\subsection{Returns}{
+No return value
+}
+}
+\if{html}{\out{<hr>}}
+\if{html}{\out{<a id="method-SOMASparseNDArray-tiledbsoma_upgrade_shape"></a>}}
+\if{latex}{\out{\hypertarget{method-SOMASparseNDArray-tiledbsoma_upgrade_shape}{}}}
+\subsection{Method \code{tiledbsoma_upgrade_shape()}}{
+Allows the array to have a resizeable shape as described in the
+TileDB-SOMA 1.15 release notes.  Raises an error if the shape exceeds maxshape in any
+dimension. Raises an error if the array already has a shape.
+\subsection{Usage}{
+\if{html}{\out{<div class="r">}}\preformatted{SOMASparseNDArray$tiledbsoma_upgrade_shape(shape)}\if{html}{\out{</div>}}
+}
+
+\subsection{Arguments}{
+\if{html}{\out{<div class="arguments">}}
+\describe{
+\item{\code{shape}}{A vector of integerish, of the same length as the array's \code{ndim}.}
+}
+\if{html}{\out{</div>}}
+}
+\subsection{Returns}{
+No return value
+}
+}
+\if{html}{\out{<hr>}}
 \if{html}{\out{<a id="method-SOMASparseNDArray-clone"></a>}}
 \if{latex}{\out{\hypertarget{method-SOMASparseNDArray-clone}{}}}
 \subsection{Method \code{clone()}}{
diff --git a/apis/r/src/RcppExports.cpp b/apis/r/src/RcppExports.cpp
index 1c86f27413..af669c514a 100644
--- a/apis/r/src/RcppExports.cpp
+++ b/apis/r/src/RcppExports.cpp
@@ -415,6 +415,30 @@ BEGIN_RCPP
     return rcpp_result_gen;
 END_RCPP
 }
+// resize
+void resize(const std::string& uri, Rcpp::NumericVector new_shape, Rcpp::Nullable<Rcpp::CharacterVector> config);
+RcppExport SEXP _tiledbsoma_resize(SEXP uriSEXP, SEXP new_shapeSEXP, SEXP configSEXP) {
+BEGIN_RCPP
+    Rcpp::RNGScope rcpp_rngScope_gen;
+    Rcpp::traits::input_parameter< const std::string& >::type uri(uriSEXP);
+    Rcpp::traits::input_parameter< Rcpp::NumericVector >::type new_shape(new_shapeSEXP);
+    Rcpp::traits::input_parameter< Rcpp::Nullable<Rcpp::CharacterVector> >::type config(configSEXP);
+    resize(uri, new_shape, config);
+    return R_NilValue;
+END_RCPP
+}
+// tiledbsoma_upgrade_shape
+void tiledbsoma_upgrade_shape(const std::string& uri, Rcpp::NumericVector new_shape, Rcpp::Nullable<Rcpp::CharacterVector> config);
+RcppExport SEXP _tiledbsoma_tiledbsoma_upgrade_shape(SEXP uriSEXP, SEXP new_shapeSEXP, SEXP configSEXP) {
+BEGIN_RCPP
+    Rcpp::RNGScope rcpp_rngScope_gen;
+    Rcpp::traits::input_parameter< const std::string& >::type uri(uriSEXP);
+    Rcpp::traits::input_parameter< Rcpp::NumericVector >::type new_shape(new_shapeSEXP);
+    Rcpp::traits::input_parameter< Rcpp::Nullable<Rcpp::CharacterVector> >::type config(configSEXP);
+    tiledbsoma_upgrade_shape(uri, new_shape, config);
+    return R_NilValue;
+END_RCPP
+}
 // sr_setup
 Rcpp::List sr_setup(const std::string& uri, Rcpp::CharacterVector config, Rcpp::Nullable<Rcpp::CharacterVector> colnames, Rcpp::Nullable<Rcpp::XPtr<tiledb::QueryCondition>> qc, Rcpp::Nullable<Rcpp::List> dim_points, Rcpp::Nullable<Rcpp::List> dim_ranges, std::string batch_size, std::string result_order, Rcpp::Nullable<Rcpp::DatetimeVector> timestamprange, const std::string& loglevel);
 RcppExport SEXP _tiledbsoma_sr_setup(SEXP uriSEXP, SEXP configSEXP, SEXP colnamesSEXP, SEXP qcSEXP, SEXP dim_pointsSEXP, SEXP dim_rangesSEXP, SEXP batch_sizeSEXP, SEXP result_orderSEXP, SEXP timestamprangeSEXP, SEXP loglevelSEXP) {
@@ -617,6 +641,8 @@ static const R_CallMethodDef CallEntries[] = {
     {"_tiledbsoma_maybe_soma_joinid_shape", (DL_FUNC) &_tiledbsoma_maybe_soma_joinid_shape, 2},
     {"_tiledbsoma_maybe_soma_joinid_maxshape", (DL_FUNC) &_tiledbsoma_maybe_soma_joinid_maxshape, 2},
     {"_tiledbsoma_has_current_domain", (DL_FUNC) &_tiledbsoma_has_current_domain, 2},
+    {"_tiledbsoma_resize", (DL_FUNC) &_tiledbsoma_resize, 3},
+    {"_tiledbsoma_tiledbsoma_upgrade_shape", (DL_FUNC) &_tiledbsoma_tiledbsoma_upgrade_shape, 3},
     {"_tiledbsoma_sr_setup", (DL_FUNC) &_tiledbsoma_sr_setup, 10},
     {"_tiledbsoma_sr_complete", (DL_FUNC) &_tiledbsoma_sr_complete, 1},
     {"_tiledbsoma_create_empty_arrow_table", (DL_FUNC) &_tiledbsoma_create_empty_arrow_table, 0},
diff --git a/apis/r/src/rinterface.cpp b/apis/r/src/rinterface.cpp
index 9437276625..10bd26f704 100644
--- a/apis/r/src/rinterface.cpp
+++ b/apis/r/src/rinterface.cpp
@@ -176,6 +176,7 @@ SEXP soma_array_reader(const std::string& uri,
 
    // Nanoarrow special: stick schema into xptr tag to return single SEXP
    array_xptr_set_schema(arrayxp, schemaxp); 			// embed schema in array
+   sr->close();
    return arrayxp;
 }
 
@@ -205,13 +206,16 @@ Rcpp::CharacterVector get_column_types(const std::string& uri,
         vs[i] = std::string(tiledb::impl::to_str(datatype));
     }
     vs.attr("names") = colnames;
+    sr->close();
     return vs;
 }
 
 // [[Rcpp::export]]
 double nnz(const std::string& uri, Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
     auto sr = tdbs::SOMAArray::open(OpenMode::read, uri, "unnamed", config_vector_to_map(config));
-    return static_cast<double>(sr->nnz());
+    auto retval = static_cast<double>(sr->nnz());
+    sr->close();
+    return retval;
 }
 
 //' @noRd
@@ -232,14 +236,18 @@ bool check_arrow_array_tag(Rcpp::XPtr<ArrowArray> xp) {
 Rcpp::NumericVector shape(const std::string& uri,
                           Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
     auto sr = tdbs::SOMAArray::open(OpenMode::read, uri, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
-    return Rcpp::toInteger64(sr->shape());
+    auto retval = Rcpp::toInteger64(sr->shape());
+    sr->close();
+    return retval;
 }
 
 // [[Rcpp::export]]
 Rcpp::NumericVector maxshape(const std::string& uri,
                           Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
     auto sr = tdbs::SOMAArray::open(OpenMode::read, uri, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
-    return Rcpp::toInteger64(sr->maxshape());
+    auto retval = Rcpp::toInteger64(sr->maxshape());
+    sr->close();
+    return retval;
 }
 
 // [[Rcpp::export]]
@@ -251,6 +259,7 @@ Rcpp::NumericVector maybe_soma_joinid_shape(const std::string& uri,
     // This was done intentionally to resolve an ambiguous-overload compiler error.
     auto sr = tdbs::SOMADataFrame::open(uri, OpenMode::read, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
     auto retval = sr->maybe_soma_joinid_shape();
+    sr->close();
     if (retval.has_value()) {
       return Rcpp::toInteger64(retval.value());
     } else {
@@ -264,6 +273,7 @@ Rcpp::NumericVector maybe_soma_joinid_maxshape(const std::string& uri,
                           Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
     auto sr = tdbs::SOMADataFrame::open(uri, OpenMode::read, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
     auto retval = sr->maybe_soma_joinid_maxshape();
+    sr->close();
     if (retval.has_value()) {
       return Rcpp::toInteger64(retval.value());
     } else {
@@ -276,5 +286,35 @@ Rcpp::NumericVector maybe_soma_joinid_maxshape(const std::string& uri,
 Rcpp::LogicalVector has_current_domain(const std::string& uri,
                           Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
     auto sr = tdbs::SOMAArray::open(OpenMode::read, uri, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
-    return Rcpp::LogicalVector(sr->has_current_domain());
+    auto retval = Rcpp::LogicalVector(sr->has_current_domain());
+    sr->close();
+    return retval;
+}
+
+// [[Rcpp::export]]
+void resize(const std::string& uri,
+            Rcpp::NumericVector new_shape,
+            Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
+    // This function is solely for SparseNDArray and DenseNDArray for which the
+    // dims are required by the SOMA spec to be of type int64. Domain-resize for
+    // variant-indexed dataframes will be separate work as tracked on
+    // https://github.com/single-cell-data/TileDB-SOMA/issues/2407.
+    auto sr = tdbs::SOMAArray::open(OpenMode::write, uri, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
+    std::vector<int64_t> new_shape_i64 = i64_from_rcpp_numeric(new_shape);
+    sr->resize(new_shape_i64);
+    sr->close();
+}
+
+// [[Rcpp::export]]
+void tiledbsoma_upgrade_shape(const std::string& uri,
+            Rcpp::NumericVector new_shape,
+            Rcpp::Nullable<Rcpp::CharacterVector> config = R_NilValue) {
+    // This function is solely for SparseNDArray and DenseNDArray for which the
+    // dims are required by the SOMA spec to be of type int64. Domain-resize for
+    // variant-indexed dataframes will be separate work as tracked on
+    // https://github.com/single-cell-data/TileDB-SOMA/issues/2407.
+    auto sr = tdbs::SOMAArray::open(OpenMode::write, uri, "unnamed", config_vector_to_map(Rcpp::wrap(config)));
+    std::vector<int64_t> new_shape_i64 = i64_from_rcpp_numeric(new_shape);
+    sr->upgrade_shape(new_shape_i64);
+    sr->close();
 }
diff --git a/apis/r/src/rutilities.cpp b/apis/r/src/rutilities.cpp
index ee2dfc205e..7ba5cb19dd 100644
--- a/apis/r/src/rutilities.cpp
+++ b/apis/r/src/rutilities.cpp
@@ -202,6 +202,17 @@ Rcpp::XPtr<ArrowSchema> schema_setup_struct(Rcpp::XPtr<ArrowSchema> schxp, int64
     return schxp;
 }
 
+std::vector<int64_t> i64_from_rcpp_numeric(const Rcpp::NumericVector& input) {
+
+  auto ndim = input.size();
+  std::vector<int64_t> output(ndim);
+  for (auto i = 0; i < ndim; i++) {
+    output[i] = input[i];
+  }
+
+  return output;
+}
+
 
 // formerly stats.cpp
 
diff --git a/apis/r/src/rutilities.h b/apis/r/src/rutilities.h
index ee1483e0eb..e451013d58 100644
--- a/apis/r/src/rutilities.h
+++ b/apis/r/src/rutilities.h
@@ -68,3 +68,5 @@ inline void array_xptr_set_schema(SEXP array_xptr, SEXP schema_xptr) {
 
 // make a TimestampRange from a DatetimeVector (of size two)
 std::optional<tdbs::TimestampRange> makeTimestampRange(Rcpp::Nullable<Rcpp::DatetimeVector> tsvec);
+
+std::vector<int64_t> i64_from_rcpp_numeric(const Rcpp::NumericVector& input);


Test patch:
diff --git a/apis/python/tests/test_dataframe.py b/apis/python/tests/test_dataframe.py
index c5a9199901..7cfa41cd8a 100644
--- a/apis/python/tests/test_dataframe.py
+++ b/apis/python/tests/test_dataframe.py
@@ -91,7 +91,9 @@ def test_dataframe(tmp_path, arrow_schema):
         assert len(sdf) == 5
 
         # More to come on https://github.com/single-cell-data/TileDB-SOMA/issues/2407
-        assert sdf.has_upgraded_domain == soma._new_shape_feature_flag_enabled()
+        assert (
+            sdf.tiledbsoma_has_upgraded_domain == soma._new_shape_feature_flag_enabled()
+        )
 
         with pytest.raises(AttributeError):
             assert sdf.shape is None
diff --git a/apis/python/tests/test_shape.py b/apis/python/tests/test_shape.py
index 3a7470f042..0920c94733 100644
--- a/apis/python/tests/test_shape.py
+++ b/apis/python/tests/test_shape.py
@@ -5,8 +5,6 @@
 
 import tiledbsoma
 
-from tests._util import maybe_raises
-
 
 @pytest.mark.parametrize(
     "element_dtype",
@@ -18,32 +16,27 @@
     ],
 )
 @pytest.mark.parametrize(
-    "shape_exc",
+    "arg_shape",
     [
-        # Note: non-None exceptions are coming on https://github.com/single-cell-data/TileDB-SOMA/issues/2407
-        [(100,), None],
-        [(100, 200), None],
-        [(100, 200, 300), None],
+        (100,),
+        (100, 200),
+        (100, 200, 300),
     ],
 )
 def test_sparse_nd_array_basics(
     tmp_path,
     element_dtype,
-    shape_exc,
+    arg_shape,
 ):
     uri = tmp_path.as_posix()
-    arg_shape, arg_create_exc = shape_exc
     ndim = len(arg_shape)
 
     # Create the array
-    with maybe_raises(arg_create_exc):
-        snda = tiledbsoma.SparseNDArray.create(
-            uri,
-            type=element_dtype,
-            shape=arg_shape,
-        )
-    if arg_create_exc is not None:
-        return
+    tiledbsoma.SparseNDArray.create(
+        uri,
+        type=element_dtype,
+        shape=arg_shape,
+    )
 
     assert tiledbsoma.SparseNDArray.exists(uri)
 
@@ -53,7 +46,10 @@ def test_sparse_nd_array_basics(
         assert snda.shape == arg_shape
 
         # More to come on https://github.com/single-cell-data/TileDB-SOMA/issues/2407
-        assert snda.has_upgraded_shape == tiledbsoma._new_shape_feature_flag_enabled()
+        assert (
+            snda.tiledbsoma_has_upgraded_shape
+            == tiledbsoma._new_shape_feature_flag_enabled()
+        )
 
         # Before current-domain support: shape is maxshape.
         #
@@ -102,23 +98,79 @@ def test_sparse_nd_array_basics(
 
     # Test reads out of bounds
     with tiledbsoma.SparseNDArray.open(uri) as snda:
-        # https://github.com/single-cell-data/TileDB-SOMA/issues/2407
+        coords = tuple(arg_shape[i] + 10 for i in range(ndim))
         with pytest.raises(tiledbsoma.SOMAError):
-            coords = tuple(arg_shape[i] + 10 for i in range(ndim))
             snda.read(coords).tables().concat()
 
     # Test writes out of bounds
     with tiledbsoma.SparseNDArray.open(uri, "w") as snda:
         with pytest.raises(tiledbsoma.SOMAError):
-            dikt = {"soma_data": [30]}
             dikt = {name: [shape + 20] for name, shape in zip(dim_names, arg_shape)}
+            dikt["soma_data"] = [30]
             table = pa.Table.from_pydict(dikt)
             snda.write(table)
 
     with tiledbsoma.SparseNDArray.open(uri) as snda:
         assert snda.shape == arg_shape
-        if not tiledbsoma._new_shape_feature_flag_enabled():
-            assert snda.shape == snda.maxshape
+
+    if tiledbsoma._new_shape_feature_flag_enabled():
+
+        # Test resize down
+        new_shape = tuple([arg_shape[i] - 50 for i in range(ndim)])
+        with tiledbsoma.SparseNDArray.open(uri, "w") as snda:
+            # TODO: check draft spec
+            # with pytest.raises(ValueError):
+            with pytest.raises(tiledbsoma.SOMAError):
+                snda.resize(new_shape)
+
+        with tiledbsoma.SparseNDArray.open(uri) as snda:
+            assert snda.shape == arg_shape
+
+        # Test resize too big
+        new_shape = tuple([4_000_000_000 for i in range(ndim)])
+        # TODO: check draft spec
+        # with pytest.raises(ValueError):
+        with pytest.raises(tiledbsoma.SOMAError):
+            with tiledbsoma.SparseNDArray.open(uri, "w") as snda:
+                snda.resize(new_shape)
+        with tiledbsoma.SparseNDArray.open(uri) as snda:
+            assert snda.shape == arg_shape
+
+        # Test writes out of bounds
+        with tiledbsoma.SparseNDArray.open(uri, "w") as snda:
+            with pytest.raises(tiledbsoma.SOMAError):
+                dikt = {name: [shape + 20] for name, shape in zip(dim_names, arg_shape)}
+                dikt["soma_data"] = [30]
+                table = pa.Table.from_pydict(dikt)
+                snda.write(table)
+
+        # Test reasonable resize
+        new_shape = tuple([arg_shape[i] + 50 for i in range(ndim)])
+        with tiledbsoma.SparseNDArray.open(uri, "w") as snda:
+            snda.resize(new_shape)
+
+            dikt = {}
+            for i in range(ndim):
+                dikt[dim_names[i]] = [arg_shape[i] + 20]
+            dikt["soma_data"] = pa.array([34.5], type=element_dtype)
+            table = pa.Table.from_pydict(dikt)
+
+            # Re-test writes out of old bounds, within new bounds
+            with tiledbsoma.SparseNDArray.open(uri, "w") as snda:
+                # Implicitly checking there's no raise
+                snda.write(table)
+
+            # Re-test reads out of old bounds, within new bounds
+            with tiledbsoma.SparseNDArray.open(uri) as snda:
+                assert snda.shape == new_shape
+
+                coords = tuple([(arg_shape[i] + 20,) for i in range(ndim)])
+                # Implicitly checking there's no raise
+                readback = snda.read(coords).tables().concat()
+                assert readback == table
+
+        with tiledbsoma.SparseNDArray.open(uri) as snda:
+            assert snda.shape == new_shape
 
 
 ## Pending 2.27 timeframe for dense support for current domain, including resize
@@ -134,6 +186,14 @@ def test_dense_nd_array_basics(tmp_path):
 
         assert dnda.non_empty_domain() == ((0, 0), (0, 0))
 
+    with tiledbsoma.DenseNDArray.open(uri, "w") as dnda:
+        with pytest.raises(NotImplementedError):
+            dnda.resize((300, 400))
+
+    with tiledbsoma.DenseNDArray.open(uri) as dnda:
+        assert dnda.non_empty_domain() == ((0, 0), (0, 0))
+        assert dnda.shape == (100, 200)
+
 
 @pytest.mark.parametrize(
     "soma_joinid_domain",
diff --git a/apis/r/tests/testthat/test-SOMASparseNDArray.R b/apis/r/tests/testthat/test-SOMASparseNDArray.R
index d442d0444c..c1b2650be5 100644
--- a/apis/r/tests/testthat/test-SOMASparseNDArray.R
+++ b/apis/r/tests/testthat/test-SOMASparseNDArray.R
@@ -62,10 +62,11 @@ test_that("SOMASparseNDArray creation", {
   ## maxshape
   # TODO: more testing with current-domain feature integrated
   # https://github.com/single-cell-data/TileDB-SOMA/issues/2407
+
   if (.new_shape_feature_flag_is_enabled()) {
-    expect_true(ndarray$has_upgraded_shape())
+    expect_true(ndarray$tiledbsoma_has_upgraded_shape())
   } else {
-    expect_false(ndarray$has_upgraded_shape())
+    expect_false(ndarray$tiledbsoma_has_upgraded_shape())
   }
   shape <- ndarray$shape()
   maxshape <- ndarray$maxshape()
diff --git a/apis/r/tests/testthat/test-shape.R b/apis/r/tests/testthat/test-shape.R
new file mode 100644
index 0000000000..753d49d49b
--- /dev/null
+++ b/apis/r/tests/testthat/test-shape.R
@@ -0,0 +1,247 @@
+test_that("SOMADataFrame shape", {
+  #uri <- withr::local_tempdir("soma-dataframe-shape")
+  uri <- "/tmp/fooze"
+  asch <- create_arrow_schema()
+
+  index_column_name_choices = list(
+    "soma_joinid",
+    c("soma_joinid", "foo"),
+    c("soma_joinid", "baz"),
+    c("baz", "foo")
+  )
+
+  for (index_column_names in index_column_name_choices) {
+    has_soma_joinid_dim <- "soma_joinid" %in% index_column_names
+
+    if (dir.exists(uri)) unlink(uri, recursive=TRUE)
+
+    sdf <- SOMADataFrameCreate(uri, asch, index_column_names = index_column_names)
+    expect_true(sdf$exists())
+    expect_true(dir.exists(uri))
+
+    tbl0 <- arrow::arrow_table(foo = 1L:4L,
+                               soma_joinid = 1L:4L,
+                               bar = 1.1:4.1,
+                               baz = c("apple", "ball", "cat", "dog"),
+                               schema = asch)
+
+    sdf$write(tbl0)
+    sdf$close()
+
+    sdf <- SOMADataFrameOpen(uri)
+
+    if (.new_shape_feature_flag_is_enabled()) {
+      expect_true(sdf$tiledbsoma_has_upgraded_domain())
+    } else {
+      expect_false(sdf$tiledbsoma_has_upgraded_domain())
+    }
+    expect_error(sdf$shape(), class = "notYetImplementedError")
+    expect_error(sdf$maxshape(), class = "notYetImplementedError")
+
+    # Not implemented this way per
+    # https://github.com/single-cell-data/TileDB-SOMA/pull/2953#discussion_r1746125089
+    # sjid_shape <- sdf$.maybe_soma_joinid_shape()
+    # sjid_maxshape <- sdf$.maybe_soma_joinid_maxshape()
+    sjid_shape <- maybe_soma_joinid_shape(sdf$uri, config = as.character(tiledb::config(sdf$tiledbsoma_ctx$context())))
+    sjid_maxshape <- maybe_soma_joinid_maxshape(sdf$uri, config = as.character(tiledb::config(sdf$tiledbsoma_ctx$context())))
+
+    if (has_soma_joinid_dim) {
+      # More testing to come on
+      # https://github.com/single-cell-data/TileDB-SOMA/issues/2407
+      expect_false(sjid_shape == -1)
+      expect_false(sjid_maxshape == -1)
+    } else {
+      expect_true(sjid_shape == -1)
+      expect_true(sjid_maxshape == -1)
+    }
+
+    sdf$close()
+
+    rm(sdf, tbl0)
+
+    gc()
+  }
+})
+
+test_that("SOMASparseNDArray shape", {
+  uri <- withr::local_tempdir("soma-sparse-ndarray-shape")
+  asch <- create_arrow_schema()
+
+  element_type_choices = list(arrow::float32(), arrow::int16())
+  arg_shape <- c(100, 200)
+  for (element_type in element_type_choices) {
+
+    if (dir.exists(uri)) unlink(uri, recursive=TRUE)
+    ndarray <- SOMASparseNDArrayCreate(uri, element_type, shape = arg_shape)
+    ndarray$close()
+
+    ndarray <- SOMASparseNDArrayOpen(uri)
+    expect_equal(ndarray$ndim(), length(arg_shape))
+
+    expect_equal(ndarray$shape(), as.integer64(arg_shape))
+
+    # More generally after current-domain support:
+    readback_shape <- ndarray$shape()
+    readback_maxshape <- ndarray$maxshape()
+    expect_equal(length(readback_shape), length(readback_maxshape))
+    if (.new_shape_feature_flag_is_enabled()) {
+      expect_true(all(readback_shape < readback_maxshape))
+    } else {
+      expect_true(all(readback_shape == readback_maxshape))
+    }
+
+    ndarray$close()
+
+    # Test write in bounds
+    ndarray <- SOMASparseNDArrayOpen(uri, "WRITE")
+    soma_dim_0 <- c(2,3)
+    soma_dim_1 <- c(4,5)
+    soma_data <- c(60, 70)
+    sm <- sparseMatrix(i = soma_dim_0, j = soma_dim_1, x = soma_data)
+    ndarray$write(sm)
+    ndarray$close()
+
+    ndarray <- SOMASparseNDArrayOpen(uri)
+    ned <- ndarray$non_empty_domain()
+    expect_equal(ned, c(2,4))
+
+    # Test reads out of bounds
+    coords <- list(bit64::as.integer64(c(1,2)), bit64::as.integer64(c(3,4)))
+    expect_no_error(x <- ndarray$read(coords=coords)$tables()$concat())
+
+    coords <- list(bit64::as.integer64(c(101,202)), bit64::as.integer64(c(3,4)))
+    expect_error(x <- ndarray$read(coords=coords)$tables()$concat())
+
+    ndarray$close()
+
+    if (.new_shape_feature_flag_is_enabled()) {
+      ndarray <- SOMASparseNDArrayOpen(uri, "WRITE")
+
+      # Test resize down
+      new_shape <- c(50, 60)
+      expect_error(ndarray$resize(new_shape))
+
+      # Test writes out of old bounds
+      soma_dim_0 <- c(200,300)
+      soma_dim_1 <- c(400,500)
+      soma_data <- c(6000, 7000)
+      sm <- sparseMatrix(i = soma_dim_0, j = soma_dim_1, x = soma_data)
+      expect_error(ndarray$write(sm))
+
+      # Test resize up
+      new_shape <- c(500, 600)
+      expect_no_error(ndarray$resize(new_shape))
+
+      # Test writes within new bounds
+      soma_dim_0 <- c(200,300)
+      soma_dim_1 <- c(400,500)
+      soma_data <- c(6000, 7000)
+      sm <- sparseMatrix(i = soma_dim_0, j = soma_dim_1, x = soma_data)
+      expect_no_error(ndarray$write(sm))
+      ndarray$close()
+
+      ndarray <- SOMASparseNDArrayOpen(uri)
+      coords <- list(bit64::as.integer64(c(101,202)), bit64::as.integer64(c(3,4)))
+      expect_no_error(x <- ndarray$read(coords=coords)$tables()$concat())
+      ndarray$close()
+    }
+
+    rm(ndarray)
+    gc()
+  }
+})
+
+test_that("SOMADenseNDArray shape", {
+  uri <- withr::local_tempdir("soma-dense-ndarray-shape")
+  asch <- create_arrow_schema()
+
+  element_type_choices = list(arrow::float32(), arrow::int16())
+  arg_shape <- c(100, 200)
+  for (element_type in element_type_choices) {
+
+    if (dir.exists(uri)) unlink(uri, recursive=TRUE)
+    ndarray <- SOMADenseNDArrayCreate(uri, element_type, shape = arg_shape)
+    ndarray$close()
+
+    ndarray <- SOMADenseNDArrayOpen(uri)
+
+    expect_equal(ndarray$ndim(), length(arg_shape))
+
+    expect_equal(ndarray$shape(), as.integer64(arg_shape))
+
+    # More generally after current-domain support:
+    readback_shape <- ndarray$shape()
+    readback_maxshape <- ndarray$maxshape()
+    expect_equal(length(readback_shape), length(readback_maxshape))
+    # TODO: Awaiting core support for new shape in dense arrays.
+    # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+    #if (.new_shape_feature_flag_is_enabled()) {
+    #  expect_true(all(readback_shape < readback_maxshape))
+    #} else {
+    #  expect_true(all(readback_shape == readback_maxshape))
+    #}
+    expect_true(all(readback_shape == readback_maxshape))
+
+    ndarray$close()
+
+    # Test write in bounds
+    ndarray <- SOMADenseNDArrayOpen(uri, "WRITE")
+    mat <- create_dense_matrix_with_int_dims(100, 200)
+    ndarray$write(mat)
+    ndarray$close()
+
+    ndarray <- SOMADenseNDArrayOpen(uri)
+    ned <- ndarray$non_empty_domain()
+    expect_equal(ned, c(99, 199))
+
+    # Test reads out of bounds
+    coords <- list(bit64::as.integer64(c(1,2)), bit64::as.integer64(c(3,4)))
+    expect_no_error(ndarray$read_arrow_table(coords=coords))
+
+    coords <- list(bit64::as.integer64(c(101,202)), bit64::as.integer64(c(3,4)))
+    expect_error(ndarray$read(coords=coords)$tables()$concat())
+
+    ndarray$close()
+
+    if (.new_shape_feature_flag_is_enabled()) {
+      ndarray <- SOMADenseNDArrayOpen(uri, "WRITE")
+
+      # Test resize down
+      new_shape <- c(50, 60)
+      expect_error(ndarray$resize(new_shape))
+
+      # Test writes out of old bounds
+      ndarray <- SOMADenseNDArrayOpen(uri, "WRITE")
+      mat <- create_dense_matrix_with_int_dims(300, 400)
+      expect_error(ndarray$write(mat))
+      ndarray$close()
+
+      # Test resize up
+      new_shape <- c(500, 600)
+      # TODO: Awaiting core support for new shape in dense arrays.
+      # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+      # expect_no_error(ndarray$resize(new_shape))
+      expect_error(ndarray$resize(new_shape))
+
+      # Test writes within new bounds
+      ndarray <- SOMADenseNDArrayOpen(uri, "WRITE")
+      mat <- create_dense_matrix_with_int_dims(300, 400)
+      # TODO: Awaiting core support for new shape in dense arrays.
+      # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+      # expect_no_error(ndarray$write(sm))
+      expect_error(ndarray$write(sm))
+      ndarray$close()
+
+      ndarray <- SOMADenseNDArrayOpen(uri)
+      coords <- list(bit64::as.integer64(c(101,202)), bit64::as.integer64(c(3,4)))
+      # TODO: Awaiting core support for new shape in dense arrays.
+      # https://github.com/single-cell-data/TileDB-SOMA/issues/2955
+      # expect_no_error(x <- ndarray$read(coords=coords)$tables()$concat())
+      expect_error(x <- ndarray$read(coords=coords)$tables()$concat())
+      ndarray$close()
+    }
+
+    rm(ndarray)
+    gc()
+  }
+})
diff --git a/apis/r/tests/testthat/test-shape.r b/apis/r/tests/testthat/test-shape.r
deleted file mode 100644
index 76a171dc37..0000000000
--- a/apis/r/tests/testthat/test-shape.r
+++ /dev/null
@@ -1,102 +0,0 @@
-test_that("SOMADataFrame shape", {
-  #uri <- withr::local_tempdir("soma-dataframe-shape")
-  uri <- "/tmp/fooze"
-  asch <- create_arrow_schema()
-
-  index_column_name_choices = list(
-    "soma_joinid",
-    c("soma_joinid", "foo"),
-    c("soma_joinid", "baz"),
-    c("baz", "foo")
-  )
-
-  for (index_column_names in index_column_name_choices) {
-    has_soma_joinid_dim <- "soma_joinid" %in% index_column_names
-
-    if (dir.exists(uri)) unlink(uri, recursive=TRUE)
-
-    sdf <- SOMADataFrameCreate(uri, asch, index_column_names = index_column_names)
-    expect_true(sdf$exists())
-    expect_true(dir.exists(uri))
-
-    tbl0 <- arrow::arrow_table(foo = 1L:4L,
-                               soma_joinid = 1L:4L,
-                               bar = 1.1:4.1,
-                               baz = c("apple", "ball", "cat", "dog"),
-                               schema = asch)
-
-    sdf$write(tbl0)
-    sdf$close()
-
-    sdf <- SOMADataFrameOpen(uri)
-
-    if (.new_shape_feature_flag_is_enabled()) {
-      expect_true(sdf$has_upgraded_domain())
-    } else {
-      expect_false(sdf$has_upgraded_domain())
-    }
-    expect_error(sdf$shape(), class = "notYetImplementedError")
-    expect_error(sdf$maxshape(), class = "notYetImplementedError")
-
-    # Not implemented this way per
-    # https://github.com/single-cell-data/TileDB-SOMA/pull/2953#discussion_r1746125089
-    # sjid_shape <- sdf$.maybe_soma_joinid_shape()
-    # sjid_maxshape <- sdf$.maybe_soma_joinid_maxshape()
-    sjid_shape <- maybe_soma_joinid_shape(sdf$uri, config = as.character(tiledb::config(sdf$tiledbsoma_ctx$context())))
-    sjid_maxshape <- maybe_soma_joinid_maxshape(sdf$uri, config = as.character(tiledb::config(sdf$tiledbsoma_ctx$context())))
-
-    if (has_soma_joinid_dim) {
-      # More testing to come on
-      # https://github.com/single-cell-data/TileDB-SOMA/issues/2407
-      expect_false(sjid_shape == -1)
-      expect_false(sjid_maxshape == -1)
-    } else {
-      expect_true(sjid_shape == -1)
-      expect_true(sjid_maxshape == -1)
-    }
-
-    sdf$close()
-
-    rm(sdf, tbl0)
-
-    gc()
-  }
-})
-
-test_that("SOMASparseNDArray shape", {
-  uri <- withr::local_tempdir("soma-sparse-ndarray-shape")
-  asch <- create_arrow_schema()
-
-  element_type_choices = list(arrow::float32(), arrow::int16())
-  shape_choices = list(
-    c(100),
-    c(100,200),
-    c(100,200,300)
-  )
-  for (element_type in element_type_choices) {
-    for (shape in shape_choices) {
-
-      if (dir.exists(uri)) unlink(uri, recursive=TRUE)
-      ndarray <- SOMASparseNDArrayCreate(uri, element_type, shape = shape)
-
-      expect_equal(ndarray$ndim(), length(shape))
-
-      expect_equal(ndarray$shape(), as.integer64(shape))
-
-      # More generally after current-domain support:
-      readback_shape <- ndarray$shape()
-      readback_maxshape <- ndarray$maxshape()
-      expect_equal(length(readback_shape), length(readback_maxshape))
-      # We can't as.integer things that don't fit in a 32-bit int -- they come
-      # back as NA. :(
-      expect_true(all(readback_shape <= readback_maxshape))
-
-      # Resize tests upcoming on
-      # https://github.com/single-cell-data/TileDB-SOMA/issues/2407
-      ndarray$close()
-
-      rm(ndarray)
-      gc()
-    }
-  }
-})