# Array Write Ergonomics & Default-Fill Partial Writes — Design

Date: 2026-06-18

## Problem

Writing array-typed MXAccess attributes through the gateway has two ergonomic
shortfalls:

1. **Asymmetric addressing.** An array attribute reads fine by its bare name
   (`Obj.Arr`), but writes require the `[]` body suffix (`Obj.Arr[]`). The
   handle registered from the bare name is read-capable but not cleanly
   write-capable.
2. **Whole-array writes only.** Every write replaces the entire array; to change
   a few elements the client must marshal and send the full array. This is a
   native MXAccess COM constraint (there is no element-wise write API), but it
   pushes avoidable cost onto clients for large arrays.

This design removes both frictions without breaking MXAccess parity. The worker
is not modified — it continues to perform an honest whole-array COM write. All
new behavior lives in the gateway and the contract.

## Why MXAccess forces whole-array writes

The native MXAccess COM `Write` takes a complete VARIANT (`SAFEARRAY` for
arrays). There is no `WriteArrayElement(index, value)`. Confirmed in the worker:
`VariantConverter.ConvertToComArray` marshals the entire CLR array in one shot,
and `MxAccessSession.Write` forwards it verbatim to the COM proxy. Any "partial
write" feature must therefore reconstruct a full array before the COM call.

We deliberately do **not** reconstruct it from current state (no
read-modify-write merge): that would add latency, cache-staleness, and a race
window against other writers, and would paper over MXAccess semantics. Instead
partial writes are **stateless default-fill** (see below).

## Goals

- Writing an array attribute by its bare name works like reading does — the
  gateway appends `[]` automatically when it knows the attribute is an array.
- A client can send only the indices it wants plus a total length, instead of
  the full array.

## Non-goals

- **No preserve-unchanged merge.** Unmentioned indices are written as the
  element type's default, **not** kept at their current value.
- No element-wise COM write — MXAccess has no such API; every write is
  whole-array and we keep it that way.
- No change to `ReadBulk` string addressing.
- The gateway does **not** infer total length; the client supplies it.

## Decisions (resolved during brainstorming)

| Question | Decision |
|---|---|
| Scope | Both: suffix ergonomics **and** partial writes |
| Partial-write semantics | Stateless **default-fill**: unmentioned indices = type default (reset, not preserved) |
| Total length | **Client specifies** `total_length` explicitly |
| Time/timestamp default | **Unix epoch** |
| Suffix fix location/actor | **Gateway**, using in-memory Galaxy `is_array` metadata, at **AddItem** time |
| Suffix fallback when metadata unavailable | **Pass through unchanged** (no regression) |
| Partial-write contract shape | New `MxSparseArray` as a `oneof` arm on `MxValue` |
| Per-client helpers | **Included** in this change |

## Contract changes (`mxaccess_gateway.proto`)

A write-only sparse representation, added as a `oneof kind` arm on `MxValue` so
every write command (`Write`, `Write2`, `WriteSecured`, `WriteSecured2`,
`WriteBulkEntry`) accepts it without new RPCs:

```proto
message MxSparseArray {
  MxDataType element_data_type = 1;
  uint32 total_length = 2;
  repeated MxSparseElement elements = 3;
}

message MxSparseElement {
  uint32 index = 1;
  MxValue value = 2;   // scalar
}

// added to MxValue oneof kind:
//   MxSparseArray sparse_array_value = 19;
```

`sparse_array_value` is **write-only**: the worker never produces it, and the
gateway rejects it on any read/event path. Regenerate `Generated/` and commit
the generated `.cs` (the net48 worker build needs the checked-in types — see the
proto-codegen-regen rule).

## Suffix normalization — at `AddItem`, in the gateway

The item handle binds to the literal address string at `AddItem` and is reused
for both reads and writes; at write time only the integer handle is available,
which is too late to change the address. So normalization happens at
registration.

In the gateway's `AddItemCommand` / `AddItem2Command` handling
(`GatewaySession`), before forwarding to the worker:

1. If `item_definition` already ends with `[]` → leave unchanged.
2. Else look up `item_definition + "[]"` in the in-memory Galaxy hierarchy cache
   (`IGalaxyHierarchyCache` → `GalaxyTagLookup.Attribute.IsArray`). The index is
   keyed by `FullTagReference`, which already carries the `[]` suffix for
   arrays, so the lookup key must include `[]`. If found and `is_array` →
   rewrite `item_definition` to the `[]` form.
3. **Fallback:** metadata unavailable or address not found as an array →
   forward verbatim (current behavior).

Store the **normalized** address in `SessionItemRegistration.TagAddress` so
write-time constraint checks (`ConstraintEnforcer`) and readback resolve
consistently against the `[]`-keyed index.

This is safe for reads: both the bare and `[]` forms return the array on read,
so promoting a registration to the `[]` form does not change read behavior — it
only makes the handle write-capable.

`AddItem2Command` (with `item_context`) normalizes `item_definition` the same
way. `ReadBulk` is unaffected — it uses raw address strings with its own
ephemeral registration, so bare-name reads continue to work unchanged.

## Partial-write expansion — at the gateway, worker untouched

In the gateway write path, before forwarding any write command to the worker, if
`MxValue.KindCase == SparseArrayValue`:

1. Allocate a full array of `total_length`, element type `element_data_type`.
2. Initialize every slot to the type default:
   - `bool` → `false`
   - `int32` / `int64` → `0`
   - `float` / `double` → `0`
   - `string` → `""`
   - `time` / `timestamp` → Unix epoch
3. For each `MxSparseElement`, set `array[index]` from the scalar `value`.
4. Replace the `MxValue` with a normal `array_value` (full `MxArray`).

The worker then receives an ordinary whole-array `MxValue`;
`VariantConverter.ConvertToComArray` and the COM `Write` are unchanged. Parity
preserved — it really is a whole-array write.

Expansion is applied uniformly to every write variant by normalizing the
`MxValue` of each command (`Write`, `Write2`, `WriteSecured`, `WriteSecured2`,
and each `WriteBulkEntry`) before it leaves the gateway.

## Validation & errors (gateway, `InvalidArgument`)

- `total_length == 0`, or any `index >= total_length` → reject.
- Duplicate indices → reject (no silent last-wins).
- `element_data_type` must be a supported scalar element type (not `Raw` /
  `Unspecified`); each element `value` must match it.
- Empty `elements` with `total_length = N` → valid: writes an all-defaults array
  of length N (explicit reset).
- A sparse value arriving on a read/event path → reject (guard; the worker never
  produces one).

## Clients (all five) & docs — same change

Per the repo rule that docs change with the source:

- Regenerate proto types for dotnet, go, python, rust, java. Watch the Java
  generated-file churn — revert spurious protobuf-version diffs when no `.proto`
  semantics changed beyond the new messages; commit the net48-relevant regen.
- Add a thin per-client helper to build a sparse write, e.g.
  `WriteArrayElements(handle, totalLength, {index → value})`.
- Update the **"Array writes replace the whole array"** section in all five
  client READMEs: document default-fill semantics (unmentioned = reset to
  default, not preserved), the `total_length` requirement, and that bare-name
  array writes now auto-normalize to the `[]` form.
- Update `gateway.md` (command/value surface) and the value-conversion doc.

## Testing

- **Gateway (FakeWorkerHarness):**
  - Sparse → full expansion per element type; default-fill sizing; correct
    placement of specified indices.
  - `total_length == 0`, index-out-of-range, and duplicate-index rejection.
  - Empty-elements all-defaults case.
  - Suffix normalization: bare array → `[]`; bare scalar → unchanged;
    already-`[]` → unchanged; metadata-cold → pass-through.
- **Clients:** helper + round-trip serialization per language.
- **Live MXAccess (opt-in, windev):** one default-fill write and one bare-name
  array write against real COM.

## Affected components

- Contracts: `mxaccess_gateway.proto` + regenerated `Generated/`.
- Gateway: `GatewaySession` (AddItem normalization, write expansion),
  `SessionItemRegistration` (store normalized address), interaction with
  `IGalaxyHierarchyCache` / `GalaxyTagLookup`.
- Worker: **unchanged.**
- Clients: dotnet, go, python, rust, java (regenerated types + helper + README).
- Docs: `gateway.md`, value-conversion doc, five client READMEs.
