Skip to content

indexPolicy — Per-Half GSI Membership Rules

A DynamoDB item is one row. In real systems, multiple writers may touch a single item for different reasons — a telemetry pipeline writing per-event clock and alert state, an enrichment job writing the owning account, a stamp writer marking a side-channel published timestamp. Each writer touches a different subset of attributes, and each one issues a partial Entity.update payload.

GSI keys are derived from those attributes — a partition key like gsi1pk is composed from one or more composite attributes on the model. A writer that doesn’t supply some composites shouldn’t have to either read the item first or silently break the index. indexPolicy is how you tell the library what to do, per half, when an update doesn’t have everything needed to recompose a GSI key.

2. Mental model — three units, three contracts

Section titled “2. Mental model — three units, three contracts”

Three units to keep distinct:

  • Composite attribute — a model field declared as part of a GSI’s key (e.g. accountId, alertState).
  • GSI key attribute — the physical DynamoDB attribute the library writes (gsi1pk, gsi1sk). Each is composed from one or more composite attributes.
  • GSI — the queryable index. An item appears in GSI query results if and only if both gsiNpk and gsiNsk are present (DDB projection rule — invisible items are still in the table, just not in the GSI).

Three contracts, all per-half:

preserve is a contract with other writers (“don’t disturb my key when you fire”); sparse is a contract with yourself as the half’s owner (“drop my key if I touch this half but can’t compose it”); Entity.remove([attr]) is the explicit signal that a composite is gone — the library REMOVEs the half(s) containing the cleared attribute.

That framing — declaration / evaluation / outcome / cascade are all per-half — is what makes the multi-writer story work. v1.7.0 had three connected bugs in how outcomes were rolled up; v1.7.1 makes the model uniformly per-half across all four pillars.

SettingWhat it meansWhen to use
'preserve' (default)“If the update touches my half but I can’t compose it, leave the stored GSI key alone.” Stored value may go stale until the next write that can compose, or an Entity.remove cascade fires.When the half’s composites are owned by another writer — the other writer’s key value is the source of truth, your update should pass through.
'sparse'”If the update touches my half but I can’t compose it, REMOVE the stored GSI key — the item drops out of this half of the GSI.”When this writer is the half’s sole owner and absence means “shouldn’t be indexed here.”

Both rules fire only when the half is touched — see the per-half evaluation gate below.

Declare an indexPolicy per GSI:

const Devices = Entity.make({
model: Device,
entityType: "Device",
primaryKey: {
pk: { field: "pk", composite: ["channel", "deviceId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
// byCurrentAlert — the canonical hybrid GSI: pk is enrichment-owned
// (preserve so telemetry doesn't disturb it); sk is telemetry-owned
// (sparse so an event without an alert drops the half).
byCurrentAlert: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["accountId"] },
sk: { field: "gsi1sk", composite: ["alertState", "timestamp"] },
indexPolicy: { pk: "preserve", sk: "sparse" },
},
},
timestamps: true,
})

Halves omitted from indexPolicy default to 'preserve'. If you don’t declare indexPolicy at all, both halves are 'preserve'.

5. The structural rule (longest valid leading prefix)

Section titled “5. The structural rule (longest valid leading prefix)”

For each touched half (PK and SK independently), the library walks the composite list left-to-right and builds the longest valid leading prefix of values present in the merged record ({ ...storedKeyAttrs, ...payload }).

Composite state for the halfOutcome
All composites presentSET the half from the full composition
Trailing composites absent ([A, B, _, _])SET the half truncated to the leading prefix [A, B]
Empty leading prefix OR hole pattern ([_, C] or [A, _, C])Compose failed — see “Per-half outcome” below

Truncation on PK is the same hierarchical demotion as truncation on SK. An item with pk.composite = ['accountId', 'fleetId'] and fleetId absent composes a partition key of account#A and stays queryable at the account scope.

Per-half evaluation gate (reframed as a skip-predicate in v1.7.3)

Section titled “Per-half evaluation gate (reframed as a skip-predicate in v1.7.3)”

Before the structural rule even runs, each half is asked one question: can this half be safely skipped? The gate exists for one reason — multi-writer protection: prevent writer A from clobbering keys for halves it doesn’t own when writer B does own them. The skip-predicate states that purpose directly:

A half is skipped iff multi-writer protection actually applies — composites exist (otherwise the half value is a constant prefix and there’s nothing to multi-writer-clobber), no composite was explicitly removed via Entity.remove([...]), AND every composite is absent from BOTH the update payload AND the keyRecord (the entity primary-key attributes carried alongside the payload). If the half cannot be safely skipped, it is evaluated — SET / REMOVE / noop per the structural rule below.

This is what makes the multi-writer story work end-to-end:

WriterPayloadbyCurrentAlert eval
Stamp{ published: "2026-04-30" }Both halves skipped (composites not in payload, not in keyRecord, not in Entity.remove, both halves have composites) → noop, both halves preserved
Enrichment{ accountId: "newAcct" }pk evaluated (SET); sk skipped (noop)
Telemetry{ alertState: "active", timestamp: T }pk skipped (noop); sk evaluated (SET)

The skip-predicate has three structural conditions, each closing a different class of regression:

  1. composites.length > 0 — empty-composite halves (e.g. sk: { composite: [] }) are always evaluated. The half value is a constant prefix; multi-writer protection doesn’t apply, and skipping would leave the key permanently missing. Closes #46 — see the empty-composite half pitfall below.
  2. !hasRemoved — halves with any composite in Entity.remove([...]) are always evaluated. Removal intent must run the structural rule (which may then SET-truncate, REMOVE under sparse, or REMOVE under preserve via the cascade override).
  3. every(composite NOT in updatePayload AND NOT in keyRecord) — only writers genuinely not claiming ownership of the half (composites absent from both input sources) get the skip. Closes #41 (multi-writer leak — composites in payload mean ownership) and #43 (PK-composites-only — entity-PK composites arrive via keyRecord, never via payload, so they correctly fail this clause and are always evaluated). The resulting SET for PK-composites-only halves is idempotent because PK composites are immutable.

The skip-predicate’s negation is observably equivalent to the touched-predicate ||-chain that v1.7.0 → v1.7.2 progressively built up — same SET/REMOVE outcomes for every input. v1.7.3 reframes the gate to make its purpose (multi-writer protection) the only thing the predicate checks, so future degenerate-case shapes fall out automatically instead of requiring another || arm.

6. Per-key outcome rolls up to per-key storage

Section titled “6. Per-key outcome rolls up to per-key storage”

The roll-up is per-key — each half’s outcome applies to its own key attribute only. PK dropping doesn’t drop SK; SK dropping doesn’t drop PK. The item may be invisible in the GSI during a single-half-dropped period (DDB projection rule needs both keys for query visibility), but the surviving half’s value persists. When the missing half is later composed again, the item rejoins the GSI without the other writer needing to re-fire.

Outcome for this half’s key attributeConditions
noop (leave key alone)Half is skipped by the gate (multi-writer protection applies — has composites, no removal, every composite absent from payload AND keyRecord)
SET fullHalf evaluated, all composites composable
SET truncated to leading prefixHalf evaluated, leading prefix non-empty, no hole
REMOVE this half’s keyHalf evaluated, can’t compose (empty leading prefix OR hole pattern), AND policy is 'sparse' OR a composite is in Entity.remove([...]) (cascade override under preserve)
noop (stored key may go stale)Half evaluated, can’t compose, policy is 'preserve', AND no composite is in Entity.remove

The “noop with stale risk” row is the one place sparse vs preserve actually differ at runtime. Under preserve, if you set({ X: undefined }) for an optional composite without supplying surrounding context, the GSI key isn’t updated — it retains whatever was stored last. To force a clean cleanup under preserve, use Entity.remove(["X"]): the cascade override fires and REMOVEs the half.

7.1 Single-writer sparse — alert lifecycle

Section titled “7.1 Single-writer sparse — alert lifecycle”
// Lifecycle: alertState is the only sk composite, and it's owned by a
// single ingest writer. Declaring sk: sparse means "if my event doesn't
// have an alertState, drop my sk key — the item shouldn't be in this GSI."
yield* db.entities.Devices.put({
channel: "c-1",
deviceId: "d-1",
accountId: "acme",
alertState: "active",
timestamp: "2026-04-30T10:00:00Z",
})
// "No alert this event" — alertState explicitly undefined. sk touched
// (alertState is in payload), can't compose (empty leading prefix) →
// sparse → REMOVE sk. pk untouched → preserved.
yield* db.entities.Devices.update({ channel: "c-1", deviceId: "d-1" }).set({
alertState: undefined,
timestamp: "2026-04-30T11:00:00Z",
})
// Item invisible in byCurrentAlert (DDB needs both keys), but gsi1pk
// preserved. Re-adding alertState recomposes sk → item rejoins WITHOUT
// the enrichment writer needing to re-fire.

7.2 Multi-writer preserve + sparse — full lifecycle

Section titled “7.2 Multi-writer preserve + sparse — full lifecycle”
// Item exists with all composites populated.
yield* db.entities.Devices.put({
channel: "c-2",
deviceId: "d-2",
accountId: "acme",
alertState: "active",
timestamp: "2026-04-30T10:00:00Z",
})
// Stamp writer — touches a non-composite attribute. Both halves untouched
// → both preserved. (v1.7.0 would have REMOVE'd sk because sparse fired
// on the untouched sk half.)
yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({
published: "2026-04-30",
})
// Item still visible in GSI under acme + active.
// Enrichment writer rotates the account. pk touched → SET pk full
// (account#newAcct). sk untouched → noop. Item re-indexes under newAcct;
// stored gsi1sk unchanged.
yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({
accountId: "newAcct",
})
// Telemetry writer fires fresh. sk touched → SET sk full
// (alert#active#ts#T2). pk untouched → noop.
yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({
alertState: "active",
timestamp: "2026-04-30T11:00:00Z",
})
// Telemetry "no alert" event — sk touched, can't compose, sparse →
// REMOVE sk. pk preserved. Item invisible in GSI but gsi1pk persists.
yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({
alertState: undefined,
timestamp: "2026-04-30T12:00:00Z",
})
// Telemetry rejoins. sk touched, composes fine → SET sk full. pk
// untouched, value still acme from way back. Item re-visible under
// newAcct + new sk — WITHOUT the enrichment writer re-firing. This is
// the critical multi-writer property the v1.7.1 per-key roll-up enables.
yield* db.entities.Devices.update({ channel: "c-2", deviceId: "d-2" }).set({
alertState: "active",
timestamp: "2026-04-30T13:00:00Z",
})

7.3 Hierarchical truncation via set+remove

Section titled “7.3 Hierarchical truncation via set+remove”
// Initial state — full hierarchy populated.
yield* db.entities.Assets.put({
assetId: "rack-42",
region: "americas",
country: "us",
city: "sf",
site: "datacenter-1",
})
// gsi1sk = "$indexpolicy-demo#v1#asset#country_us#city_sf#site_datacenter-1"
// Asset leaves the datacenter — *demote*, don't evict. Supply surviving
// composites via set; invalidate the leaf via remove. Per the set/remove
// asymmetry, the structural rule succeeds with the leading prefix
// [country, city] → SET sk truncated. The cascade override only fires
// when the rule CAN'T compose — here it composes fine.
yield* db.entities.Assets.update({ assetId: "rack-42" })
.set({ country: "us", city: "sf" })
.remove(["site"])
// gsi1sk = "$indexpolicy-demo#v1#asset#country_us#city_sf"
// Asset still queryable at city level via begins_with.
TriggerGranularityWhere it livesIntent
'sparse' policy + touched-half + can’t-composeper-halfper-GSI declaration, implicit”I touched this half but can’t compose it — drop me”
Entity.remove([attr]) cascadeper-half (per attribute, fires on the half(s) containing it)per-call, explicit”this composite is gone — drop the half(s) it belongs to”

Cascade and sparse interact:

  • Cascade always makes its half touched.
  • For touched halves where the structural rule CAN compose (e.g. set({ country, city }).remove(["site"]) — leading prefix [country, city] is non-empty), the structural rule wins → SET truncated.
  • For touched halves where the structural rule CAN’T compose, the per-half outcome rule fires:
    • 'sparse' → REMOVE this half’s key.
    • 'preserve' + removedSet → REMOVE this half’s key (cascade override under preserve).
    • 'preserve' + no removedSet → noop (stored key retained).

set({ X: null }) doesn’t compile for composites

Section titled “set({ X: null }) doesn’t compile for composites”

Composites can never be null (EDD-9025 — see §10 below). The v1.7.0 update-payload type widening revert means the model’s declaration is the source of truth: a Schema.optional(Schema.String) composite accepts T | undefined, not T | null. set({ X: null }) on a composite is a TypeScript error.

// ❌ TypeScript error if X is a composite (model can't widen to include null,
// payload doesn't widen beyond model)
yield* db.entities.X.update(key).set({ X: null })
// ✅ Use undefined for an optional composite, or Entity.remove for required
yield* db.entities.X.update(key).set({ X: undefined }) // X must be Schema.optional
yield* db.entities.X.update(key).remove(["X"]) // works for required + optional

Preserve + set({ X: undefined }) leaves stale risk

Section titled “Preserve + set({ X: undefined }) leaves stale risk”

Under preserve, an set({ X: undefined }) on an optional composite touches the half but can’t compose without X. Per the per-half outcome rule, preserve + can’t-compose + no removedSet → noop. The stored gsi1sk is not updated — it retains whatever was last composed, possibly a stale value derived from the old X.

To force the cleanup, use Entity.remove(["X"]) instead. The composite is added to removedSet, the cascade override fires, and the half’s key is REMOVE’d cleanly:

// Stale risk — stored gsi1sk may reflect old X
yield* db.entities.X.update(key).set({ X: undefined })
// Force cleanup — REMOVE the half's key via cascade override
yield* db.entities.X.update(key).remove(["X"])

Entity.remove(["X"]) doesn’t truncate without supplied parents (set/remove asymmetry)

Section titled “Entity.remove(["X"]) doesn’t truncate without supplied parents (set/remove asymmetry)”

set provides values to compose with. remove invalidates a composite without providing a replacement. The library has no read-before-write — so remove without surrounding set context can’t truncate (it doesn’t know what to truncate to) and instead REMOVEs the half’s key entirely.

// Truncation works — surviving composites supplied via set, leaf invalidated via remove
yield* db.entities.Assets.update(key)
.set({ region, country, city })
.remove(["site"])
// → SET gsi1sk truncated to "region#R#country#C#city#S"
// REMOVE the whole half — surviving composites not supplied
yield* db.entities.Assets.update(key).remove(["site"])
// → REMOVE gsi1sk entirely (no surviving values to compose with)

If you want to demote (truncate) hierarchically, you must set the surviving composites in the same call.

Multi-writer leak (v1.7.0 footgun, fixed in v1.7.1)

Section titled “Multi-writer leak (v1.7.0 footgun, fixed in v1.7.1)”

In v1.7.0, a stamp writer that touched only a non-composite attribute (e.g. set({ published: "2026-04-30" })) would still trigger sparse-policy evaluation on every GSI half declared sparse — and REMOVE the half’s key even though the stamp writer wasn’t claiming ownership of the half. This made the multi-writer pattern impractical.

v1.7.1’s per-half evaluation gate fixes this: untouched halves are skipped entirely. The stamp writer above leaves both halves alone.

byChannel GSI returns 0 items in v1.7.0 / v1.7.1 (PK-composites-only regression, fixed in v1.7.2)

Section titled “byChannel GSI returns 0 items in v1.7.0 / v1.7.1 (PK-composites-only regression, fixed in v1.7.2)”

In v1.7.0 and v1.7.1, GSIs whose composites are entirely entity primary-key composites — e.g. byChannel: { pk: [channel], sk: [deviceId] } on a Telemetry entity with primaryKey: [channel, deviceId] — were silently skipped on every write. The per-half evaluation gate consulted only the update payload, but PK composites never appear there: the writer addresses the row by key, never restates them in .set({...}), and .append() separates structural fields from the SET clause. The composer ran but the gate classified both halves as untouched → gsi*pk / gsi*sk were never written → channel-scoped GSI queries returned nothing.

v1.7.2 fixes this in two places:

  1. The per-half evaluation gate now also counts keyRecord membership, so PK-composite-only halves are touched on every write that has a keyRecord.
  2. Entity.append() no longer filters PK composites out of the payload it passes to the composer (the filter never solved a real problem and combined with #1 to silently break this pattern).

Affected items: items written to PK-composites-only GSIs under v1.7.0 or v1.7.1 will repair themselves on the next Entity.update() against them. The gate now fires correctly, the structural rule composes the immutable PK values, and the missing GSI keys are SET. No data migration is needed; reads via the GSI start returning these items as their next update lands. If you have items that aren’t naturally updated, a one-shot bulk set({ otherField: ... }) or Entity.update(key).set({}) (touching no fields, just bumping updatedAt + version) is enough to repair them.

This pattern is common — tenant-scoped queries (byTenant: { pk: [tenantId] }), entity-key-projected GSIs, channel-scoped time-series. Test coverage for the policy-aware composer must always include the PK-composites-only shape; missing it (as the v1.7.1 fixture matrix did) is what produced this regression.

Empty-composite-half GSI returns 0 items in v1.7.0–v1.7.2 (fixed in v1.7.3)

Section titled “Empty-composite-half GSI returns 0 items in v1.7.0–v1.7.2 (fixed in v1.7.3)”

In v1.7.0, v1.7.1, and v1.7.2, GSIs with at least one half declared composite: [] (the standard “bare entity prefix” pattern, common in single-table-design lookup GSIs) had that half silently skipped on every Entity.update(). The per-half evaluation gate used a “touched” predicate built from .some(...) clauses — and .some(...) over an empty array trivially returns false, so the gate classified empty halves as untouched and never composed them. Entity.put() worked correctly (it uses the separate composeAllKeys path), but any subsequent .update() that touched the OTHER half left the empty-composite half missing — and worse, an .update() that bound a previously-sparse GSI for the first time (e.g. setting deviceBinding on a vehicle that was created without one) wrote only the PK half, leaving the SK missing → invisible to the GSI.

// Reproducer (#46) — Vehicle entity with byDeviceBinding lookup GSI.
const Vehicles = Entity.make({
model: Vehicle,
entityType: "Vehicle",
primaryKey: {
pk: { field: "pk", composite: ["id"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byDeviceBinding: {
name: "gsi3",
pk: { field: "gsi3pk", composite: ["deviceBinding"] },
sk: { field: "gsi3sk", composite: [] }, // ← empty SK composite
},
},
})
// Pre-v1.7.3:
yield* db.entities.Vehicles.put({ id: "veh-1" }) // sparse, no gsi3 keys
yield* db.entities.Vehicles.update({ id: "veh-1" }).set({ deviceBinding: "cloud#dev-1" })
// → gsi3pk SET, gsi3sk NEVER written → query returns 0 items.
// v1.7.3:
// → gsi3pk SET, gsi3sk SET to constant prefix → query returns the vehicle. ✓

v1.7.3 reframes the per-half gate from a touched-predicate to a skip-predicate keyed on the gate’s actual purpose (multi-writer protection). The leading composites.length > 0 short-circuit keeps empty-composite halves “always evaluated” — the value is a constant prefix, so there’s nothing for another writer to clobber, and skipping would just leave a permanently missing key.

Affected items: vehicles (or other entities with this shape) that were .update()’d under v1.7.0–v1.7.2 with a missing empty-half key will repair themselves on the next .update() against them under v1.7.3. The next write composes the missing half from the constant prefix and the item rejoins the GSI. No data migration is needed; reads via the GSI start returning these items as their next update lands. If you have items that aren’t naturally updated, a one-shot bulk set({ otherField: ... }) (touching no GSI composites, just bumping updatedAt + version) is enough to repair them.

This shape is common in single-table designs — any GSI used as a flat lookup (byOwner, byEmail, byExternalId) where the SK is just the entity prefix. Test coverage for the policy-aware composer must always include empty-composite halves; missing it (as the v1.7.2 fixture matrix did) is what produced this regression.

EDD-9025 — composite attributes can’t be nullable

Section titled “EDD-9025 — composite attributes can’t be nullable”

At Entity.make() time, the library walks every composite across primaryKey, every entry in indexes, and every entry in unique constraints. For each composite’s Schema, it inspects the AST and throws CompositeNullableError (EDD-9025) if null is reachable anywhere in the type union (Schema.NullOr, Schema.NullishOr, Schema.Union with a Schema.Null branch, etc.).

The semantic justification: composites participate in string composition (acc#X#alert#Y); null is not a meaningful slot value, only present-with-value or absent. Allowing Schema.NullOr on a composite would let set({ composite: null }) typecheck and then either blow up at runtime or silently produce a key with the literal string "null" as a slot.

// ❌ Throws EDD-9025 at Entity.make() time:
class BadModel extends Schema.Class<BadModel>("BadModel")({
id: Schema.String,
tenantId: Schema.NullOr(Schema.String), // ← composite nullable
}) {}
Entity.make({
model: BadModel,
entityType: "BadModel",
primaryKey: { pk: { field: "pk", composite: ["id"] }, sk: { field: "sk", composite: [] } },
indexes: {
byTenant: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["tenantId"] },
sk: { field: "gsi1sk", composite: [] },
},
},
})
// → CompositeNullableError [EDD-9025]:
// composite attribute "tenantId" on index "byTenant" resolves to a Schema
// that includes `null` ... Replace the nullable wrapper with Schema.optional(...)
// ✅ Use Schema.optional(...) — produces T | undefined, no null:
class OkModel extends Schema.Class<OkModel>("OkModel")({
id: Schema.String,
tenantId: Schema.optional(Schema.String), // ← T | undefined; sparse pattern works
}) {}

Combined with the v1.7.x update-payload type widening revert, set({ composite: null }) is a TypeScript error two ways — the model can’t widen the composite to include null, and the update payload type isn’t widened beyond the model. The stale-GSI-via-set-null footgun is closed at the type level.

EDD-9024 (CompositeKeyHoleError) — deprecated in v1.7.1

Section titled “EDD-9024 (CompositeKeyHoleError) — deprecated in v1.7.1”

The error class is still exported for back-compat with consumers who type-imported it for Effect.catchTag handlers, but it is no longer thrown at runtime under v1.7.1. Hole patterns now collapse into the unified per-half can’t-compose rule (drop under sparse, noop-or-cascade-override under preserve). Remove any Effect.catchTag("CompositeKeyHoleError", ...) handlers — the throw site is gone and the catch is dead code.

Per the per-half evaluation gate, each half is skipped iff multi-writer protection actually applies — and evaluated otherwise. The skip condition is:

  • the half has composites (composites.length > 0) — empty-composite halves are always evaluated (#46), AND
  • no composite is in Entity.remove([...]) (cascade intent always evaluates), AND
  • every composite is absent from BOTH the update payload AND the keyRecord (the entity primary-key attributes used to address the row).

Equivalently, a half is evaluated when ANY of these holds:

  • the half has no composites (constant prefix — always SET), OR
  • a composite is in Entity.remove([attr]), OR
  • a composite is in the update payload (the writer is asserting ownership), OR
  • a composite is in keyRecord (entity-PK composites — added to the gate in v1.7.2, #43).

The presence of an indexPolicy declaration does not force evaluation of every update under v1.7.1+ (this is a behavior change from v1.7.0). The per-half gate is the same regardless of whether indexPolicy is declared.

put() does NOT consult indexPolicy. It writes a complete item from scratch — any missing composite means “this item is not in that GSI.” Unchanged from prior versions.

v1.7.x unifies .append() with .update() — both call the same composer with the same per-half evaluation gate. Composites outside appendInput are absent under the structural rule, AND the half is untouched per the gate (composite name is not in payload, not in keyRecord, not in removedSet). Halves whose composites are entirely outside appendInput and entirely outside the entity primary key are skipped.

For GSIs whose composites are entirely entity-PK composites (e.g. byChannel: { pk: [channel], sk: [deviceId] } on primaryKey: [channel, deviceId]), the half is always touched via keyRecord membership and the structural rule composes from the immutable PK values on every append. The resulting SET is idempotent. (v1.7.0 / v1.7.1 silently skipped these GSIs — see the byChannel pitfall above; closes #43.)

A multi-writer entity should declare its GSI shape so each half is owned by a single writer’s domain — that’s the design rule and the natural fit with per-half policy.

Each GSI half should be entirely owned by a single writer’s domain. The library does not paper over cross-writer composite ownership — that’s a consumer-side modeling discipline.

With per-half policy, the per-half evaluation gate, and the structural composition rule, a writer that doesn’t own a GSI’s composites simply doesn’t supply them, and the half is skipped entirely. There is no per-composite leakage across writers like in v1.6, and no GSI-wide blast radius like in v1.7.0.

Concretely: if you have a hybrid GSI with pk = [accountId, alertState] where accountId is enrichment-owned and alertState is ingest-owned, restructure to put each writer’s composites on its own half — pk = [accountId], sk = [alertState, ...] — so each half has a single owner. The per-half policy then expresses each writer’s intent cleanly, and the per-half gate ensures one writer never disturbs the other’s key.

v1.7.2 is a patch release fixing the PK-composites-only GSI regression introduced by v1.7.1’s per-half evaluation gate (closes #43). No API changes — same indexPolicy: { pk, sk } declaration, same Entity.remove([...]) API, same EDD-9025 invariants.

Behavior change (strictly more correct than v1.7.1):

  • Per-half evaluation gate now also counts keyRecord membership. GSI halves whose composites are entirely entity primary-key composites are now correctly classified as touched on every write, and the structural rule composes their keys from the immutable PK values. The resulting SET is idempotent. Items written under v1.7.0 / v1.7.1 against such GSIs (where the gate silently skipped them) repair themselves on the next Entity.update() against them — no data migration needed; reads via the GSI start returning these items as their next update lands.

If you used the byChannel-style pattern (GSI composites = subset of entity PK) under v1.7.0 or v1.7.1 and noticed channel-scoped queries returning no items, this is the fix. Re-run any item that should be in the GSI through any update touching any field, and the GSI key composition will land.

v1.7.1 was a patch release fixing three connected bugs in the v1.7.0 roll-up. No API changes — same indexPolicy: { pk, sk } declaration, same Entity.remove([...]) API, same EDD-9025 invariants.

Behavior changes (all bug fixes — strictly more correct than v1.7.0):

  1. GSI-wide cascade on can’t-compose → per-key REMOVE. v1.7.0 REMOVE’d both gsiNpk AND gsiNsk whenever either half couldn’t compose; v1.7.1 REMOVEs only the half that couldn’t compose. The other half’s stored value persists (preserved across writers).
  2. Per-half evaluation gate (NEW). v1.7.0 fired the policy on every update of every GSI declaring indexPolicy, regardless of whether the writer touched the half. v1.7.1 skips untouched halves entirely.
  3. Entity.remove([attr]) is per-half (not GSI-wide). v1.7.0 REMOVE’d both halves of every GSI containing the cleared attribute; v1.7.1 REMOVEs only the half(s) whose composite list contains it.
  4. CompositeKeyHoleError (EDD-9024) deprecated — no longer thrown. Hole patterns collapse into the unified per-half can’t-compose rule.

Most consumers won’t see any difference because v1.7.0 just shipped. The behavior changes mainly affect the multi-writer pattern that v1.7.0 was designed to enable — and didn’t actually enable correctly.

1.7.x simplifies the v1.6 per-attribute callback model to per-half declaration. This is a breaking change in 1.7.0 (shipped as a minor bump because in-the-wild consumer count is ~1).

Per-attribute callback → per-half object. Replace the callback API with the per-half object literal. Take the most-restrictive per-attribute policy on each half:

// 1.6.0
indexPolicy: () => ({
region: "preserve",
country: "preserve",
alertState: "sparse",
})
// 1.7.x — pick the most-restrictive per-half. If alertState is on the SK
// half and is the membership key, declare the SK half sparse.
indexPolicy: { pk: "preserve", sk: "sparse" }

set({ attr: null })Entity.remove([attr]). The v1.6 “set null = drop” pattern no longer cascades GSI keys (the structural composer treats null as absent — the same as omitted). For the explicit-drop intent, use Entity.remove([attr]):

// 1.6.0 — implicitly cascaded to drop the GSI
yield* db.entities.Devices.update(key).set({ alertState: null })
// 1.7.x — explicit per-attribute drop + per-half cascade
yield* db.entities.Devices.update(key).remove(["alertState"])

Mixed sparse/preserve attrs in same half → not expressible. Pick one per half. The half is a single concatenated string; per-attribute mixing within a half had no coherent runtime semantic anyway under the v1.6 model.

Update-payload type widening reverted. v1.6 wrapped each update field in Schema.NullishOr so set({ attr: null }) would always typecheck. v1.7.x reverts this; set({ attr: null }) only compiles when the model declares the attr as nullable. Combined with EDD-9025, this closes the stale-GSI footgun at the type level.

EDD-9025 — composite attribute schemas must not include null. Convert any nullable composites to Schema.optional(...) (T | undefined). This catches a footgun that v1.6 silently allowed.

Hierarchical PK truncation is now supported (additive). The v1.6 PK-clear-degrades-to-sparse asymmetry is gone — pk.composite = ['accountId', 'fleetId'] with fleetId absent now truncates the PK to account#A instead of dropping the GSI. If you relied on the old PK-drop behavior, declare the PK half as 'sparse' to keep that semantic.

Full working program (requires DynamoDB Local):

Terminal window
docker run -p 8000:8000 amazon/dynamodb-local
npx tsx examples/guide-index-policy.ts

See examples/guide-index-policy.ts for the full source.