Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,81 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.09.07 (2026-09-24)

Three asks a platform raised against `26.09.06` on the day it shipped: logs that could not be
searched by the identifiers the product is built around, a serving process that had to own the
framework's own tables to start, and a cache bridge that made every process a consumer of a
queue it had nothing to do with. Each was found by running a service on the framework, not by
reading it.

### Fixed

- **An identifier in a log message survives redaction, and `PHONE` means a phone number.** The
built-in `PHONE` rule was guarded by digits only — `(?<!\d)` and `(?!\d)` — and a hex letter
or a hyphen satisfies a digit-only guard, so the rule matched happily inside a uuid: about one
uuid in four had a digit run in its middle replaced, and a line read `run
01a0b263-b0b7-71f5-bfb<PHONE>b2aa0`. A run id grepped out of an application therefore never
matched its own log line — silently, and only sometimes, which is worse than always, because
the operator concludes the event never happened. The same rule could not match compact E.164
at all (eleven contiguous digits cannot be split by `\d{3}\d{4}`), so `+34911234567` went to
the log untouched: it gave up searchability *and* privacy at once, and neither is traded away
to fix the other. Identifier shapes — uuid of any version, W3C trace id and span id, ULID —
are now carved out of the message **before** any entity pattern runs, and the entity rules
only ever see the gaps between them, so no rule can consume half of one, on the regex engine
and on the Presidio engine alike. This is the message-text half of the rule `#156` applied to
the structlog correlation *field keys*; the 16- and 32-character hex shapes require at least
one hex letter on purpose, so an all-digit run of that length is still read as a card number
rather than hidden behind the guard. `PHONE` itself is now anchored on word boundaries and
matches compact E.164, separated international, `(212) 555-0143`, `202-555-0143` and
`555-0143`; a national number written as seven bare digits with no separator is deliberately
no longer matched. `pyfly.logging.redaction.preserve-patterns` (a named-regex map, symmetric
with `extra-patterns`) protects a consumer's own id shape, and an invalid entry there is
warned about and ignored exactly as an invalid `extra-patterns` entry is. Forty tests in
`tests/logging/test_identifiers_survive_redaction.py`, including a sweep over three thousand
generated uuids per version — with a quarter of them mangled before, a handful of examples
would have passed by luck.

- **A serving process boots the Postgres event bus with no right to create schema.**
`PostgresEventBus.start()` ran the outbox `CREATE TABLE/INDEX IF NOT EXISTS` in every process
at every boot, including a pure publisher, since `publish()` lazily starts the bus. PostgreSQL
checks `CREATE` on the schema *before* it checks `IF NOT EXISTS`, so an existing table did not
save a role without that right; and `CREATE INDEX IF NOT EXISTS` on a table you do not own
fails even when the index is already there, which a `GRANT CREATE ON SCHEMA` does not fix. The
framework was not asking for one privilege, it was asking every deployment to keep a boot role
that *owned* two framework-internal tables and to make the serving role a member of it —
under a least-privilege posture, not an inconvenience but a violation. `start()` now probes
first with a single `to_regclass` round trip, which needs nothing beyond `USAGE` on the schema,
and issues the DDL only when a table is genuinely missing; a first boot against an empty
database still creates everything, so `pyfly new` needs no configuration. The gate is an
opt-*out*: `pyfly.eda.postgres.auto-create-tables: false` stops the framework issuing DDL under
any circumstance, for a deployment whose schema belongs to its migrations. The consumer group's
cursor row is deliberately outside the skip — it is data, not schema, it needs only `INSERT`,
and a new group joining an existing deployment still has to create its own. Six unit tests in
`tests/eda/test_postgres_outbox_ddl_gate.py` drive `start()` against a fake asyncpg and assert
on the statements it sends; three in `tests/integration/test_eda_postgres_least_privilege.py`
boot the bus against a real server as a role that holds `USAGE`, `SELECT/INSERT/UPDATE` and
nothing else, and fail on the old adapter with `permission denied for schema public`.

- **The CQRS cache-invalidation bridge subscribes one handler per rule, not the wildcard.**
`EdaCacheInvalidationBridge.subscribe()` registered `"*"` the moment a CQRS context had an EDA
bus, whether or not any rule was registered — so a process subscribed to everything in order
to discard it, since an unregistered event type reaches `on_envelope` and does nothing at all.
On the Postgres bus that is not passive: `_drain` refuses to advance the consumer group's
cursor while no handler is registered, precisely so events published before a worker subscribed
are not lost, and a wildcard handler that dispatches nothing defeated that guard — an API
sharing a worker's consumer group drained the worker's queue into a no-op and its jobs stayed
queued. On Kafka and Redis the same subscription cost a partition assignment and a pattern
subscription for no work. The bridge now subscribes an event type when a rule for it is
registered, and nothing while it has no rule, so a process that invalidates nothing is not a
consumer of the bus and the `_drain` guard works as written; registering before or after the
framework attaches the bridge both work. There was also no way to refuse it short of disabling
CQRS entirely — `pyfly.cqrs.cache.invalidation.enabled: false` now does exactly that. Eleven
tests in `tests/cqrs/test_cache_bridge_subscribes_narrowly.py` and the regression that names
the damage in `tests/eda/test_cursor_guard_with_cache_bridge.py`.

---

## v26.09.06 (2026-09-24)

> Prepared during the wave of 2026-09-17 as `26.09.05`; that number was taken meanwhile by the
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.06-brightgreen" alt="Version: 26.09.06"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.07-brightgreen" alt="Version: 26.09.07"></a>
<a href="https://mypy-lang.org/"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#philosophy"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down Expand Up @@ -852,13 +852,13 @@ See **[`samples/lumen/`](samples/lumen/README.md)** for an end-to-end DDD micros

```bash
# Install the latest release (uv)
uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.6-py3-none-any.whl"
uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.7-py3-none-any.whl"

# Install with specific extras
uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.6-py3-none-any.whl"
uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.7-py3-none-any.whl"

# Or with pip
pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.6-py3-none-any.whl"
pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.7-py3-none-any.whl"
```

### One-Line Install (CLI + Framework)
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,7 @@ Missing optional tools are shown with a `-` dash indicator (dimmed), while missi
Verifies that PyFly itself is importable and displays the installed version:

```
✓ pyfly v26.09.06
✓ pyfly v26.09.07
```

### Summary
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/

PyFly v26.09.06 | Python 3.12.0
PyFly v26.09.07 | Python 3.12.0

2026-01-15T10:30:00Z [info] starting_application app=my-service version=0.1.0
2026-01-15T10:30:00Z [info] no_active_profiles message=No active profiles set, falling back to default
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ PyFly Doctor
✓ mypy — Type checker

PyFly packages:
✓ pyfly v26.09.06
✓ pyfly v26.09.07

All checks passed!
```
Expand Down
4 changes: 2 additions & 2 deletions docs/modules/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ class BannerMode(enum.Enum):
| Mode | Behavior |
|---|---|
| `TEXT` | Full ASCII art banner (default) with a framework version line. |
| `MINIMAL` | Single line: `:: PyFly :: (v26.09.06)` |
| `MINIMAL` | Single line: `:: PyFly :: (v26.09.07)` |
| `OFF` | No banner output at all. |

### BannerPrinter Class
Expand Down Expand Up @@ -639,7 +639,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/

:: PyFly Framework :: (v26.09.06)
:: PyFly Framework :: (v26.09.07)
```

### Custom Banner Files
Expand Down
28 changes: 24 additions & 4 deletions docs/modules/cqrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,28 @@ and injected into the `DefaultQueryBus`. No extra configuration is required.
### EDA-driven cache invalidation

When an EDA `EventPublisher` bean is present, `CqrsAutoConfiguration`
also creates an **`EdaCacheInvalidationBridge`** bean and subscribes it to the
bus as a wildcard listener. The bridge evicts `QueryCacheAdapter` entries in
response to domain events arriving on the `pyfly.eda` bus.
also creates an **`EdaCacheInvalidationBridge`** bean and attaches it to the
bus. The bridge evicts `QueryCacheAdapter` entries in response to domain events
arriving on the `pyfly.eda` bus.

It subscribes **one handler per registered rule**, and nothing at all while no
rule is registered — a process that invalidates nothing is not a consumer of the
bus. Registering a rule after startup subscribes it there and then, so the order
of `register()` and the framework's `subscribe()` does not matter.

> **Prior behaviour (corrected in 26.09.07):** the bridge subscribed the
> wildcard `"*"` from every process the moment a CQRS context had an EDA bus,
> whether or not any rule was registered — so a process subscribed to everything
> in order to discard it. On the Postgres bus that is not passive: the adapter
> refuses to advance the consumer group's cursor while no handler is registered,
> precisely so events published before a worker subscribes are not lost, and a
> wildcard handler that dispatches nothing defeated that guard. An API sharing a
> worker's consumer group drained the worker's queue into a no-op and its jobs
> stayed queued. On Kafka and Redis the same subscription cost a partition
> assignment and a pattern subscription for no work.

Set `pyfly.cqrs.cache.invalidation.enabled: false` to refuse the bridge outright
without disabling CQRS.

Register invalidation rules on the bridge after startup (or inject the bean):

Expand Down Expand Up @@ -684,6 +703,7 @@ pyfly:
| `pyfly.cqrs.query.cache_ttl` | `int` | `900` | Default cache TTL (seconds). |
| `pyfly.cqrs.query.metrics_enabled` | `bool` | `true` | Query metrics. |
| `pyfly.cqrs.query.tracing_enabled` | `bool` | `true` | Query tracing. |
| `pyfly.cqrs.cache.invalidation.enabled` | `bool` | `true` | Wire the EDA-driven cache-invalidation bridge. `false` refuses the bean without disabling CQRS. |
| `pyfly.cqrs.authorization.enabled` | `bool` | `true` | Authorization checks. |
| `pyfly.cqrs.authorization.custom.enabled` | `bool` | `true` | Custom authorization. |
| `pyfly.cqrs.authorization.custom.timeout_ms` | `int` | `5000` | Custom auth timeout. |
Expand All @@ -709,7 +729,7 @@ these beans into the DI container:
| `command_event_publisher` | `CommandEventPublisher` | `EdaCommandEventPublisher` when an EDA `EventPublisher` bean is present; `NoOpEventPublisher` otherwise |
| `command_bus` | `DefaultCommandBus` | |
| `query_cache_adapter` | `QueryCacheAdapter` | Injects `CacheAdapter` when available; no-op otherwise |
| `eda_cache_invalidation_bridge` | `EdaCacheInvalidationBridge \| None` | Created and subscribed to the EDA bus when an `EventPublisher` bean is present; `None` otherwise |
| `eda_cache_invalidation_bridge` | `EdaCacheInvalidationBridge \| None` | Created and attached to the EDA bus when an `EventPublisher` bean is present, subscribing one event type per registered rule; `None` otherwise. Gated by `pyfly.cqrs.cache.invalidation.enabled` (default `true`) |
| `query_bus` | `DefaultQueryBus` | |

`cqrs_metrics_service` optionally injects a `MetricsRegistry` bean from the
Expand Down
29 changes: 29 additions & 0 deletions docs/modules/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ property. All keys are optional; the defaults work for local development.
| `pyfly.eda.postgres.dsn` | `str` | *(required)* | PostgreSQL DSN for the producer connection pool. |
| `pyfly.eda.postgres.listen-dsn` | `str` | same as `dsn` | Optional dedicated DSN for the LISTEN connection. |
| `pyfly.eda.postgres.channel` | `str` | `pyfly_eda` | `pg_notify` channel name. |
| `pyfly.eda.postgres.auto-create-tables` | `bool` | `true` | Create `pyfly_eda_outbox` / `pyfly_eda_offsets` when they are missing. The DDL is skipped when they already exist, so a serving process needs no schema-creation right; `false` means the framework never issues DDL at all. |
| `pyfly.eda.rabbitmq.url` | `str` | `amqp://guest:guest@localhost/` | AMQP connection URL. |
| `pyfly.eda.rabbitmq.exchange-name` | `str` | `pyfly` | Name of the durable DIRECT exchange to declare. |

Expand Down Expand Up @@ -295,6 +296,34 @@ The JSON serializer **reads both envelope shapes** of the Firefly family — PyF
empty object) — and raises one typed `EnvelopeDecodeError` for anything else. To *write* the
LaraFly shape (its key order, `DATE_ATOM` timestamps), select `serialization-format: firefly-json`.

### Postgres: what privileges a serving process actually needs

`PostgresEventBus.start()` used to run `CREATE TABLE IF NOT EXISTS` and
`CREATE INDEX IF NOT EXISTS` for the outbox in **every** process at **every** boot — including a
process that only publishes, since `publish()` lazily starts the bus. Postgres checks `CREATE` on
the schema *before* it checks `IF NOT EXISTS`, so an existing table did not save a role without
that right; and `CREATE INDEX IF NOT EXISTS` on a table you do not own fails even when the index
is already there, which a `GRANT CREATE ON SCHEMA` does not fix. The practical consequence was
that a serving role had to own two framework-internal tables, or belong to a role that did.

`start()` now **probes first**: one `to_regclass` round trip, which needs nothing beyond `USAGE`
on the schema, and the DDL runs only when a table is genuinely missing. A first boot against an
empty database still creates everything, so `pyfly new` needs no configuration. Set
`pyfly.eda.postgres.auto-create-tables: false` when migrations own the schema and the framework
must not issue DDL under any circumstance.

A serving process therefore needs only:

```sql
GRANT USAGE ON SCHEMA public TO serving;
GRANT SELECT, INSERT, UPDATE ON pyfly_eda_outbox, pyfly_eda_offsets TO serving;
GRANT USAGE ON SEQUENCE pyfly_eda_outbox_id_seq TO serving;
```

The consumer group's cursor row (`INSERT ... ON CONFLICT DO NOTHING` into `pyfly_eda_offsets`) is
issued on every boot regardless: it is data, not schema, it needs only `INSERT`, and a new
consumer group joining an existing deployment still has to create its own.

---

## Declarative Decorators
Expand Down
39 changes: 39 additions & 0 deletions docs/modules/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ message for sensitive entities before writing to any output.
| `redaction.entities` | list[string] | see below | Entity names to detect and mask |
| `redaction.mask` | string | `placeholder` | Mask style: `placeholder` (`<EMAIL>`), `partial` (`****1111`), or `hash` (`<EMAIL:a1b2c3d4>`) |
| `redaction.extra-patterns` | map[string, string] | `{}` | Additional named regex patterns to redact (e.g. `{"EMP": "EMP-\\d{4}"}`) |
| `redaction.preserve-patterns` | map[string, string] | `{}` | Additional named regex patterns whose matches are **never** redacted, on top of the built-in identifier shapes (e.g. `{"ORDER_REF": "ORD-[0-9]{8}"}`) |
| `redaction.deny-fields` | list[string] | `["password", "token", "secret"]` | Structured log fields whose value is always replaced with `<REDACTED>` |
| `redaction.allow-fields` | list[string] | `[]` | When set, only these structured fields (plus `event`) are scanned; others are skipped |
| `redaction.streams.enabled` | bool | `false` | Opt-in: wrap `sys.stdout`/`sys.stderr` with the redactor |
Expand All @@ -208,6 +209,44 @@ Default entities detected by the regex engine:
ship but are **off by default** — IP addresses are common in logs and redacting
them is often undesirable; enable them via `redaction.entities` if you need them.)

### Identifiers are never mangled

A uuid, a W3C trace id or span id, and a ULID are made of digits and hex letters,
so a generic PII rule can match a substring of one. Before 26.09.07 that is
exactly what happened: roughly one uuid in four had a digit run in its middle
replaced by `<PHONE>`, so a run id grepped out of an application never matched
the log line it appeared on — silently, and only sometimes.

Those shapes are now carved out of the message **before** any entity pattern
runs, and the entity rules only ever see the gaps between them. A pattern
therefore cannot match across a protected identifier, whichever engine is in
use. The built-in protected shapes are:

| Name | Shape |
|------|-------|
| `UUID` | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, any version, either case |
| `TRACE_ID` | 32 hex characters (W3C trace-context), at least one of them a letter |
| `SPAN_ID` | 16 hex characters (W3C trace-context), at least one of them a letter |
| `ULID` | 26 Crockford base32 characters starting `0`-`7` |

The hex shapes require at least one hex **letter** on purpose: an all-digit run
of 16 characters is a card number far more often than it is a span id, and
protecting it would turn this guard into a way to hide PII. Add your own
identifier shapes with `redaction.preserve-patterns`.

This is the message-text half of the rule
`pyfly.logging.redaction.processor._NEVER_REDACT` applies to the structlog
correlation *fields* (`trace_id`, `span_id`, `trace_flags`, `correlationId`).

`PHONE` was tightened in the same release. It used to be guarded by digits only
(`(?<!\d)` / `(?!\d)`), which a hex letter or a hyphen satisfies — the reason it
read uuids as phone numbers — and it could not match compact E.164 at all, so
`+34911234567` went to the log untouched. It is now anchored on word boundaries
and matches compact E.164, separated international, `(212) 555-0143`,
`202-555-0143` and `555-0143`. A national number written as seven bare digits
with no separator is no longer matched; write it with a separator or with its
country code.

### Upgrading to Presidio (NER-based redaction)

Install the optional extra:
Expand Down
Loading
Loading