Property Lists

Why

Use property lists for ordered collections that can grow beyond DynamoDB’s 400KB item limit. Items are stored individually with list metadata for scalable operations.

Basics

notes = actor.property_lists.notes
notes.append("First note")
notes.append({"title": "Meeting", "content": "Team sync"})
first = notes[0]
count = len(notes)  # see "Storage Format" below -- not free under v2
for item in notes:
    print(item)
all_items = notes.to_list()

Metadata

notes.set_description("Personal notes")
notes.set_explanation("User‑generated notes and reminders")
desc = notes.get_description()
expl = notes.get_explanation()

Common Operations

  • append(item)

  • insert(index, item)

  • pop(index=-1)

  • remove(value)

  • clear()

  • delete() (delete entire list)

  • slice(start, end) (efficient range load)

  • index(value, start=0, stop=None)

  • count(value)

Use Cases

# Blog posts
blog_posts = actor.property_lists.blog_posts
blog_posts.append({"title": "Getting Started", "tags": ["tutorial"]})

# Webhooks
webhooks = actor.property_lists.webhook_endpoints
webhooks.append({"url": "https://api.example.com/webhook", "events": ["property_change"]})

# Activity log
activity = actor.property_lists.activity_log
activity.append({"timestamp": "2024-01-15T14:30:00Z", "action": "property_updated"})

When to Use

  • Regular properties: small key–value data, under ~50KB

  • Property lists: growing collections, list ops, complex items, or large datasets

Namespace Collision Detection

Property names and list property names share the same namespace. Attempting to create a property when a list with the same name exists, or vice versa, will raise a ValueError:

# This will raise ValueError if a list named 'notes' already exists
actor.properties.notes = "some value"

# This will raise ValueError if a property named 'tags' already exists
tags = actor.property_lists.tags
tags.append("python")

To resolve collisions:

  • Delete the existing property/list first, or

  • Use a different name for the new property/list

Migration Example

# Old: large JSON array (risk hitting 400KB limit)
actor.properties.user_notes = ["Note 1", "Note 2"]

# New: scalable list
notes = actor.property_lists.user_notes
for n in ["Note 1", "Note 2"]:
    notes.append(n)

Storage Format (v1 / v2)

List properties have two internal storage formats. Both are fully supported, and which one a given list uses does not change any REST response or any value the API returns.

It is not entirely invisible in one respect: reading items one at a time by index costs more under v2, and the cost is per item, not a fixed factor. lst[i] re-derives the position from the list’s key ordering before resolving it, because a cached ordering can be stale and resolving against a stale one returns the wrong item, and that re-derivation is a whole-list range query – the same query to_list() issues to read every item. So a for i in range(len(lst)): lst[i] loop over an n-item v2 list issues n whole-list queries – O(n) total capacity for the loop, not “two queries per item” – where the same loop under v1 costs one point read per item. On a 2026-08-19 production incident this made a 10-item delete cost roughly 1.19M RCU. Use to_list(), to_indexed_list() or plain iteration instead – each is a single query for the whole list regardless of length, in both formats, and to_indexed_list() returns the (index, item) pairs such a loop is usually after.

append()/extend() do not share that shape: as of 3.14, each reads only the list’s current LAST rank (one item’s read capacity, via get_last_in_range) rather than the whole ordering – a fixed cost regardless of list length. Before 3.14, append() did re-read the whole key ordering before writing, matching the positional-read cost above; this is the one exception the “positional access costs the whole list” rule used to have no exception for. extend() of n items pays that one read ONCE for the whole batch, not once per item.

  • v1 (dense integers): items stored as list:{name}-{index}, with an authoritative length in metadata. Every list created before this format existed.

  • v2 (fractional rank keys): items stored as list:{name}-#{rank}, where position is derived from sorting the rank keys – there is no separate stored length to disagree with, so the corruption class verify()/compact() exist to repair (holes and orphans from an interrupted delete/insert) cannot occur. Every new list is created in this format.

New list names may not contain # (reserved for internal storage keys); creating one raises ValueError immediately.

Counting items without paying for the whole list

len(lst) is always exact, on both formats – under v2 it counts the rank-key range, which is the same whole-list query to_list() issues. When you only need an approximate count – a UI badge, a rough quota check – lst.get_metadata()["length"] avoids that query entirely under v2, where it is served from count_hint: an item count list mutations maintain as a side effect, not counted fresh on every call. (Under v1 the two are identical – length has always been an authoritative stored field there.)

count_hint is advisory, and the drift bound is a documented contract: at any moment, |count_hint - len(lst)| is at most the number of mutations currently in flight against the list, plus – during a rolling deploy only – mutations applied by pre-3.14 writers since the last rank-counting 3.14 mutation, plus one per mutation whose advisory metadata touch failed since the last rank-counting mutation. It never accumulates beyond those three terms, and it self-corrects: the next insert()/pop()/remove()/del lst[i]/compact() call overwrites the hint with the counted truth (append()/extend() merge stored-plus-delta and never re-count on their own). A quiesced list whose mutations all landed cleanly reports an exact hint.

A quota check that must never over-admit should trust the hint while strictly below its limit, and confirm with the exact len() only once the hint reaches the limit – the whole-list read is then paid at the quota boundary, not on every save:

def within_quota(lst, limit: int) -> bool:
    hint = lst.get_metadata()["length"]
    if hint < limit:
        return True          # trust the advisory count
    return len(lst) < limit  # at the boundary: pay for the exact count

verify() reports drift beyond what a healthy list should show as count_hint_drift (informational, not part of healthy – expected drift under concurrent mutation is not corruption), and compact() always rewrites the hint to the counted truth as part of its rebalance.

Reading with `consistent=False`

to_list(), slice() and to_indexed_list() accept a consistent keyword, default True – the default does not change, so nothing about an existing call changes on upgrade. On DynamoDB, an eventually consistent range read costs half the read capacity of a strongly consistent one (measured on an 81-row list: 241 RCU -> 120.5 RCU). PostgreSQL accepts and ignores the parameter – its reads are consistent by construction.

Whether a stale read is acceptable is an application question, not a library one, which is why the library does not second-guess the choice: consistent=False on an instance that just wrote to this list may not see that write. It is only correct where the caller cannot have just written the rows it is about to read – a background report, a read-mostly cache refresh, a different actor’s read of data it did not just produce:

recent_notes = actor.property_lists.notes.to_list(consistent=False)

Positional access (lst[i], __setitem__, __delitem__, insert(), pop(), remove()) always reads strongly consistent and takes no such parameter – a stale rank feeding a positional write touches the wrong row, which is a correctness bug, not a cost trade. __iter__ (plain for item in lst) also takes no parameter for the same reason iteration exists as a convenience over to_list(); use to_list(consistent=False) directly when the saving matters for a full scan.

Note

A list created before this restriction existed may legitimately contain # in its name. Such a list keeps working as v1 forever (migration refuses it, see below), and the library keeps it isolated from any v2 list whose name is a prefix of it: a list named foo-#bar stores rows that fall inside the byte range a v2 list named foo reads, so the range read additionally requires a well-formed rank key. Renaming such lists is still the cleaner long-term answer, and it is what unblocks migrating them.

Migrating existing v1 lists to v2

Existing v1 lists keep working indefinitely – migration is optional and gradual, never required for a list to keep functioning:

  • Lazy (off by default): set ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH to a positive number and a v1 list with at most that many items migrates automatically the next time it’s mutated (append, insert, item assignment, or item deletion). 50 is a reasonable value. A failed lazy migration is logged and the mutation still succeeds against the v1 storage – migration never turns an ordinary write into a failure.

    It defaults to off because it is a rollback-safety control: see the danger box below. Leaving it off means the upgrade changes no stored data at all, so rolling back stays a pure code rollback. Turn it on once the release has been live long enough that rollback is off the table – or skip it and use the bulk script, which is the same operation on a rate limiter and at a time you choose.

    This does not make v2 opt-in: every list created from now on is v2 regardless of this setting. Only the conversion of lists that already exist is deferred.

    Two further things lazy migration deliberately will not do, whenever it is enabled:

    • It never migrates a damaged list. If verify() reports the list unhealthy, migration is skipped with an operator-actionable warning. Migration closes holes in flight, which is correct when an operator runs the script and reads its report – but silently, under an ordinary write, it would erase the evidence: the hole disappears, the lost item stays lost, duplicate residue becomes indistinguishable from real data, and verify() starts reporting the list healthy. Repair is an explicit decision. Run compact() (or verify_property_lists.py --repair), then migrate.

    • It runs inline, in the request. One append() to a 40-item v1 list performs the whole migration before the append itself – dozens of sequential writes plus two full-partition reads. That is the second reason to leave it at 0 on Lambda or any latency-sensitive deployment, after the rollback hazard below.

    When enabled, the size threshold is checked at the moment of the mutation, which is not the same as “large lists stay v1 until I run the script”. A clear() followed by extend() – a whole-list rewrite, the shape a prune or a re-sync usually takes – migrates a list of any original size, because the first append() inside extend() sees a list of length 0. If you are planning a controlled rollout, that is the case to know about; ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH=0 – the default – is the only setting that makes “no list migrates without me” true.

  • Bulk: larger or idle lists are migrated with the operator script:

    actingweb-migrate-property-lists              # dry run
    actingweb-migrate-property-lists --migrate
    

    The script reports lists it refuses to migrate – names containing # (rename first) and lists with holes or orphans (repair first) – and any duplicate-value residue it preserves as-is. The dry run exits 1 if anything would be refused, so 0 means the migration has nothing to trip over; treat that as the gate rather than reading the log.

  • Programmatic: actor.property_lists.<name>._list_prop.migrate_to_v2() migrates one list directly. Idempotent – safe to call again (a no-op once the list is already v2) and safe to re-run after an interruption.

Warning

Migration refuses a damaged list, and this is worth understanding rather than working around. Migrating a list with a hole in it is not merely lossy – it is unreportably lossy. The surviving rows are renumbered, so afterwards the hole is gone, the list verifies healthy, and nothing is left to say an item was ever destroyed. Repair first (actingweb-verify-property-lists --repair, or compact()), which closes the hole while leaving the duplicate evidence intact, and the question does not arise.

--migrate-damaged (or migrate_to_v2(allow_damaged=True)) exists for the operator who has looked at the damage and decided to move on. It logs what it is giving up.

Duplicate residue does not block migration, because it survives the conversion visibly – a v2 list’s verify() reports duplicates the same way a v1 list’s does. Only holes and orphans gate.

Danger

No list may become v2 until every process that serves it can read v2 – and that includes the release you might roll back to.

An older process does not error on a v2 list. It reads it as empty, silently: the metadata row still exists (so the list “exists”), but a v2 list stores no length field and a pre-v2 reader takes the absence as zero. Worse, a write from that process lands in v1 storage and the list forks – two versions, two disjoint views of one list, neither reporting anything wrong. --downgrade cannot reconcile a forked list afterwards; it overwrites v1 storage with the v2 content, destroying whatever the older process wrote there.

The dangerous direction is rollback, not deployment. A deploy leaves at most a brief mixed-version window. A rollback does not: deploy, let lazy migration convert lists for hours or days, then roll back for an unrelated reason, and every list that migrated now reads as empty in production. There is no timing to be lucky about, and recovery is --downgrade one list at a time, from a v2-capable checkout, against a database being served by the code you just rolled back to.

Migration forward is automatic, fleet-wide and inline. Recovery back is manual and per-list. Size your caution to that asymmetry.

This is why ``ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH`` defaults to 0. Out of the box, no existing list changes format, so an upgrade stays a pure code change with no data to reconcile if you roll back. Convert later, once the release has been live long enough that rollback is off the table, as a deliberate rate-limited step via scripts/migrate_property_lists.py --migrate – or by setting the variable to a positive number and letting ordinary writes do it. Either way it is your decision and your timing, which is the point.

Warning

There is no supported forward path from v1 to v2 other than migration, and downgrading a v2 list back to v1 is an emergency-only operation (scripts/migrate_property_lists.py --downgrade ACTOR_ID/list_name), intended for rolling back to a release that predates v2 support. It takes no lock against concurrent writes and is not part of the normal operational flow – do not script it into routine tooling.

Order matters. Where lazy migration has been enabled, a downgraded list at or under ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH is, by definition, a lazy-migration candidate again – if the current application (the one with v2 support) is still running against it, its very next mutation (append/insert/item __setitem__/ __delitem__) migrates it straight back to v2, silently undoing the downgrade. At the default of 0 that particular race is off, but the ordering rule is the same either way, because a v2-aware application still writes v2 to any list it creates or converts. Roll the application back to the pre-v2 release first, then run --downgrade against the database from a checkout that still has v2 support (the tool itself needs the v2 code to read the list it’s converting) – never the other way around.

Finding Items by Value

Most lists are addressed by position (lst[i]), but items are often looked up by an identifying field instead – the same identity_key verify() already checks for duplicates:

task = tasks.find("id", "task-42")           # first match, or None
matches = tasks.find_all("id", "task-42")     # every match

Both are a single whole-list read (to_list()), matched in memory – the same cost as any hand-written scan loop, just without every consumer writing one. An item that is not a dict, or that lacks identity_key entirely, never matches and never raises. Both accept the same consistent keyword to_list() does (see above).

Handles: reading for a later write

items_with_handles() (v2 lists only – raises on a v1 list, naming migrate_to_v2()) returns [(handle, item), ...] for the whole list in one read:

for handle, item in tasks.items_with_handles():
    if item.get("status") == "done":
        ...  # a later phase's update_by_handle()/delete_by_handle()
             # write conditioned on this exact handle

A handle is an opaque, single-use read receipt – not a durable identifier. It carries the item’s rank key and the exact raw stored string the read returned, and is only valid against the SAME list instance, within the same request or process that read it. Two things make it unsafe to persist or transmit:

  • It is not wire-stable across a list “generation”. A rank is unique only until the list is emptied: delete() followed by append() starts a brand new rank sequence from the same deterministic starting point, so a fresh list’s first item gets the identical rank the original list’s first item had. A handle read before the delete would silently address the wrong (new) item after one.

  • It is always read strongly consistent, regardless of any consistent=False used elsewhere against the same list – a stale handle’s conditional write would fail against a row nobody actually touched.

Writing by handle, or by value

delete_by_handle(handle) and update_by_handle(handle, item) (v2 lists only) condition a single write on the exact bytes a handle read:

for handle, item in tasks.items_with_handles():
    if item.get("status") == "done":
        tasks.delete_by_handle(handle)

Both are single-shot – there is no retry loop. A handle pins the exact stored bytes, so a failed condition (the row changed or vanished since the read) simply returns False; there is nothing to re-resolve, and retrying would mean “delete/overwrite whatever is there now” rather than the item the handle addressed. Check the return value if the answer matters to the caller.

For the common case of “every item matching a field”, remove_where() and update_where() wrap the scan-and-mutate loop above, and work on both storage formats:

removed = tasks.remove_where("status", "archived")
updated = tasks.update_where("status", "open", {"status": "in_progress"})

# stop after the first match instead of every match
tasks.remove_where("id", "task-42", first_only=True)

Through actor.property_lists.<name> (as above), both return the number of items actually affected; on the core ListProperty layer they instead return the affected items themselves (the removed values / the pre-update values), which is what the notifying wrapper builds its per-item subscription diffs from – len() of that list is the same count. Under v2 they are built on items_with_handles() plus the handle mutators above – one whole-list read, then one conditional write per match, same single-shot semantics per match (a match lost to a concurrent mutation between the read and its write is simply not counted, never retried). Under v1 they fall back to positional access; remove_where() applies multi-match deletes in descending index order specifically, since deleting ascending would shift every later match onto the wrong row as each earlier delete closes a hole.

A multi-match remove_where()/update_where() call against a list with subscribers registers one diff per affected item – see Subscription Manager for what that means for callback fan-out, and for the old_item field update_where()/update_by_handle() diffs carry.

Reading Many Lists Cheaply

If your code needs the contents of several of an actor’s lists at once – rendering a dashboard, say – fetching each list separately means one database read per list. Two methods return the names and the raw rows in one read, so you can fill in each list from data you already have.

All of them, when you want everything the actor has:

names, rows = actor.property_lists.list_all_with_rows()
for name in names:
    lst = getattr(actor.property_lists, name)
    lst.prime_from_rows(rows)          # uses `rows`, no extra read
    items = lst.to_list_from_rows(rows)

One namespace of them, when your page only renders lists sharing a name prefix – memory_personal, memory_travel, memory_food:

names, rows = actor.property_lists.list_prefix_with_rows("memory_")
for name in names:                     # only the memory_* lists
    lst = getattr(actor.property_lists, name)
    items = lst.to_list_from_rows(rows)

prefix is a prefix, not a namespace. It also matches a list named exactly memory, and siblings such as memory-old. Pass the delimiter if you mean a namespace: "memory_", not "memory".

Both halves of the return are scoped: names holds only the matching lists. That is worth reading twice, because it is the way a switch from list_all_with_rows() goes wrong quietly – code that keeps iterating names simply stops seeing every list outside the prefix, and nothing raises.

The two also differ on errors, deliberately. list_all_with_rows() returns ([], {}) if the read fails; list_prefix_with_rows() raises DbError. For a scoped read an empty result is a perfectly ordinary answer, so swallowing a failure would present a throttled query as “you have no memories”. An empty prefix raises ValueError rather than silently becoming the whole-partition read.

When the scoped read actually pays

Not universally, and the naming invites the wrong assumption. On a measured production account, list_all_with_rows() cost 1,361.0 read units across 11 chained pages; the five scoped reads covering the same lists cost 1,363.5 across 15 queries. Summing several scoped reads over everything is marginally worse than one whole-partition read. The saving comes entirely from the lists you skip: reading one namespace out of five cost 685.0 units across 8 queries, half the dump.

The latency win is yours to take, not the library’s. Each call is one synchronous query; the library issues no concurrent reads and spawns no threads. What makes several scoped reads faster than one dump is issuing them from independent request handlers or an async gather – and the floor is not one round trip but the deepest pagination chain among them, since a large list still pages.

One consequence of reading each namespace separately: two lists returned by two different calls reflect two different instants. There is no snapshot isolation across calls, and none across lists within one call either.

For both methods, treat rows as a snapshot from the moment you fetched it – it won’t reflect a change made a moment later – and pass it straight into prime_from_rows()/to_list_from_rows() rather than inspecting it yourself; its internal shape isn’t part of the public API and may change in a future release. Neither changes the cost of position-based access covered above (lst[i], pop(), remove()) – those still check the list’s current state directly, on purpose, so they can’t return or destroy the wrong item using data that might already be out of date.

There is deliberately no names-only list_prefix(). A keys-only projection saves no read capacity on DynamoDB – it still pays for the whole item – so it would break the list_all()/list_all_with_rows() pairing for nothing.

REST API

List properties integrate with the standard /properties endpoints:

Create an empty list:

POST /{actor_id}/properties
Content-Type: application/json
{"notes": {"_type": "list"}}
# Returns 201: {"notes": "[Empty list property created]"}

# The list must exist before the /items endpoint below will accept
# anything for it -- POST /items on an unknown list is a 404.

GET all items:

GET /{actor_id}/properties/{list_name}
# Returns: [item1, item2, ...]

GET all properties (default / format=short):

GET /{actor_id}/properties
# Returns: {"name": "Alice", "notes": {"_list": true, "count": 2}}

GET all properties with full list data (format=full):

GET /{actor_id}/properties?format=full
# Returns: {"name": "Alice", "notes": {"_list": true, "count": 2, "description": "...", "items": [...]}}

GET metadata only (metadata=true):

GET /{actor_id}/properties?metadata=true
# Returns: {"simple": {"properties": [...], "total_bytes": N}, "lists": {...}}

GET/POST items (an implementation extension, not part of the ActingWeb spec – the spec addresses items by path index, /properties/{list_name}/{index}):

GET /{actor_id}/properties/{list_name}/items
# Returns: {"items": [{"index": 0, "item": item0}, {"index": 1, "item": item1}, ...], "count": N}
# "index" is the STORAGE index -- the same index accepted by the
# update/delete actions below, so the two are always consistent.

POST /{actor_id}/properties/{list_name}/items
Content-Type: application/json
{"action": "add", "item_value": {...}}          # append to end
{"action": "update", "item_index": N, "item_value": {...}}
{"action": "delete", "item_index": N}
# "update"/"delete" on a row that changed since the request read it:
# 503 with Retry-After, same as PUT ?index=N below -- as of 3.14 these
# are conditional writes, not unconditional overwrites.

Bulk update items (an implementation extension, like /items above):

POST /{actor_id}/properties
Content-Type: application/json
{"{list_name}": {"items": [
  {"index": 0, ...item data...},   # update -- any keys besides "index"
  {"index": 3},                    # delete -- ONLY the "index" key
  {"index": 5, ...item data...}    # append -- index == current length
]}}
# Every "index" is interpreted against the list as it stood BEFORE the
# batch. Updates apply first, in the given order; deletes apply last,
# in descending index order.

As of 3.14, a batch that concurrently races another writer no longer silently overwrites or misapplies what it finds – each item is reported individually rather than failing the whole request: the response summary counts only the items that actually applied, and a skipped item is logged, not raised. Two consequences worth knowing before relying on batch semantics:

  • A {"index": N, ...} update whose row changed since the batch’s own read is not applied and is not counted – the other items in the batch still are. Retry the batch (or just that item) to see the current content and decide again.

  • Same-index update + delete, in one batch, is now well-defined: for an index that existed before the batch, the update applies and the delete is reported as skipped (“concurrently modified”) rather than deleting the row the update just wrote; for an index the batch itself creates (index >= the pre-batch length), the pair is a net no-op – nothing is appended and nothing is reported skipped. Before 3.14 this raced against whatever the positional delete pass found by then, most often deleting the updated row – if your integration relied on that, address the same index only once per batch.

  • Duplicate indices in one batch resolve to one write, later entry wins: two updates addressing the same index – pre-existing or batch-created – store the second value in a single row (the response still counts each request entry). Two deletes at the same pre-existing index remove one row, with the second reported as skipped – unlike pre-3.14, where position shift between the two deletes could take a neighbouring row with it.

PUT item at index:

PUT /{actor_id}/properties/{list_name}?index=0
Content-Type: application/json
{...item data...}

# index == current list length: creates (appends) the item.
# index > current list length: 404 Not Found (no padding is created).
# index within range but the row changed since this request read it:
# 503 with Retry-After (same signal a contended list metadata write
# already used) -- as of 3.14 this replace is a conditional write, not
# an unconditional overwrite of whatever it finds.

DELETE entire list:

DELETE /{actor_id}/properties/{list_name}

GET/PUT metadata:

GET /{actor_id}/properties/{list_name}/metadata
PUT /{actor_id}/properties/{list_name}/metadata
Content-Type: application/json
{"description": "...", "explanation": "..."}

Corrupted list (409 Conflict)

Every list-serving path above (GET on the list, /items, format=full and metadata=true on the properties root) returns structured 409 if it finds an item missing from storage within the list’s recorded length – the residue an interrupted delete/insert can leave. This can only happen on a v1 (dense-integer) list – see Storage Format (v1 / v2) above; a v2 list has no separate stored length for a row to disagree with, so this failure mode is structurally impossible there:

{"error": "list_corrupted", "list": "notes", "detail": "...", "remedy": "compact"}

Note

verify() has two duplicate checks and each is blind to what the other catches. adjacent_duplicates compares raw stored bytes of neighbouring rows – exactly the residue an interrupted shift leaves, but it stops finding a duplicate once either copy is edited. Pass identity_key="id" (or whatever field identifies your items) to also get duplicate_identities, which compares that field across the whole list: it survives later edits, and it does not assume the copies stayed neighbours. Duplicates from a different mechanism – a failed read turning an upsert into an append, say – are under no obligation to be adjacent. Both sweep tools take --identity-key. Check identity_checked_count in the report before trusting an empty result: rows without the field are excluded from the comparison, so a mistyped key produces a report shaped exactly like a clean one having compared nothing.

The tools ship with the library as actingweb-verify-property-lists and actingweb-migrate-property-lists, so they are available from an installed wheel; scripts/ keeps thin wrappers for repo checkouts.

There is no HTTP repair endpoint. Repair through the library API – actor.property_lists.notes.verify() to inspect, .compact() to fix – or the operator sweep script, scripts/verify_property_lists.py. verify()/compact() also work on v2 lists, for a different purpose: detecting and rebalancing rank keys that have grown long from repeated inserts at the same position, before they approach the internal length cap.

Warning

compact() is not crash-safe end to end, in either storage format. It writes every item to its new location before retiring any old row, so an interruption partway leaves a copy at both. Re-running compact() does not undo that – it treats every copy as a genuine item, and under v1 it explicitly declines to touch duplicate residue at all. Prefer running it when the actor is not taking writes, and re-run verify() afterwards rather than assuming success.

v1 (dense integers): interrupting the rewrite of a 4-slot list with one hole leaves [a, c, c, d] or [a, c, d, d] with the length still reading 4 – readable with no error, because nothing is structurally inconsistent. verify() catches it through the adjacent byte-identical heuristic, but repair will not remove it: duplicates are preserved by design, including the one repair itself created. Resolving it is manual.

v2 (rank keys): the same window, as a rank rebalance. The deliberate trade here is that the alternative – retiring each old row as its replacement is written – would leave interrupted states silently reordered instead, which nothing detects at all. Recovery is manual; the stale copies are the ones whose rank keys are not part of the evenly spaced sequence a fresh rebalance produces.

Concurrency during a whole-list rewrite

compact(), migrate_to_v2() and the operator sweep scripts rewrite a whole list. None of them takes a lock, and nothing excludes an application write while one runs. “Run repair when the actor is not taking writes” is therefore a requirement, not a suggestion: a concurrent write during a rewrite can be lost.

Two things bound how bad that gets.

A concurrent write can no longer revert the storage format from a stale cache. Every metadata write names the fields it is changing and merges them into a freshly read metadata row, so an append() running against a list that was migrated a moment ago updates a timestamp and nothing else. In 3.13.0rc6 and earlier it wrote a whole cached dictionary back, restoring format: 1 over a completed migration and leaving metadata claiming v1 while every item lived in v2 rows nothing read.

Be precise about what that buys, because the difference matters operationally. The old window was unbounded in time: a ListProperty instance an application held on to kept its cached metadata until something explicitly invalidated it, so a write minutes or hours after a migration could still revert it. As of 3.14, that window is closed rather than merely narrowed: every metadata write conditions on the exact bytes it read, via a bounded compare-and-swap retry loop built on a per-row compare-and-set primitive (added in 3.14 for exactly this). A migration that completes in the gap between another writer’s read and its write no longer gets overwritten — the write’s condition fails, the loop re-reads the row the migration just produced, and merges onto that instead. Sustained contention (the row keeps changing across every retry) raises ListMetadataContentionError rather than corrupting the row or retrying forever; PropertiesHandler, PropertyMetadataHandler and PropertyListItemsHandler all map it to 503 with a ``Retry-After`` header, since a contended row is a retryable condition, not a server fault. Dispatch (which storage format a mutation writes into) is decided from the same fresh read, closing the companion gap where a retained instance wrote into the format a migrated list no longer has — see verify()’s foreign_format_rows field.

This closes the metadata read-modify-write window specifically. It does not make compact()/migrate_to_v2()’s bulk item-row copying crash-atomic — an interruption mid-rewrite can still leave duplicate or reordered item rows, tracked separately in thoughts/todo/whole-list-rewrite-atomicity.md. Quiescing writes during a migration remains the operational recommendation for that part.

Two concurrent migrations resolve to last-writer-wins at whole-list granularity, and this is accepted rather than prevented. Each migration clears the v2 range before writing, so the loser’s snapshot never mixes with the winner’s — you get one coherent list, not an interleaving. What is not guaranteed is that the winner is the newer snapshot: a migration that read the list first can finish last, and items added in between are then absent from the migrated list. Mutual exclusion would close this, and was designed and rejected for 3.13 (the designs are recorded in thoughts/plans/2026-08-15-property-list-metadata-integrity.md). Run one migration at a time, against a quiesced actor, and the question does not arise.

Interrupted format changes clean themselves up on the next run: leftover rows from the format the list is no longer in are swept when the migration or downgrade is re-run, and by clear() and delete(). verify() reports them as foreign_format_rows — informational only, because they are inert to every reader of the current format.

See the ActingWeb specification and Properties handler documentation for complete API details.

Web UI

The UI detects list properties and provides dedicated list pages with item and metadata editing. See WWW Handler and Templates for template customization.