Commit graph

20 commits

Author SHA1 Message Date
e77f431d47 fix: resuming an auto-paused contract no longer discards the backlog v0.1.0
Found by tracing what happens when a back-dated backfill contract cannot
fetch a historical rate. The failure handling itself was fine — period 0
fails, the backlog halts so nothing settles out of order, five ledger rows
record the reason, no money moves, and the contract auto-pauses once the
retry budget is spent. The recovery was not.

The operator fixes the cause (switches to a stated rate, or to current),
clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently
written off, contract back to looking healthy, employee never paid. The
confirm dialog even asserted the missed paydays "are written off" — true of
one kind of pause and a lie about the other.

Two features colliding. "Do not backfill a deliberate pause" is right when
the operator paused: the pause *was* the decision not to pay. It is wrong
when payroll paused, because nobody decided anything — the money is still
owed and the operator has just removed whatever blocked it.

Contracts now carry `paused_reason`, set only when payroll pauses them and
cleared by a deliberate pause. Resume infers from it, and an explicit
`catch_up` still overrides either way. The console asks a different question
for each, quoting the reason, and flags a payroll-paused contract in the
table so the distinction is visible before anyone clicks.

Verified end to end: five failing ticks leave periods_done at 0 and pause
with "period 0 (2026-08-01) failed 5 times: no historical EUR rate
available for 2026-08-01"; resuming after switching to a manual rate keeps
the position at 0, and the next tick settles all seven owed periods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 23:02:45 +02:00
1584eb337f fix: honour a stated rate whatever the calendar says
Pay-now on a period due next week offered "Rate on the payday" and then
found a rate anyway, with only a passive hint to explain why. Two separate
problems behind that.

The real one: `resolve_price` tested `payday >= today` before it tested the
mode, so `manual` was silently ignored for any period not already
back-dated. "Pay at the rate we agreed" quietly did not, and a contract
pegged to a fixed rate honoured it only on late periods. A stated rate is
an instruction rather than a lookup, so it now wins outright and applies to
every period. `payday` and `current` keep their old order — for a payday
today or ahead there is no history to consult, so `payday` collapses into
`current`, which is the only defensible answer for a date that has not
happened.

The cosmetic one: the dialog's hint said "ignored unless the payday is
already in the past", sitting beside a control that plainly appeared to do
something — and was about to become wrong anyway, since manual now always
applies. It is replaced by a line that names the actual period: either
"Payday 2026-09-05 has not passed, so there is no historical rate to look
up — this will be priced at the current rate", or "Will convert at what BTC
was worth on <date>". The dialog header now shows which payday is being
settled, which was not visible at all before.

The contract dialogs lose the same stale hint; their label becomes
"Pricing" rather than "Price a late payday at", which no longer describes
manual mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 22:14:46 +02:00
3b14db3dff feat: name the applied rate in the payout memo
A back-dated payout's sat figure is unexplainable on its own — employee_2's
August backfill paid 18,353 sat and 14,794 sat for the same ten euro, and
only the rate says why. Both wallets now show it:

    dev retainer — 2026-08-01 · 10 EUR @ 54,487 EUR/BTC
    dev retainer — 2026-08-29 · 10 EUR @ 67,596.6 EUR/BTC

This required moving the `current` mode conversion into payroll as well.
Previously that mode handed create_invoice a fiat amount and let LNbits
convert, so the rate only became knowable by reading it back off the
resulting invoice — after the memo had already been fixed. Now every mode
resolves its rate before invoicing and the memo is accurate in all three,
rather than only for the back-dated ones.

It remains a single conversion, not a second opinion: fiat_amount_as_satoshis
is the same function create_invoice would have called, and the rate is
derived by the same formula calculate_fiat_amounts uses. Because passing
sats means LNbits no longer stamps the fiat metadata itself, payroll now
writes the identical fiat_currency / fiat_amount / fiat_rate / btc_rate keys
onto the payment, so a payroll payment still reads like any other
fiat-priced one in the payments list.

A rate that rounds an amount to zero sats now fails the period rather than
raising a zero-division while deriving the rate.

Memo omits the rate clause for sat-denominated contracts, where there is no
conversion to report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:55:49 +02:00
2e6009b612 docs: record the measured reach of the rate lookup
Ran the real lookup against both live APIs rather than the stubs. Three
findings worth being written down instead of rediscovered:

- Kraken returns ~721 daily candles, so ~2 years for any pair it quotes.
  EUR and GBP both resolve from it directly; the fallback never fires for
  them.
- CoinGecko's free tier answers within 365 days and returns 401
  Unauthorized beyond it — a plan limit wearing an auth error's clothes,
  not a transient failure. The practical ceiling is therefore ~2 years for
  major pairs and 1 year for anything else, which matters given the ask was
  "historical data for up to even a year".
- The two sources disagree by ~2.5% on the same date (2026-05-15: Kraken
  68,047.50 EUR/BTC, CoinGecko 69,743.26) — one exchange's daily close
  versus a cross-exchange average. Neither is wrong, which is the reason
  the ledger records rate_source beside rate rather than presenting a bare
  figure as canonical.

Refusal behaviour confirmed live: an unknown pair and a 2019 date both come
back None rather than falling through to some other number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:44:21 +02:00
3504bf2b02 feat(ui): choose how a late payday is priced
Surfaces the pricing modes in the console: a selector plus a rate field on
both the create and edit dialogs, and a "Rate used" column in the ledger
showing the figure alongside where it came from, so a payout can be
explained without opening the database.

Pay-now becomes a dialog rather than a confirm. That is the moment an
operator is most likely to want a rate other than the contract's own —
entering a payment that happened weeks ago at a figure they already know —
and the choice has to be made before it settles, not after. It defaults to
the contract's mode, disables the rate field unless "Rate I enter" is
picked, and refuses to submit a manual mode with no rate. The success toast
now names the rate applied, and a refused payout still shows its reason.

The mode selector is disabled for sat-denominated contracts, where there is
no conversion to have an opinion about, and its hint says the setting only
applies to a payday already in the past — otherwise it reads as though it
governs every payout, which it does not.

Quasar UMD rules honoured: no self-closing tags, `${ }` delimiters,
`:style` bindings rather than a <style> block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:43:21 +02:00
18a17cd693 feat: price a back-dated period at the day it was due
Until now every payout converted at whatever the rate was when it settled,
so a period paid late was silently mispriced. Contracts now carry a pricing
mode, and every payout records the rate it used plus where that rate came
from — a figure in the ledger can be explained months later instead of
merely trusted.

Three modes, per contract and overridable per payout:

- `payday` (default) converts at what BTC was worth on the payday itself,
  via the historical lookup.
- `current` converts at today's rate — correct when the obligation reads
  "we owe EUR 800 whenever it settles".
- `manual` converts at a rate the operator states (100000 EUR/BTC), for a
  figure that was agreed rather than looked up.

For a payday that is today or ahead, all three collapse to the same thing
and none of them touches the network: LNbits' own live pricing is the
freshest source available, so `resolve_price` returns the fiat amount
unconverted and lets create_invoice do its job. History is consulted only
where it can actually change the answer.

Where payroll does convert, it must hand create_invoice a sat amount —
create_invoice always prices fiat itself and cannot be told a rate. That
moves the single conversion point into payroll, which is why the rate and
its source are recorded on the payout. In `current` mode the rate is read
back off the invoice LNbits priced (extra["btc_rate"]) rather than
recomputed, so the row records the number actually applied.

An unavailable rate raises PricingError and fails the period. Deliberately
no fallback to today's rate: a rate that moved 30% since the payday would
pay 30% off and hide it, which is the class of error nobody finds until an
audit. The existing retry-then-pause machinery already handles a failed
period, and the ledger row names the date and currency that could not be
priced. Manual mode missing its rate is caught at contract-creation time
instead, rather than surfacing as a failed payout weeks later.

m003 defaults preserve behaviour for anything in flight — every live payday
is today or ahead, where the modes agree. Verified by applying m003 to a
copy of the running instance's database: the existing contract and its paid
payout both survive and read back correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:42:00 +02:00
aed31f4b70 feat: historical BTC rate lookup
Standalone piece for pricing a back-dated payout at the day it was due.
Nothing calls it yet; the pricing modes land next.

LNbits cannot answer this question. Its eight exchange providers are spot
tickers with no date parameter, and the rate history behind the admin chart
lives in TransientSettings — RAM only, wiped on restart, capped at
lnbits_exchange_history_size points, and collected for a single currency.
Measured on bohm: after 5.4h of uptime the buffer held 5h of USD at a 60
point cap, having already evicted its oldest points, with 85 fetch failures
leaving visible gaps. Fine for a monitoring graph, not for pricing a salary.

Kraken is primary and CoinGecko the fallback, decided on request count: one
OHLC?interval=1440 call returns ~720 daily candles, so a twelve-period
backfill costs a single request and every date is served from the parsed
series. CoinGecko is addressed by date, so the same backfill would be
twelve requests into free-tier rate limits — but it covers currencies
Kraken lists no pair for, and reaches back further than Kraken's ~2 years.
Kraken is also already one of the providers LNbits itself trusts.

The series is read from whichever result key is not "last": Kraken's key is
not predictable ("XXBTZEUR", "XBTUSDT", ...) and reconstructing it is a
guess. An error payload deliberately does not populate the cache, so an
unknown pair retries rather than serving an empty series for six hours.

Neither provider is allowed to raise. A rate that cannot be established
returns None, and the caller must treat that as "do not pay" — the one
outcome this module must never produce is a plausible-looking wrong number.
sats_for rounds rather than truncates, because always rounding down would
quietly shortchange the employee over the life of a contract.

Tests stub the HTTP layer rather than the function under test, so the real
cache path runs — an earlier version stubbed _kraken_series and
re-implemented the caching in the test, which would have passed even with
the cache deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:39:18 +02:00
36e1643470 docs: correct how the employee payslip surface is gated
The README and operations notes claimed the /my/* endpoints "stay reachable
regardless of LNBITS_ADMIN_EXTENSIONS" because "the API routes are mounted
instance-wide". Both halves are wrong, and the smoke test on bohm caught it:
an invoice-key request from employee_1 came back with

    {"detail": "Extension 'payroll' not enabled."}

LNbits gates every extension path per user via check_user_extension_access
(lnbits/decorators.py:420), not just the UI listing. Two consequences the
docs now state instead of contradicting:

- An employee needs `payroll` among their active extensions before an
  invoice key gets them anywhere. Enable it through
  LNBITS_USER_DEFAULT_EXTENSIONS or per account in the Admin UI.
- `payroll` must stay OUT of LNBITS_ADMIN_EXTENSIONS. That list trips the
  earlier branch of the same check — "User not authorized for extension" —
  which a non-admin can never clear, so it would permanently lock employees
  out of their own payslips. It also buys nothing: check_super_user on the
  router and user.super_user on the page route are what actually restrict
  the console, and a non-admin key was verified to get 401 from
  /contracts and /users.

Also notes the wart this exposes: an employee with the extension enabled
sees "Payroll" in their menu and gets a 403 page, because the only page
route is the operator console. Filed as a follow-up rather than fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 16:16:19 +02:00
8a10c3cacc chore: annotate the aggregate row so mypy passes
`fetchone` without a model leaves its TModel unbound, so the COUNT(*) row
needs an explicit type. Completes the black + ruff + mypy pipeline the
Makefile declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:56:45 +02:00
23bc54f558 feat(ui): super-user payroll console
The page the whole extension exists to be driven from: pick a user, pick
which of their wallets to pay into, set amount, currency, frequency, start
date and how many payments — plus the ledger, filters and a CSV button.

Notes for review:

- The employee-wallet picker only offers wallets belonging to the selected
  employee. The API rejects anything else, so this is about not presenting
  the mistake rather than about enforcement.
- The dialog draws a live calendar from whatever is currently typed. A
  wrong start date or frequency is cheapest to catch before saving, which
  is what the preview endpoint was for.
- "Next payday" comes from the schedule endpoint per row rather than being
  computed in JS. A payday this page derived for itself could disagree with
  the one the scheduler will actually use, and month-end is exactly where
  that would happen.
- The destructive actions say what they do: resume warns that missed
  paydays are written off, delete says the schedule position goes with the
  row and points at cancel instead, pay-now says the period is consumed
  even if its payday has not arrived.
- A refused pay-now surfaces the ledger row's reason with a longer toast —
  triggering a payout by hand is precisely when you want to know why it
  did not land.

Quasar UMD rules honoured: no self-closing tags anywhere in the template,
`${ }` delimiters so Jinja never sees a moustache, and `:style` bindings
instead of a <style> block, since LNbits themes override typography
utilities with !important.

The page route is gated on super_user as well, via check_user_exists plus
an explicit flag check — the template needs the full User for its wallet
picker, which check_super_user does not return. It is a UX nicety; the API
behind it is gated independently and does not trust this route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:56:16 +02:00
4b6d647d23 feat: CSV export and employee payslip view
Two audiences the super-user API did not serve.

Accounting gets `GET /api/v1/payouts.csv`, filterable by contract, status
and date range. The range bounds the *payday* rather than the row
timestamp, so a period contains the paydays that belong to it even when one
of them took three days of retries to settle — otherwise a late retry lands
in the wrong month's export.

Every exported field is neutralised against spreadsheet formula injection.
`detail` carries exception text and the memo carries operator input, and a
cell beginning `=`, `+`, `-` or `@` executes when the file is opened. Worth
the eight lines: this file is written specifically to be opened in somebody
else's spreadsheet.

Employees get `/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts`
on a **separate router** gated by a wallet invoice key rather than by
super-user rights. Separate router so it cannot inherit — or accidentally
shed — the wrong gate. Scoped to the wallet rather than the account because
an invoice key names exactly one wallet, leaving no lookup that could widen
the result to a sibling wallet the key does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:53:12 +02:00
ee10b2e04d feat: schedule preview and off-cycle payouts
Two things an operator needs that the scheduler alone does not give them.

Preview draws a contract's calendar from loose terms — start date,
frequency, period count — with no employee or wallet required, because the
schedule is what an operator wants to sanity-check first and a mistyped
start date is cheapest to fix before anything is saved. The same shape is
available for a live contract, from its current position. Both return
`ends_on`: "12 monthly payments from 15 Jan" is far easier to verify
against "ends 15 Dec" than against a list of twelve dates.

pay-now settles the next period immediately and consumes it. Deliberately
one endpoint rather than two: "run it now, don't wait for the tick" and
"pay it early" are the same operation and differ only in whether today
happens to be the payday. It bypasses the back-dated skip — that guard
exists to stop a new contract firing surprise back-pay, and an operator
explicitly asking to pay is not a surprise — and it takes the same
per-contract lock as the scheduler, which is what that lock was added for.
The ledger row is returned even on failure, since a manual payout that did
not land is exactly when you want the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:51:20 +02:00
8b054d27ea feat: payout ledger with bounded retry
Closes the gap the scheduler commit left open: a failed payday was retried
indefinitely with nothing but a log line to show for it.

Every attempt — paid, skipped and failed — is now written to
payroll.payouts and exposed at GET /api/v1/payouts. Failures are in the
ledger, not only in the log, because "why did nobody get paid on the 1st"
is the question the ledger exists to answer.

Ledger rows are self-describing: each copies the terms in force at the time
(amount, currency, both wallets) instead of pointing at the contract, and
there is no foreign key to contracts. A payout has to still read correctly
after its contract is edited, and deleting a contract must not take its
history with it. This is the one place duplicating a contract field is
right — the contract holds what is true now, a payout holds what was true
then.

Retries are bounded at 5 attempts per period, after which the contract is
paused rather than the period abandoned. A payday that cannot be funded is
a fact somebody has to act on; dropping it silently is the one outcome
payroll must never produce. Pausing does not advance the position, so
resuming after topping up retries the same payday. The attempt count is
derived from the ledger rather than a column on the contract, so it
survives a restart and stays auditable.

Also restores the explanatory comments on the broad `except Exception`
handlers, which ruff's RUF100 stripped along with their now-unused noqa
directives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:49:46 +02:00
95bfa86f79 feat: contract lifecycle — pause, resume, cancel
Beyond create and delete, a payroll line needs to be stoppable without
being erased. Three transitions, expressed as pure functions on the model
so the rules are testable without a DB, and mapped to 409 at the API
boundary — a refused transition is a well-formed request that the
contract's current state declines.

The decision with money attached is what resume does about the paydays that
fell while the contract was paused. It skips them: a pause is a decision
not to pay, and resuming into an unannounced multi-period transfer is the
opposite of what "resume" implies. `catch_up=true` opts into paying them,
for a pause that was an operational hold rather than a call about the
money.

Cancel is now the way to stop a running payroll; DELETE stays as the
"created it by mistake" escape hatch, and the docstrings say which is
which, because deleting discards the schedule position and the record that
the contract ever existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:47:07 +02:00
99f2131474 feat: recurring payout scheduler
Turns a contract into money moving. One permanent task ticks every five
minutes and settles whatever each active contract owes; the actual transfer
is a plain internal LNbits invoice on the employee's wallet, paid from the
source wallet.

services.py is split into a pure half and an effectful half on purpose.
Paydays are the part of payroll that is easy to get subtly wrong and
expensive to get wrong in production, so the schedule math has no DB, no
wallets and no clock of its own, and is covered by tests.

Decisions worth reviewing:

- The n-th payday is a function of start_date and n alone. Advancing a
  stored date would drift on every late tick and would pin a month-end
  contract to the 28th forever; anchoring means 31 Jan pays 28 Feb and then
  31 Mar. Tested both ways round.
- A failed period does not advance the contract's position, and a backlog
  halts at the first failure so paydays cannot settle out of order.
- The sat amount is derived exactly once, by create_invoice, and the value
  it returns is what gets recorded — never recomputed from amount x rate.
- Back-dated start dates skip rather than back-pay by default; a mistyped
  start date is far more likely than a genuine back-pay request. Explicit
  `backfill` opts in, and a single tick is capped at 12 periods either way.
- Per-contract asyncio lock, with the row re-read under it. Not needed by
  the scheduler alone, but off-cycle payout paths land mid-tick and
  double-paying is the worst thing this extension could do.

Known gap, addressed by the payout-ledger commit that follows: a failure is
retried indefinitely, once per tick, with nothing but a log line to show
for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:45:40 +02:00
55f3dfac91 style: run black and ruff over the committed sources
Pure formatting — import ordering and black's line wrapping. No behaviour
change. Kept out of the feature commits so their diffs stay readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:45:27 +02:00
889751a41f chore: ignore the lnbits runtime data folder
Running the test suite imports lnbits, which creates ./data/ next to the
CWD and drops a real 32-byte .lnbits_auth_key into it. That is precisely
the file class behind the 2026-05-14 leak, and it appears without anyone
asking for it — so it gets ignored before any feature commit can sweep it
up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:45:21 +02:00
dc29173ada feat: super-user REST API for payroll contracts
Contract CRUD plus the account directory the operator picks an employee
from. The extension loads and is fully drivable over HTTP after this
commit; the console UI lands separately.

Auth: the gate is `check_super_user`, applied at the *router* level so a
new endpoint cannot be added ungated by forgetting a decorator. Payroll
reads accounts the caller does not own and moves money between their
wallets, so a wallet-scoped `require_admin_key` — which any user holds for
their own wallets — would be the wrong gate here despite the similar name.

Validation lives in one place (`_validate_terms`) because the employee and
the destination wallet arrive from a form as two independent ids and
nothing further down the write path re-checks that they belong together.
That check is what stops a payout being aimed at a third party's wallet.

Edits are deliberately partial: recipient, source wallet and start date are
not patchable. They are the contract's identity — changing them would
retroactively alter what already-made payouts were for, and re-anchoring
the start date would silently move every remaining payday.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:40:18 +02:00
f253b51aa9 feat: payroll contract schema and CRUD
The one entity payroll needs: a standing instruction to pay an employee a
fixed amount, at a fixed cadence, from a given start date, a given number
of times.

Two design decisions worth reviewing here, both documented in
docs/data-model.md:

- Schedule position is `periods_done` (a counter), not a stored
  `next_run_at`. Every payday is recomputed as occurrence(start_date,
  frequency, n), so a late tick cannot make the schedule drift, and a
  contract anchored on the 31st pays 28 Feb then 31 Mar rather than being
  permanently pinned to the 28th.
- `periods_done` counts periods *consumed* (paid or deliberately skipped),
  not periods successfully paid. A failed payout leaves it untouched so the
  next tick retries that payday instead of dropping it.

There is no employee table — an employee is an LNbits account. Only
`employee_username` is copied, and only as a display label so history stays
readable after a rename; authorisation always goes through `employee_id`.

Ordinary migrations, not the migrations_fork.py split: this is an
aiolabs-original extension, so there is no upstream migrations.py to stay
byte-identical with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:38:25 +02:00
a211a8ab8b chore: scaffold payroll extension
Repo tooling and LNbits extension metadata only — no runtime code yet, so
this lands separately from the feature work per the workspace commit rules
(cross-cutting concerns commit first).

- MIT licence, .gitignore, README
- Makefile + pyproject wired for the standard aio fork lint pipeline
  (black + ruff + mypy) and pytest
- config.json declaring id/version/tile so the extension is installable
  from the aiolabs catalog once there is something to install

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:36:32 +02:00