Skip to content

unbind() silently disables optimistic concurrency on a use_revision model #143

Open

Description

@cdwilson

unbind() validates its arguments against _CONFIG_KEYS, which includes use_revision — but bind() has no use_revision parameter, because the revision_id field must exist in the class namespace before pydantic builds the class. So use_revision can be unbound and never re-bound: the field stays, the protocol stops.

What happens

Model.unbind() with no arguments removes every config key this class set (src/tinydantic/_model.py:1016-1017), use_revision among them. _uses_revision() then resolves False, so:

  • writes stop rotating revision_id (it stays None in every new document),
  • save(), replace(), and delete() stop checking the held token,
  • the revision_id field itself is still there, still serialized, still in model_fields.

The model looks revisioned and behaves unrevisioned. bind() cannot put it back.

This is reachable through the documented teardown idiom: a fixture that binds a database in setup and calls Model.unbind() in teardown takes use_revision with it, so a later test on the same class runs without the concurrency guard.

Reproduction

from tinydb import TinyDB
from tinydb.storages import MemoryStorage

from tinydantic import StaleDocumentError, TinydanticModel

db = TinyDB(storage=MemoryStorage)


class Doc(TinydanticModel, database=db, use_revision=True):
    text: str


first = Doc(text="a").insert()
print("token after insert:", first.revision_id is not None)

Doc.unbind()
Doc.bind(database=db)

second = Doc(text="b").insert()
print("token after unbind + rebind:", second.revision_id is not None)
print("revision_id still a field:", "revision_id" in Doc.model_fields)

stale = Doc.get_by_id(first.id)
Doc.update_by_ids({"text": "changed by someone else"}, [first.id])
try:
    stale.save()
except StaleDocumentError:
    print("stale save: rejected")
else:
    print("stale save: accepted")

Run with python repro.py.

Actual output:

token after insert: True
token after unbind + rebind: False
revision_id still a field: True
stale save: accepted

Expected: either unbind() refuses use_revision with the same explanation bind() carries (it cannot be late-bound, so it cannot be late-unbound either), or bare unbind() skips it. Nothing should be able to leave a model with a revision_id field and no revision protocol.

Environment: tinydantic 0.4.0 at 719ca78, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-13.

What to do

Make _CONFIG_KEYS membership the wrong test for unbind(). Either raise ValueError for an explicit unbind("use_revision") — naming the class-keyword-only rule — and skip it in the bare-unbind() sweep, or drop use_revision from what unbind() accepts entirely.

What was not done

Verified against the current tree; no fix was built or tested. Whether an explicit unbind("use_revision") should raise or be a documented no-op is a design call.

Found while fixing M1 of the 2026-08-13 docs and docstring audit, which flagged bind()'s docstring for claiming every config key can be bound late.

Assisted-by: Claude:claude-opus-5

Activity

  1. added
    bugSomething isn't working
    api-designShape of the public surface
    ai-assistedCreated or investigated with help from an AI agent
    on Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-assistedCreated or investigated with help from an AI agentapi-designShape of the public surfacebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions