API Reference
The vocabulary of the loom — set down here exactly as the source declares it.
API Reference¶
Flechtwerk's public surface is small and settled in shape. Every entry below is generated directly from the source docstrings, so it never drifts from the code. The core vocabulary is Extractor, Transformer, Message, State, Event, Config, ConfigStore, the runtime handle Flechtwerk, and the typed-record handles of flechtwerk.attribute — with a few supporting types (Payload, IncomingMessage, MqttBrokerConfig, CompressionType) documented alongside them below.
Stages¶
flechtwerk.Transformer ¶
Bases: Stage, ABC
Event transformer (stateless or stateful).
Three ways to construct one:
-
Declaratively with the
@transformer(...)decorator, which binds a transform function to its input topics — the decorated name becomes the stage::@transformer(input_topics=["my-topic"]) async def stage(msg, state): ...
-
Functionally with
Transformer.of(...), the factory the decorator wraps, when the transform function must stay callable under its own name::stage = Transformer.of( input_topics=["my-topic"], transform=my_transform_fn, )
-
As a subclass for lifecycle management (HTTP clients, dedup instances)::
class MyTransformer(Transformer): input_topics = ["my-topic"]
async def __aenter__(self): self.http = httpx.AsyncClient() return self async def __aexit__(self, *exc_info): await self.http.aclose() async def transform(self, msg, state): ...
The Kafka consumer group ID (driving consumer group membership,
transactional offset commits, and changelog topic naming) is set on
Flechtwerk by the caller; stages don't carry it.
A transformer may additionally declare config_topics (see Stage)
and look their entries up via configs (inherited from Stage, where
its contract is documented) — a config table joined against the
partitioned input stream. A record produced to a config topic is
visible there no later than the next batch.
Source code in src/flechtwerk/transformer.py
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | |
of
classmethod
¶
of(
*,
input_topics: list[str],
transform: TransformFn,
enrich_config: EnrichConfigFn | None = None,
extract_state_key: ExtractStateKeyFn | None = None
) -> Transformer
Build a Transformer from a transform function and input topics.
Use this for stateless or simply-stateful stages that don't need lifecycle management; subclass directly for stages that own resources (HTTP clients, dedup instances, etc.).
Patches the supplied callables in as instance attributes that
shadow the class-level abstract method transform (and, when
provided, the default enrich_config / extract_state_key). The ABC
discipline still applies to every other construction path —
Transformer() and any abstract subclass remain uninstantiable.
Source code in src/flechtwerk/transformer.py
transform
abstractmethod
¶
Transform an incoming message into zero or more output Messages.
Declared without async so that implementations — async def
functions containing yield, i.e. async generator functions whose
call returns an AsyncIterator directly — are compatible overrides
under strict type checking. A coroutine-typed abstract (async def
with no yield) would make every real override incompatible.
Yield a State to signal the desired state. The runner persists it only if it differs from the current state. If no State is yielded, nothing is persisted (stateless behavior). Yielding an empty/falsy State deletes the entry from the state store (and writes a Kafka tombstone to the changelog) atomically with the output messages.
Both parameters are read-only. The runner hands state a defensive
copy and never reuses msg after the call, so mutating either in
place has no effect — it is silently discarded. Produce output only by
yielding, and enrich by spreading (Event({**msg.value, ...})) rather
than mutating in place.
Source code in src/flechtwerk/transformer.py
flechtwerk.transformer.transformer ¶
transformer(
*,
input_topics: list[str],
enrich_config: EnrichConfigFn | None = None,
extract_state_key: ExtractStateKeyFn | None = None
) -> Callable[[TransformFn], Transformer]
Decorator form of Transformer.of — bind a transform function to its input topics.
The decorated async generator becomes the built Transformer, so the name
you define is the stage, ready to hand to Flechtwerk.of::
@transformer(input_topics=["my-input"])
async def stage(msg: IncomingMessage, state: State) -> AsyncIterator[Message | State]:
...
enrich_config and extract_state_key are the same optional overrides as on
Transformer.of — this is exactly that call with transform supplied by
the decoration. Call Transformer.of directly when the transform function
must stay callable under its own name, and subclass Transformer when you
need lifecycle management (__aenter__ / __aexit__).
Source code in src/flechtwerk/transformer.py
flechtwerk.Extractor ¶
Bases: Stage, ABC
Poll-driven data extractor (stateful or stateless).
Three ways to construct one:
-
Declaratively with the
@extractor(...)decorator, which binds a poll function to its config topics — the decorated name becomes the stage::@extractor(config_topics=["my-config"]) async def stage(config, state): ...
-
Functionally with
Extractor.of(...), the factory the decorator wraps, when the poll function must stay callable under its own name::stage = Extractor.of( config_topics=["my-config"], poll=my_poll_fn, )
-
As a subclass for lifecycle management (HTTP clients, MQTT sessions)::
class MyExtractor(Extractor): config_topics = ["my-config"]
async def __aenter__(self): self.http = httpx.AsyncClient() return self async def __aexit__(self, *exc_info): await self.http.aclose() async def poll(self, config, state): ...
Config topics are re-read from the earliest on every startup — never
through a committed-offset consumer group. The runner's membership
consumer does join the application_id group, but purely for
ownership leases: it never commits offsets and every record it fetches
is discarded (see ExtractorRunner). Replicas up to the config topics'
partition count split the configs between them; further replicas are
hot standbys. self.configs (inherited from Stage) always holds
the GLOBAL config store — scale-out only narrows which configs poll
is invoked for. The caller sets the application_id used for
changelog topic naming (and the membership group) on Flechtwerk;
stages don't carry it.
Source code in src/flechtwerk/extractor.py
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | |
wakeup
class-attribute
instance-attribute
¶
Optional wakeup for push-driven sources.
None (the default) keeps the runner on a plain
poll_interval sleep between cycles. A stage whose input
arrives asynchronously (e.g. an MQTT subscription) sets this in
__aenter__ and fires it on arrival; the runner then treats the
interval as an upper bound, polling as soon as the event is set —
the interval degrades to the idle/config-drain cadence.
of
classmethod
¶
of(
*,
config_topics: list[str],
poll: PollFn,
enrich_config: EnrichConfigFn | None = None,
extract_state_key: ExtractStateKeyFn | None = None
) -> Extractor
Build an Extractor from a poll function and config topics.
enrich_config and extract_state_key are optional overrides; omit them
to use the defaults (no enrichment, extract_state_key returns the
Kafka message key).
Patches the supplied callables in as instance attributes that
shadow the class-level abstract method poll (and, when
provided, the default enrich_config / extract_state_key methods). The
ABC discipline still applies to every other construction path —
Extractor() and any abstract subclass remain uninstantiable.
Source code in src/flechtwerk/extractor.py
on_active_configs
async
¶
Reconciliation hook: the configs this instance actively polls.
The runner is the SOLE caller — implementations override this, never invoke it. The contract, guaranteed at every call:
configsis the complete active set, keyed by wire key: every config this instance currently owns that is not suspended. Tombstoned, suspended, disowned (rebalanced-away), and rewritten configs simply disappear from the mapping — one idempotent reconciliation covers every removal shape.- No poll is in flight, and every page a completed poll produced
has committed (the re-entry contract) — the hook may dispose
per-config resources without racing
poll(). - It fires eventually after any change to the active set, and may fire when nothing changed — implementations must be idempotent.
- It is NOT called on shutdown: end-of-life cleanup belongs in
__aexit__. (The MQTT template deliberately keeps its persistent broker session across restarts.)
The mapping and its configs are the runner's cache — treat them as read-only. The default does nothing; the MQTT template overrides this to unsubscribe every topic no active config declares.
Source code in src/flechtwerk/extractor.py
poll
abstractmethod
¶
Poll an external API and yield Messages.
Declared without async so that implementations — async def
functions containing yield, i.e. async generator functions whose
call returns an AsyncIterator directly — are compatible overrides
under strict type checking. A coroutine-typed abstract (async def
with no yield) would make every real override incompatible.
Yield a State to end a page: every State yield is a COMMIT
BOUNDARY — the messages yielded since the previous boundary and the
state change (persisted only if it differs from the last committed
value) commit in one Kafka transaction. Order matters: yield a
page's messages FIRST and the State that accounts for them LAST —
the State yield is what closes the page. Inverting that splits them
across two transactions, cursor before messages, and a crash
between the two commits loses the page for good: the re-poll
resumes from a cursor that already skipped past it. Yield your
resume cursor once per page of source data, and at least once every
10 minutes during long extractions (the transaction timeout).
Messages yielded after the last boundary — or by a poll that never
yields State at all — are NOT lost: they commit as one trailing
page when the generator completes, only without a cursor, so the
next poll re-enters with the prior state. Yielding an empty/falsy
State deletes the entry from the state store (and writes a Kafka
tombstone to the changelog). On crash, the last-COMMITTED page is
retained — its messages are already durable, and the uncommitted
page's messages were aborted, so re-polling from the committed
cursor duplicates nothing.
Both parameters are read-only. The runner hands each poll a private copy
of config and state, so mutating either in place has no effect —
it is silently discarded. Emit records by yielding a Message and
persist your resume cursor by yielding a State.
Source code in src/flechtwerk/extractor.py
flechtwerk.extractor.extractor ¶
extractor(
*,
config_topics: list[str],
enrich_config: EnrichConfigFn | None = None,
extract_state_key: ExtractStateKeyFn | None = None
) -> Callable[[PollFn], Extractor]
Decorator form of Extractor.of — bind a poll function to its config topics.
The decorated async generator becomes the built Extractor, so the name you
define is the stage, ready to hand to Flechtwerk.of::
@extractor(config_topics=["my-config"])
async def stage(config: Config, state: State) -> AsyncIterator[Message | State]:
...
enrich_config and extract_state_key are the same optional overrides as on
Extractor.of — this is exactly that call with poll supplied by the
decoration. Call Extractor.of directly when the poll function must stay
callable under its own name, and subclass Extractor when you need
lifecycle management (__aenter__ / __aexit__).
Source code in src/flechtwerk/extractor.py
Records & Messages¶
flechtwerk.Event ¶
flechtwerk.State ¶
flechtwerk.Config ¶
flechtwerk.Message
dataclass
¶
A message to be written to Kafka.
key and value each accept any Payload — see its docs for the
encoding rules and for how to express other shapes. Construction
validates both fields so a mistake fails at the yield site, not inside
the runner's transactional send path.
Source code in src/flechtwerk/types.py
flechtwerk.Payload
module-attribute
¶
What a Message may carry as key or value — one wire encoding per member.
bytes: sent as-is; the application has already encoded it. The escape hatch for foreign wire formats (Avro, msgpack, JSON scalars/arrays, ...).str: UTF-8 text, deliberately NOT JSON-quoted — a wire-format commitment: string keys aredecode_key's exact mirror, and plain-text values feed foreign readers (e.g. Druid lookup tables). Quoting them would remap every partition and state identity.Event: canonical JSON — compact, sorted keys — so equal records produce identical bytes (stable partitioning for structured keys).
Event is the one Flechtwerk-schema payload: the wire carries no type,
so the reader assigns the semantics — a config topic's consumers wrap the
same bytes as Config regardless of what the producer held. Raw dicts
are rejected: wrap them in Event.wrap(d) for identical wire bytes plus
codec validation. State and Config are excluded on purpose — a
State inside a Message would be emitted, not persisted (yield it
bare to persist it), and a Config travels as data (wrap it in
Event(config)); the explicit conversion marks the semantic handoff.
flechtwerk.IncomingMessage
dataclass
¶
Typed Attributes¶
flechtwerk.attribute.Attribute ¶
A typed handle on one key in a RawDict, paired with an encode/decode codec.
optional=False (the default) declares a required field whose
write_to rejects None; optional=True allows None (stored as
JSON null). The read methods are kind-agnostic — which presence
semantic applies is chosen by the caller's method ([] vs
.get / .pop), not by this flag.
Public attributes:
name: the wire-level dict key this attribute reads/writes.codec: theCodec[V]driving encode/decode. Exposed so callers can compose codecs (e.g.LIST(some_attr.codec)lifts an attribute's codec into a list-of-V codec). Direct encode/decode access goes throughattr.codec.encode/attr.codec.decode; the Attribute itself only surfaces dict-operating methods (read_from,write_to,present_in,delete_from,get_from,pop_from).optional: whetherNone/ absence is permitted on write.
Source code in src/flechtwerk/attribute/attribute.py
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
required
property
¶
Whether this attribute must be present and non-None — the inverse of optional.
__init_subclass__ ¶
Reject subclasses outside this module — the Attribute hierarchy is sealed.
Source code in src/flechtwerk/attribute/attribute.py
read_from ¶
Look up this attribute in raw and return the decoded value.
Raises MissingAttributeError if the key is absent or the stored
value is None. This is kind-agnostic — a [] read raises on
absence regardless of optional; absence-tolerance lives in
Record.get / Record.pop.
Source code in src/flechtwerk/attribute/attribute.py
write_to ¶
Encode value and store it under this attribute's name in raw.
A required attribute (optional=False) rejects None at the
write-site so it can't land silently as JSON null; an optional
attribute stores None as null. The not self.optional check
rides the None branch only, so the common non-None write pays
nothing for it.
Source code in src/flechtwerk/attribute/attribute.py
present_in ¶
delete_from ¶
get_from ¶
Return the decoded value, or default if missing or None.
pop_from ¶
Remove and return the decoded value; raise KeyError if missing and no default.
A stored None is returned as None (no decode), and the key is
removed — mirroring dict.pop semantics for None writes.
Source code in src/flechtwerk/attribute/attribute.py
Runtime & Configuration¶
flechtwerk.Flechtwerk ¶
Bases: ABC
The application-facing handle for a Flechtwerk stage.
An application constructs one with the of factory and calls
run()::
await Flechtwerk.of(
application_id="my-extractor",
bootstrap_servers="localhost:9092",
client_id="my-extractor-0",
poll_interval=timedelta(minutes=1),
stage=my_extractor,
).run()
This is deliberately a narrow surface — of / run / the async
context manager, nothing else. The Kafka resources (consumers,
transactional producers, state stores, runners) live on the private
_FlechtwerkModule reactor-di container that of returns; an
application must not reach past this handle into the wiring, whose
invariants (EOS-v1 fencing, changelog restore ordering, config-store
bootstrap sequencing) are the framework's to keep. Same idiom as
Extractor.of / Transformer.of: a factory on the public
abstraction that returns a private concrete subclass typed as the
abstraction.
To embed Flechtwerk as a component of a larger reactor-di module, wire
the concrete container directly — declare make[Flechtwerk,
_FlechtwerkModule] and let the parent module fill every lookup
field by attribute name.
Source code in src/flechtwerk/module.py
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | |
of
classmethod
¶
of(
*,
application_id: str,
bootstrap_servers: str,
client_id: str,
stage: Extractor | Transformer,
compression_type: CompressionType | None = "zstd",
keyring: Keyring | None = None,
max_poll_records: int = 500,
metrics_labels: dict[str, str] | None = None,
metrics_port: int = 0,
mqtt: MqttBrokerConfig | None = None,
poll_interval: timedelta | None = None
) -> Flechtwerk
Build a fully-configured application handle.
Use this when running Flechtwerk as the program's entry point.
client_id is the process identity: every Kafka client this
module opens derives its ID from it, and for an MQTT-sourced stage
it also names the persistent MQTT session — so it must be unique
per instance and stable across restarts (production K8s passes the
pod name). compression_type defaults to "zstd" because
Flechtwerk outputs JSON everywhere (encode_json) and JSON
compresses ~13×; pass None to disable. keyring carries the
process keyring for flechtwerk.secrets encrypted attributes — it is
installed once per process at stage startup (__aenter__; a
conflicting second install raises) and may be left None by stages
without encrypted attributes. Constructing a handle has no
side effects; standalone producers/tooling call
flechtwerk.secrets.install_keyring(...) directly.
max_poll_records caps how many records one getmany() fetch
may return — Kafka's max.poll.records, and the same default of
500 (aiokafka alone leaves it unbounded). The cap is what makes a
backlog drain as many ordinary batches instead of one giant one: an
unbounded first fetch after downtime returns the entire backlog as
a single batch, which pins it in memory in parsed form, floods any
per-message I/O in transform with one concurrent call per state
key, and outlives max.poll.interval.ms — a mid-batch group
eviction. metrics_labels
defaults to an empty dict and metrics_port defaults to 0
(Prometheus disabled). mqtt carries the platform's shared MQTT
broker settings; it is used only by MQTT-sourced stages and ignored
everywhere else, so the caller may pass it unconditionally.
poll_interval is likewise consumed only by extractors — the poll
cadence (the runner's idle / wakeup wait). It defaults to None
("unset"); an extractor requires a positive timedelta and a
transformer ignores it, so this too may be passed unconditionally.
application_id, bootstrap_servers, client_id and stage
are required. An extractor scales out by itself: replicas of the
same application_id split the configs along the config topics'
partitions (see ExtractorRunner), so the deployment's replica
count is the only scaling knob.
Source code in src/flechtwerk/module.py
run
abstractmethod
async
¶
Bootstrap resources and run the configured stage forever.
The runner's main loop is while True, so under normal operation
this coroutine never returns — it terminates only by cancellation
or an unrecovered exception.
Source code in src/flechtwerk/module.py
flechtwerk.ConfigStore ¶
Latest config per wire key, merged across a stage's config topics.
Values are kept as wire bytes and parsed on every get() — each call
returns a fresh Config (a protective copy by construction). Malformed
values decode to an empty Config with a warning, so they flow into
the caller's validation instead of masquerading as a missing key.
From a stage's perspective the store is read-only: query it with
get() (and in / len). put/delete exist for the config
machinery alone — calling them, or otherwise mutating the store, from
application code is an error. The store is a projection of the config
topics, fed exclusively by bootstrap_config_store /
drain_config_updates; a stage-side write never reaches Kafka (see the
"config topics never participate in a Kafka transaction" invariant),
corrupts only this instance, and is silently reverted on the next record
for the key or on the next restart.
Source code in src/flechtwerk/configs.py
flechtwerk.MqttBrokerConfig
dataclass
¶
Shared MQTT connection settings — one broker serves the whole platform.
Defined here rather than in flechtwerk/mqtt.py so the container can
annotate its mqtt slot without importing paho: reactor-di evaluates
all class annotations at decoration time, and paho must stay an opt-in
import confined to flechtwerk/mqtt.py (what makes the flechtwerk[mqtt]
optional extra work).
Deliberately broker-only: the identity of the instance's persistent MQTT
session is the module-wide client_id (see Flechtwerk.of), which
configured_stage injects onto the stage alongside these settings.
Source code in src/flechtwerk/module.py
flechtwerk.CompressionType
module-attribute
¶
Kafka producer compression codec — closed set matching aiokafka's accepted values.
Secrets¶
The flechtwerk[secrets] optional extra — field-level encryption for attributes. See the Encrypted Secrets concept page for usage and the full spec (wire format, rotation, migration, post-quantum posture).
flechtwerk.secrets.ENCRYPTED ¶
Wrap inner so the attribute's value is stored as an encrypted token.
A plain composable codec: ENCRYPTED(LIST(STR)), LIST(ENCRYPTED(STR)),
and ENCRYPTED(RECORD) are all valid.
scope (default none) binds the token into a domain-separation
compartment — a token stamped with one scope is rejected by a codec
declaring a different one. It is a one-way ratchet: a scoped codec still
accepts an unscoped token (so a scope can be added to an already-encrypted
field without a flag-day, then swept in with reencrypt), but an unscoped
codec rejects a scoped token (a scope cannot be silently dropped).
read_plaintext (default off) tolerates a legacy plaintext value on read
for this attribute during a migration, logging a WARNING and emitting
secret_plaintext_read; off, a plaintext value raises
PlaintextSecretError.
Source code in src/flechtwerk/secrets.py
flechtwerk.keyring.Keyring
dataclass
¶
The keys and which one is primary — pure key material.
Build via Keyring.of(...) (raw bytes) or Keyring.from_json(...) (a
JWK Set document). Both validate: every key is 32 bytes and primary
names a held key.
Source code in src/flechtwerk/keyring.py
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | |
of
classmethod
¶
Build a keyring from raw key bytes — the programmatic / test entry point.
from_json
classmethod
¶
Parse an RFC 7517 JWK Set with a primary extension member.
Accepts oct keys with an unpadded-base64url k and a kid; unknown
members (top-level and per-key) are ignored, per JWK. This is the
deployment entry point — the application reads the document from
wherever it lives (a mounted secret, a file, an env var it reads
itself) and hands the text in.
Source code in src/flechtwerk/keyring.py
key_for ¶
Return the key bytes for kid, or raise UnknownKeyError.
primary_pair ¶
flechtwerk.secrets.encrypt_value ¶
Encrypt value for attribute and return the flenc:jwe: token.
The write-side boundary for producers and form backends — the primary
defense that keeps malformed tokens off the topic. Raises SecretError
if attribute is not an encrypted attribute.
Source code in src/flechtwerk/secrets.py
flechtwerk.secrets.reencrypt ¶
Re-encrypt token under the current primary key (decrypt-with-named-kid).
The building block for a rotation sweep and for cross-environment
promotion: it decrypts with the key the token names, then re-encrypts the
same payload under the primary — preserving the exact inner bytes without
round-tripping through the decoded value. Also promotes an unscoped token to
the attribute's scope (the ratchet's upgrade path); it cannot strip a
scope (an unscoped attribute's decrypt rejects a scoped token). Raises if
attribute is not encrypted, or SecretDecryptError if the token
cannot be read.
Source code in src/flechtwerk/secrets.py
flechtwerk.secrets.is_encrypted ¶
True if value is ciphertext-form — a string under the flenc: scheme.
Classification is by scheme, not by exact token: a plaintext config value that merely looks like a URL under a different scheme is not ciphertext-form. The envelope segment is validated later, at decode.
Source code in src/flechtwerk/secrets.py
flechtwerk.secrets.kid_of ¶
Return the kid a flenc token was encrypted under (no key needed).
A scan/rotation building block: reports which key a surviving record still
depends on. Raises SecretFormatError for a non-flenc value.
Source code in src/flechtwerk/secrets.py
flechtwerk.secrets.scan_config_topics
async
¶
scan_config_topics(
consumer: AIOKafkaConsumer,
topics: Iterable[str],
attributes: Iterable[Attribute],
) -> AsyncIterator[ScanEntry]
Yield a ScanEntry for each secret-bearing value across topics.
The engine behind the migration and rotation topic scans: for every
surviving record it reports which secret attributes carry ciphertext (with
the kid), which still carry plaintext (kid=None, error=None),
and which carry a flenc value whose kid could not be read
(error set). Reads each topic to its end group-less on the given
consumer, exactly as the config bootstrap does. Config topics are small by
contract, so results are gathered then yielded.
Since the scan is the authoritative gate before destructive steps (removing
a key, ending plaintext tolerance), it must produce a COMPLETE report: a
topic that does not exist raises SecretError rather than silently
contributing zero records — a missing topic must not read as a clean "no
plaintext / no old-kid" result — and a single corrupt token is reported as
a ScanEntry with error set rather than aborting the whole scan.