Skip to content

Migrating to 0.17

From 0.16 to 0.17

Protean 0.17 makes the Unit of Work a real database transaction, begins consolidating the 1.0 public API surface, and retires the built-in email subsystem. The transaction change is the one to read before upgrading: it changes behaviour in code you did not touch. Renamed public options are drop-in: the deprecated spelling keeps working (behind a warning) through the transition window. One framework-internal option that was never part of the public API is removed outright, with no transition window. The email subsystem keeps working too, now behind a deprecation warning, and is scheduled for removal in v1.0.0.

On this page:


The Unit of Work is a real transaction

Protean 0.17 maps a Unit of Work onto one real database transaction on the PostgreSQL and MSSQL adapters, replacing a model where the engine ran in AUTOCOMMIT and the UoW buffered writes in memory until commit. See ADR-0027 for the measurements behind the decision.

This section is longer than the others on purpose. The change is a correctness fix, and most codebases get quietly better. But two shapes that were harmless before are not any more, and neither of them appears in a diff of your code, because your code does not change. You have to go looking.

What you are gaining

Worth knowing first, because it reframes the work below as paying down a debt rather than absorbing a regression. Under the old model, on PostgreSQL and MSSQL:

  • A rollback could leave a corrupted aggregate. Saving an aggregate with child entities forced a mid-UoW flush. Under AUTOCOMMIT that flush committed the parent row durably while the children were still pending, so a later rollback left an orphaned parent with none of its children.
  • The transactional outbox was not atomic. A UoW writing to two tables where the second write failed left the first durably committed. A domain write and its outbox message could split, which is the exact failure the outbox pattern exists to prevent.
  • Reads inside a UoW did not see the UoW's own writes. A filter, count or exists never flushed pending changes, so it returned stale results and in-UoW uniqueness checks read committed state.

All three are fixed by the same change. The Memory and SQLite adapters already behaved this way, so this brings PostgreSQL and MSSQL into line with them.

Change 1: a nested Unit of Work joins the outer transaction

A UnitOfWork opened while another is already active on the same context no longer runs independently. It joins the outermost transaction, and there are no savepoints, so a rollback anywhere inside dooms the whole transaction.

with UnitOfWork():
    repository_for(Order).add(order)

    try:
        with UnitOfWork():          # joins the outer transaction
            repository_for(Audit).add(entry)
            raise RuntimeError("audit failed")
    except RuntimeError:
        pass                        # the exception is handled...

    # ...but on 0.17 the outer transaction is already doomed, and the
    # order is not saved either. On 0.16 the order was saved.

The shape to look for is a nested block whose failure you deliberately swallow so the outer work survives. That no longer works.

Nesting is otherwise safe, and in one way safer than before: previously a nested UoW shared the outer session, so an inner commit durably persisted the outer's pending writes. Only the outermost UoW commits now.

This is most likely to bite you indirectly, where a handler opens a UoW and calls a service method that opens its own. Neither function looks nested on its own.

Change 2: external I/O inside a Unit of Work now holds locks

A real transaction holds its row locks and its pooled connection for as long as the block runs. Under AUTOCOMMIT no transaction was open between flushes, so an HTTP call inside a UoW cost only wall-clock time. Now it holds database locks for the duration of that call, and a pooled connection with it.

# Costly on 0.17: locks and a connection are held for the whole HTTP call.
with UnitOfWork():
    order = repository_for(Order).get(order_id)
    order.confirm()
    repository_for(Order).add(order)
    httpx.post("https://partner.example/webhook", json=payload)   # <-- move this

# Better: commit first, then talk to the outside world.
with UnitOfWork():
    order = repository_for(Order).get(order_id)
    order.confirm()
    repository_for(Order).add(order)

httpx.post("https://partner.example/webhook", json=payload)

Best of all, raise a domain event and let the outbox publish it after the transaction commits, which also gets you delivery retries.

Both fixes above move the call after a with UnitOfWork(): block. Inside a @handle method there is no "after the commit": @handle owns the Unit of Work and the method returns before it commits, so you cannot append the call at the end. Split the method instead. A method that only calls out reaches no repository and runs no transaction, and when the call must follow the write, the persisting method raises an event that a second handler acts on. See Calling External Systems from Handlers.

The shapes to look for are an HTTP client call, a broker publish, an email send, and anything else that waits on a system you do not control.

How to find both in your codebase

Do not do this by hand. protean upgrade-check reports both:

protean upgrade-check --domain=my_domain

It reports NESTED_UNIT_OF_WORK for blocks opened inside another, IO_INSIDE_UNIT_OF_WORK for external calls inside one, and, when it finds no lexical nesting, a UNIT_OF_WORK_NESTING_REVIEW note listing how many blocks are worth walking for the indirect case. The rules read your source and change nothing.

They are advisory and deliberately incomplete in one direction: nesting through a call cannot be seen statically, so a clean report is not proof. Walk the call graph under your UoW blocks for anything that opens its own.

Two things to size before you upgrade

  • Connection pool. Each concurrent UoW holds a pooled connection for its whole transaction. Size pool_size to peak UoW concurrency under a threaded host. The async engine runs on one event-loop thread and is not exposed to this.
  • MSSQL locking. Read-committed on MSSQL uses row locks rather than MVCC, so a reader can block on a writer holding a row inside a UoW where PostgreSQL would not. Enable READ_COMMITTED_SNAPSHOT if that blocking shows up.

Finally, prefer with UnitOfWork(): over a manual uow.start(). Without a try/finally, a manual start leaks the transaction, its connection and its locks if an exception is raised before commit or rollback.


event_sourced replaces is_event_sourced

Tier 1. Boolean element options are now bare predicates. The aggregate option that enables event sourcing is event_sourced; the old is_event_sourced spelling is a deprecated alias.

@domain.aggregate(event_sourced=True)   # was: is_event_sourced=True
class Account:
    ...

The alias still works but emits a RemovedInProtean10Warning and is reported as a DEPRECATED_OPTION diagnostic by protean check. It will be removed in v1.0.0. If both spellings are supplied, event_sourced wins. The internal meta_.is_event_sourced attribute is unchanged.


is_fact_event is now framework-internal

Internal option removed (no deprecation window). is_fact_event was never a public option user code should set: the framework assigns it when it generates fact events for an aggregate marked fact_events=True. Because it was never part of the public API, it is removed outright rather than deprecated: passing is_fact_event= to an event or command (via the decorator or domain.register) now raises ConfigurationError immediately.

Nothing changes for reads: meta_.is_fact_event is still present on every event and command (defaulting to False), and fact events still carry meta_.is_fact_event is True. If you were setting the option by hand, drop it and let fact_events=True on the aggregate drive generation.


Email subsystem deprecation

Tier 1. The built-in email subsystem is deprecated and will be removed in v1.0.0. Sending email is an infrastructure concern that belongs in an application-level notification service, not in the domain framework. Every email entry point still works but now emits a RemovedInProtean10Warning:

  • registering an email element, via either @domain.email or domain.register(SomeEmail);
  • domain.send_email(...) and domain.get_email_provider(...);
  • configuring a non-default [email_providers] block in domain.toml.

Deprecation warnings are silent by default. Surface them by running Python with -W default::DeprecationWarning, and run protean check to list every registered email element as a DEPRECATED_EMAIL info diagnostic, and a non-default [email_providers] block as a DEPRECATED_CONFIG info diagnostic (neither fails the check).

What to do

Move notification out of the domain. Handle the domain event (or subscribe to the stream) and call your own notification service, which owns the email transport:

@domain.event_handler(part_of=Person)
class SendWelcomeNotification:
    @handle(PersonAdded)
    def on_person_added(self, event: PersonAdded) -> None:
        # `notifications` is your application-level service wrapping whatever
        # email/SMS/push transport you use (SendGrid, SES, a queue, ...).
        notifications.send_welcome(to=event.email, name=event.first_name)

This keeps the domain focused on what happened (PersonAdded) and leaves the delivery mechanism to infrastructure you control, so it can evolve independently of the framework.


identity_type = "uuid" yields a string

Behavior change. When identity_type = "uuid", an auto-generated identity is now a UUID string in Python, not a native uuid.UUID object (ADR-0021). Previously the auto-injected id field held a native UUID, which is not JSON-serializable and broke to_dict(), event and command payloads, and API responses; an explicitly declared Auto(identity_type="uuid") field already produced a string, so the two disagreed. They now agree on str.

Nothing changes for storage: adapters that support a native UUID column still use one (the SQLAlchemy adapter maps the uuid type to its GUID column). Integer identities are unaffected.

If you have code that treated a uuid id as a native uuid.UUID (calling .hex or .int, or passing it where a UUID is required), wrap it explicitly: uuid.UUID(entity.id). Comparisons and lookups by id keep working, since a repository get(id) accepts the string form.


Data that used to fit may now be rejected

Length bounds on String and Text are checked against the sanitized value, not the raw one. sanitize=True and max_length=255 are the defaults on every String, and sanitizing escapes &, <, >, " and ' into HTML entities, each of which is longer than the character it replaces.

So a value that fit in 0.16 can be rejected in 0.17 without your code or your data changing:

class Note:
    body = String(max_length=10)

Note(body="a & b & c")   # 9 characters
# ValidationError: String has 17 characters after sanitization,
#                  exceeding max_length of 10

& becomes &amp;, so nine characters became seventeen.

Who this hits. Any field holding text a human typed: names with &, addresses, free-text notes, anything pasted from a rich-text editor. The narrower the max_length, the more likely it is.

What to do. Either raise max_length to leave room for escaping, or set sanitize=False on fields whose content is never rendered as HTML:

body = String(max_length=64, sanitize=False)

Reserve roughly 5x for text likely to be entity-heavy. See ADR-0026 for why the check moved after sanitization rather than before.


Streams are trimmed by default, and trimming can drop unread messages

Every named subscription profile now sets retention_maxlen:

Profile retention_maxlen
production 100,000
fast 100,000
batch 500,000
debug 1,000
projection none
no profile none

If you name a profile, your streams are now capped. In 0.16 they grew until something else trimmed them.

The part to read carefully. What trimming does depends on how many consumer groups read the stream:

  • Two or more groups: the stream is trimmed to the slowest group's read position and retention_maxlen is ignored. Nothing unread is lost.
  • One group, or none: the stream is capped at retention_maxlen, and this is not progress-safe. If the single reader falls further behind than the cap, the oldest unread entries are deleted. That happens during an initial catch-up over a pre-existing stream, after an outage, or whenever the producer outruns the handler.

debug's cap of 1,000 is the sharp edge: it is meant for watching one message at a time in development, and it will discard a backlog.

What to do. If you name a profile on a single-consumer stream, size retention_maxlen above the largest backlog you expect rather than the steady-state length, or set it to none and trim out of band. See Tuning subscriptions.


A failing handler now pauses itself

Stream subscriptions have a circuit breaker on by default: 10 consecutive handler failures pause reads for 60 seconds, then a single probe message decides whether to resume.

Nothing is lost. Messages stay in the stream and the pending list and are redelivered. But a subscription that used to grind through a persistent failure, sending everything to the DLQ, now stops instead, and that looks like a stall if you are not expecting it.

An open breaker does not make the engine unready: /readyz still returns 200, because the pod is healthy and its other subscriptions are still working.

Tune or disable it per handler:

[server.subscriptions.PaymentHandler]
circuit_breaker_threshold = 50        # more tolerant
circuit_breaker_reset_seconds = 10

/readyz reports an object where it reported a number

The engine's readiness probe changed shape:

- "subscriptions": 12
+ "subscriptions": {"total": 12, "details": [...]}

Anything parsing checks.subscriptions as a number breaks: a dashboard panel, an alert expression, a script doing checks.subscriptions > 0.

The block now carries per-subscription lag, pending count, DLQ depth, status and circuit-breaker state. It is informational and never changes the probe's verdict. The FastAPI health router is unchanged and still has no subscriptions key at all, so a script reading both entry points must treat it as optional. Full shape in Server hardening.


update() now goes through the same path as save()

repository.update() and QuerySet.update() used to write fields directly. They now run the full persistence path, which means three changes at once:

  • auto_now timestamps are stamped, so updated_at moves on an update that previously left it alone.
  • Pre-persist enrichers run.
  • The optimistic-concurrency version advances, so a concurrent update that silently won before now raises ExpectedVersionError.

And update() on an entity that was never persisted now raises ObjectNotFoundError instead of quietly doing nothing.

This is the correct behaviour and matches save(), but it will surface in tests that asserted a frozen updated_at, and in code that relied on last-write-wins.


Config that was silently ignored now stops start-up

Behavioural break. Three config guards used to test a value's truthiness before its type, so a falsy wrong value was read as "not set" and skipped in silence while a truthy one raised. Turning a feature off is the likelier thing to write than turning it on, so the shape most people would use was the shape nobody heard about.

They now check the type first. A value of the wrong shape is rejected whether it is falsy or not, which means config that started fine on 0.16 can stop start-up on 0.17.

Who is affected

Anyone whose domain.toml has one of these:

[server]
priority_lanes = false      # a scalar, not a table
profiles = false            # a scalar, not a table

[caches.default]
TTL = 0                     # or a negative, or "nan", or "inf"

None of these did what they look like they do. priority_lanes = false did not disable priority lanes (they were already off by default), it was simply discarded. TTL = 0 reached the memory cache as an immediate expiry and the Redis cache as an invalid one.

What to do

Write the tables as tables, and give the cache a real TTL:

[server.priority_lanes]
enabled = false             # or delete the section entirely

[caches.default]
TTL = 3600                  # a positive, finite number of seconds

The error names the key and shows the correct form, so a domain that fails to start tells you which line to change.

Also newly rejected

cache.add(projection, ttl=0) used to fall through to the cache default, so a caller asking for immediate expiry silently got 300 seconds. It now raises. Pass a positive number of seconds, or omit the argument to use the cache's own TTL.


Errors you may start seeing

Several fixes in 0.17 converted silently wrong behaviour into a raised exception. Each is right on its own; together they mean an upgrade can turn a quiet bug into a loud failure in code you did not touch. If one of these appears after upgrading, it is usually reporting a real problem that was there before.

Exception Now raised when Why
ExpectedVersionError A concurrent update loses the race, including from a filter/count read inside a Unit of Work Optimistic concurrency actually conflicts now: memory compare-and-set, SQLAlchemy version_id_col, and a child-entity edit bumping the root version
ObjectNotFoundError update() on an entity that was never persisted It used to do nothing
ValidationError Text exceeds max_length after sanitization; a duplicate violates Index(unique=True) on the in-memory repo Bounds are checked post-sanitization; the memory adapter now enforces unique indexes
ConfigurationError A [databases.*] section names an unknown provider; __version__ = True on an element; is_fact_event passed as an option Misconfiguration that used to be ignored is now refused at startup

The read-inside-a-UoW case deserves a note: with a real transaction, a read flushes pending writes first, so a version-guarded UPDATE can lose its race at the read rather than at the commit. It is translated to ExpectedVersionError so your existing version-retry path catches it.


protean check reports more, so CI may go red

The rule catalogue roughly doubled. New warning-level rules include CIRCULAR_CLUSTER_DEPENDENCY, COMMAND_HANDLER_CROSS_CLUSTER, CROSS_AGGREGATE_REFERENCE, ES_AGGREGATE_NO_EVENTS, EVENT_HANDLER_FOREIGN_EVENT, INFRA_IMPORT_IN_DOMAIN, PROJECTOR_HANDLES_ORPHANED_EVENT, QUERY_HANDLER_WITHOUT_QUERY, UNBOUNDED_INDEXED_STRING, UNINDEXED_FILTER_PATH, UPCASTER_GAP, VALUE_OBJECT_MUTABLE_FIELD and DEPRECATED_OPTION.

protean check exits 2 on warnings, unchanged from 0.16, so a domain that passed can now fail CI without any change to your code.

What to do. Fix them, they are real findings. To adopt gradually, either raise the gate so only errors fail:

[lint]
level = "error"

or suppress specific findings while you work through them, with [lint].suppressions. See Architecture fitness functions.


cache.get_ttl() returns seconds on Redis, not milliseconds

Behavioural break, Redis cache only. RedisCache.get_ttl() returned Redis' PTTL unchanged, which is milliseconds, while MemoryCache.get_ttl() returned seconds. The same method meant different things depending on the adapter, and every other TTL on the port is seconds: the TTL config key, add(ttl=...), set_ttl(). Redis now returns seconds too.

Who is affected

Anyone calling get_ttl() on a Redis-backed cache. The value is now 1000x smaller. Code that divided by 1000 to get seconds is now dividing seconds by 1000:

# Before: correct on Redis, wrong on the memory cache
seconds_left = cache.get_ttl(key) / 1000

# After: correct on both
seconds_left = cache.get_ttl(key)

Redis' two sentinel answers are unchanged: -1 (the key exists with no expiry) and -2 (no such key) are flags rather than durations, so they are returned as they are rather than scaled.

Nothing inside Protean calls get_ttl(), so this only affects your own code.

Why it was not deprecated first

There is no shape that could carry both meanings through a deprecation window: the return is a bare number, so an old caller and a new caller want different values from the same call. Correcting it outright, in a release that already documents the unit, was judged clearer than shipping two spellings.


Application services accept constructor arguments

Fixed, not a break. @domain.application_service appeared to forbid constructor injection: any service defining __init__ failed with

TypeError: object.__new__() takes exactly one argument (the type to instantiate)

That was a bug, not a rule. __new__ forwarded its arguments to object.__new__, which takes only the class. Services can now take constructor arguments, which is what makes them testable with fakes:

@domain.application_service(part_of=Order)
class PlaceOrder:
    def __init__(self, gateway):
        self.gateway = gateway

    @use_case
    def run(self, order_id): ...

# in a unit test
service = PlaceOrder(gateway=FakeGateway())

Nothing that worked before stops working; this only accepts what used to raise.

A @use_case method still needs an active domain context, because it opens a Unit of Work. That is by design, but the error said AttributeError: 'NoneType' object has no attribute 'providers' from inside the transaction machinery. It now names the use case and tells you to wrap the call in with domain.domain_context(): or use the test_domain fixture.


A failing handler method no longer stops its siblings

Behavioural break, event handlers and projectors (0.17.1). An event handler or projector can register several @handle methods for the same event. Each one already ran in its own Unit of Work, so they were transactionally independent. They were not independent in failure: the first exception propagated and the remaining methods never ran. Because the registry is a set, which methods got skipped was unspecified: it can shift with any change to the code.

Every method now gets its turn, apart from two exceptions named below, and the failures are collected and raised once dispatch finishes.

Who is affected

Anyone with more than one @handle method for a single event, where one of them can fail. Methods that used to be skipped now run, so their side effects now happen in a failure that previously suppressed them.

@domain.event_handler(part_of=Order)
class OrderReactions:
    @handle(OrderPlaced)
    def reserve_stock(self, event): ...      # may raise

    @handle(OrderPlaced)
    def notify_partner(self, event): ...     # before: might never run
                                             # after: no longer skipped

If two methods were coupled so that the second must not run when the first fails, they were relying on an ordering the framework never guaranteed. Put them in one method, where ordering is ordinary code, or chain them: have the first raise an event that the second handles.

What surfaces to the caller

One failure propagates unchanged, so a handle_error override that matches on exception type keeps working. Two or more are raised together as an ExceptionGroup, so no failure is lost to whichever one happened to be last. Anything matching on exception type has to unwrap exc.exceptions; see Classify async processing errors.

Under event_processing = "sync" the dispatch runs inside the Unit of Work's commit, which classifies by exception type. A group is not one of the types it knows, so application code at repository.add() or at the end of a with UnitOfWork(): block sees a TransactionError with the group as its __cause__. Neither except SomeError: nor except* SomeError: matches; walk __cause__.exceptions instead.

That reclassification has a sharp edge worth knowing. One failing sibling raising ValueError still reaches the caller as ExpectedVersionError, because the commit translates a bare ValueError. Two failing siblings become a TransactionError. Adding a second failing method changes the exception class your application code catches.

Two failures are not collected

An ExpectedVersionError propagates immediately and does stop the siblings that have not run yet. Grouping it would hide it from the Unit of Work's commit classification, which would surface the conflict as a TransactionError and stop the enclosing handler's version retry from firing. The conflicting method's own retry has already run and exhausted by this point.

A BaseException that is not an Exception, such as KeyboardInterrupt, also stops dispatch where it is raised.

On both paths, failures gathered before the one that ended dispatch are attached to it as a note and logged at ERROR, since nothing downstream would otherwise report them.

What this does not cover

This change is per handler method. Two handler classes subscribing to one event were isolated from each other under async processing, where each has its own subscription, and not yet under synchronous dispatch. That gap is closed separately (see Sibling handler classes are independent in failure under synchronous dispatch). Process managers run their own dispatch loop and are unchanged.

Redelivery

Delivery is at-least-once, so a failed message is retried (broker redelivery, or the event-store recovery pass while enable_recovery is on), and the sibling methods that already committed run again.

For a projector, idempotent=True handles that: the marker is keyed per handler method, so each sibling deduplicates on its own and an already-applied method is skipped on the retry. It needs an event carrying a message id and a projection on a provider with a marker store, so a cache-backed projection gets no deduplication (see ADR-0017).

Event handlers have no such option. idempotent is projector-only, so a sibling that already committed runs again on the retry, and its method has to tolerate that.

Why it was not put behind a flag

The skipping was a defect against the model the framework documents, and which siblings got skipped was arbitrary, so no program could depend on it deliberately. A flag would have preserved a behaviour that cannot be reasoned about. See ADR-0031.


Sibling handler classes are independent in failure under synchronous dispatch

Behavioural break, event_processing = "sync" (0.17.1). Two event handler or projector classes can subscribe to the same event. Under async each class has its own subscription, so one class failing never touched the others. Under synchronous dispatch the drain stopped at the first class that raised, and every class queued behind it was discarded. Now every class runs, and the failures surface once the drain finishes. This completes the guarantee ADR-0031 states, which the handler-method change delivered only for methods within one class.

Who is affected

Anyone with more than one handler class subscribing to one event under event_processing = "sync", where one class can fail. Classes that used to be skipped now run, so their side effects now happen in a failure that previously suppressed them.

@domain.event_handler(part_of=Order)
class ReserveStock:
    @handle(OrderPlaced)
    def reserve(self, event): ...        # may raise


@domain.event_handler(part_of=Order)
class NotifyPartner:
    @handle(OrderPlaced)
    def notify(self, event): ...         # before: might never run under sync
                                         # after: no longer skipped

What surfaces to the caller

The drain runs inside the enclosing Unit of Work's commit, which classifies what escapes it by exception type. So the shape a caller sees at repository.add() (or the end of a with UnitOfWork(): block) is not the handler's exception verbatim, and handle_error — an async-engine hook — does not fire on this path at all.

One failing class hits the same sharp edge the handler-method change describes: the commit reclassifies it. A bare ValueError surfaces as ExpectedVersionError, and most other exceptions surface as a TransactionError with the failure as its __cause__ (a few the commit handles specially, such as ConfigurationError, pass through as themselves). Adding a second failing class changes the class your except catches.

Two or more failing classes are raised together as an ExceptionGroup, which the commit wraps as a TransactionError with the group as its __cause__. As in the handler-method case, neither except nor except* on that TransactionError matches the failures inside — walk __cause__.exceptions. One wrinkle specific to classes: a handler class that itself has several failing @handle methods contributes a nested sub-group, so recurse into any member that is itself an ExceptionGroup (or re-raise __cause__ and match it with except*, which does recurse) rather than stopping at the first level.

An ExpectedVersionError and a BaseException that is not an Exception are the two exclusions. Each ends the drain where it is raised and propagates at once, for the same reasons they do among sibling methods: a version conflict has to reach the commit classification as itself so version retry still fires, and an interrupt must not be swallowed. Failures gathered before either one are attached to it as a note and logged at ERROR.

What this does not cover

The isolation is a property of the synchronous drain, so it reaches more than two sibling classes for one event: reactions to different events raised in the same commit, and a process manager taking part as one handler class among others, are collected the same way. What is not touched: a process manager's own dispatch loop still stops at the first of its @handle methods to fail (what that means for the instance's state is a separate decision), and broker subscribers under message_processing = "sync" still stop at the first failing subscriber. Both are tracked separately.


Smaller changes worth knowing

  • The health probe binds 127.0.0.1, not 0.0.0.0. Kubernetes probes from outside the pod will fail until you set [server.health] host = "0.0.0.0". protean upgrade-check reports this as HEALTH_PORT_BIND.
  • Synchronous dispatch is breadth-first. A nested process(cmd, asynchronous=False) returns before its downstream cascade runs, so a read immediately after it may not see the effects yet. See ADR-0016.
  • version= is honoured on explicit BaseEvent/BaseCommand subclasses. The type string was pinned at v1 regardless; it now reflects the declared version, which changes __type__ on both write and read matching. Stored ...v1 messages will not match a class that now reports v3, so add an upcaster or keep the version. __version__ = True now raises.
  • datetime and date payloads use ISO-8601 (isoformat()) in the event store, Elasticsearch and the outbox. Existing records stay readable; external consumers comparing raw timestamp text need updating.
  • Outbox.target_broker is NOT NULL. Existing tables are not altered automatically. Backfill and alter before upgrading; protean upgrade-check reports outbox schema drift.
  • Auto(increment=True) inserts flush early, so a constraint violation surfaces at add() rather than at commit(). Error handling wrapped only around commit() will stop catching it.
  • A ValueObject is now considered present by identity, not truthiness. An all-default value object round-trips instead of reading back as None.
  • $all subscriptions wait at position gaps, up to [server.event_store_subscription] gap_timeout_seconds (default 5), which adds latency but stops events being skipped. See ADR-0025.
  • protean.utils.__all__ is trimmed. from protean.utils import * no longer brings in derive_element_class, generate_identity, fully_qualified_name, convert_str_values_to_list, TypeMatcher or utcnow_func. Direct imports still work, behind a deprecation warning.
  • CLI removals. protean generate and generate docker-compose are gone, as is --debug on server and observatory (deprecated in 0.16; use --log-level DEBUG).
  • Dependency floors raised: elasticsearch>=8.18 with elasticsearch-dsl dropped, plus newer floors for fastapi, typer, uvicorn and OpenTelemetry. import elasticsearch_dsl will fail.
  • Committed IR baselines go stale. The IR gained fields, so protean-check-staleness reports STALE on the first run after upgrading. Regenerate with protean ir show --canonical.
  • New deprecations (warnings only, nothing removed yet): published on commands, the Method and Nested fields, and List(pickled=True).