Revision history for Punk

0.49    2026-09-11
        - ADDS: i18n catalogues are held in a Frozen block instead of an
          arena built here. The block holds a tree, so a level and a leaf
          are different nodes.
        - FIXES: a literal key containing a dot and the nested form it
          looks like were the same key, and which one answered was decided
          by the hash order of the process that loaded it. Two workers in
          one pool could serve different translations for the same key. A
          dotted key is now a boot error.
        - FIXES: a catalogue value that is not text no longer renders as
          the key. null, an array and a boolean are boot errors naming the
          path; numbers are stored as their digits and read back unchanged.
        - FIXES: UTF-8 is preserved through interpolation. A substituted
          value was appended as raw bytes into a string that was then
          flagged UTF-8, so a non-ASCII entry plus a downgraded
          substitution produced malformed output. Reachable from a form
          field.
        - FIXES: a catalogue from any producer keeps its encoding. Only the
          JSON path was correct before, and only because that decoder
          happens to decode to UTF-8.
        - FIXES: the documentation described a language-switcher cookie
          that was never implemented. Punk reads punk.lang and has never
          written it; the POD now says so and shows the handler that does.
        - ADDS: the locale hash can be iterated. keys, each and scalar used
          to die. The order is the same in every process.
        - BREAKING: _build takes catalogues that have been through
          Frozen's builder; a catalogue key may not contain a dot; a
          catalogue value must be text.
        - Requires Frozen 0.06.

0.48    2026-09-09
        - ADDS: websocket routes work over HTTP/2 through Extended
          CONNECT (RFC 8441). There is no 101, no Upgrade header and no
          Sec-WebSocket-Key on a multiplexed transport, so the handshake
          is new - but the RFC 6455 codec above it is the same one the
          HTTP/1.1 path uses. An Extended CONNECT is routed as the GET
          it stands in for, and the origin check still applies to it.
        - ADDS: sse routes and $c->stream work over HTTP/2 and over TLS.
          Both ran on Hyperman's detach, which hands over a file
          descriptor and so refuses an h2 stream and a TLS session; they
          now fall through to Hyperman's ABI v6 stream handle, which
          sends status, headers and body pieces through the server.
        - A stream reset by the peer, or a client that goes away, now
          reaches the handler: an sse stream's close callback fires and a
          $c->stream drain settles. HTTP/1 could only infer this from a
          dead connection, and a multiplexed transport cannot.
        - No Connection and no Transfer-Encoding on an HTTP/2 or HTTP/3
          streamed response. Both are hop-by-hop and forbidden there.
        - A refused detach falls through to the next transport instead of
          answering 503.
        - A die mid-stream truncates on the stream handle too, through
          Hyperman's v7 stream_abort: RST_STREAM on HTTP/2, a connection
          reset on TLS. A streamed body has no declared length, so ending
          cleanly is the claim that it is whole.
        - Requires Hyperman 0.45.

0.47    2026-09-09
        - FIXES: a request body with no CONTENT_LENGTH and no
          Transfer-Encoding was read as no body at all. HTTP/2 and HTTP/3
          forbid Transfer-Encoding, so that is every streamed h2 upload.
          The read now keys off psgix.input.buffered, and HTTP/1 is
          unchanged.
        - FIXES: max_body imposed no ceiling on a request with no declared
          length. It now bounds the read, for ->body, body_each, body_to
          and the multipart streamer.
        - FIXES: $c->stream chunk-framed every response that was not
          literally HTTP/1.0, putting a Transfer-Encoding on HTTP/2 and
          HTTP/3 responses, which forbid it. Only HTTP/1.1 is chunked now.
        - Punk::Test's environment sets SERVER_PROTOCOL to HTTP/1.1,
          overridable per request through `env`.

0.46    2026-09-08
        - ADDS: $c->req->xml - the request body parsed as XML through
          File::Raw::XML's C ABI, as a File::Raw::XML::Document. The parse
          is strict and takes no options: a DOCTYPE is refused wherever it
          stands, so external entities, XXE and the billion laughs are not
          reachable from a request. Parsed once per request.
        - ADDS: $c->xml($doc, $status?) - a Document, a Node or a string of
          markup as an application/xml response.
        - FIXES: a File::Raw::XML::Document returned from a handler, or set
          as a Punk::Response body, was JSON-encoded - it is a blessed
          reference like any other. It is now written as markup, which is
          also what makes respond_to(xml => sub { $doc }) answer
          application/xml.
        - Punk::Test takes `xml => $doc_or_string` as a request body and
          answers ->xml with the parsed response.
        - NOTE: $c->validate's `auto` source recognises only
          application/json, so a schema on an XML POST validates the query
          string rather than the body. Name the source to be sure of it.
        - File::Raw::XML 0.03 is a new prerequisite.
        - FIXES: the file cache's single-flight lock judged its holder's age
          from whole-second st_mtime, so an age could read a second too high
          and a `lock_wait` under a second was spent before it began 
        - FIXES: a single-flight lock that could not be attempted at all - a
          path that would not form - answered "you won", so every caller
          computed and the herd ran unprotected with nothing said. It is
          counted now and reported as `lock_errors` from the file store's
          stats.
        - t/0733-auth-sqitch.t asserted Sqitch's own deploy output, which is
          translated - under LC_ALL=de_DE a successful deploy reports
          "+ users ........ OK" and the test failed.

0.45    2026-09-06
        - A model class may now be subclassed: `package MyApp::Model::Thing;
          our @ISA = ('Punk::Model::Thing');` inherits the parent's table and
          fields, and `use Punk::Model` in the subclass lets it add a field,
          redeclare one to change its spec, or name its own table.

0.44    2026-09-05
        - ADDS: $app->keyword($name => @args) and $app->has_keyword($name) -
          call a keyword another plugin installed, from code, in the
          caller's context

0.43    2026-09-03
        - SECURITY: a websocket upgrade is refused with 403 unless its
          Origin is one the application answers as 
        - ADDS: the `origin` websocket route option - a hostname, an
          arrayref of them in `host allow` syntax, a coderef, or 0 to
          serve any origin.
        - ADDS: $c->req->body_each($cb) and $c->req->body_to($dest) - the
          request body a window at a time, and straight to a file, for the
          bodies that are not multipart. A body is read once: after either
          of these, body/json/form croak rather than answer with nothing,
          and a body already read whole is replayed from the copy.
        - ADDS: the after_response phase - `hook after_response` and
          $c->after_response($code), for work a request generates that the
          client is not waiting for. 
        - ADDS: the `rate_limit` route option - a budget for one route,
          compiled into a guard at to_app and enforced by the same C the
          keyword installs. Takes limit/window/by/tag, or a bare count.
          The counter is namespaced to the route unless a tag shares one
          between several, and the guard runs before `validate`, so a
          refused request never parses a body.

0.42    2026-09-02
        - A blessed error raised from a future, a view or a websocket
          handler arrived as a plain string on perls before 5.14. The
          croak_sv shim had no branch for a reference.
        - t/1002-awaitable.t and t/1050-asyncawait.t assert cancellation
          across an await only on perl 5.24+, which is where
          Future::AsyncAwait chains the awaited future rather than
          registering a code block that reaches nothing.
        - Both report the Future::AsyncAwait version they ran against.

0.41    2026-09-01
        - FIX: creating a future read an environment variable every time, so
          a future-returning request cost 40% more on a host with a few
          hundred of them than on one with a few dozen. The switch is read
          once now.
        - A handler that returns a Punk::Future is chained in C: one
          continuation rather than three, and none of the ->can and ->isa
          probes the general path needs. A future of any other class takes
          that path unchanged.

0.40    2026-09-01
        - Punk::AsyncAwait: async and await in apps, controllers and models.
        - Punk::Future implements the Future::AsyncAwait::Awaitable API, and
          the future an async sub returns carries the loop it suspended on.
        - Punk::Future->on_cancel, taking a code reference or a future.
        - Punk::Future->result, the Future name for get.
        - FIX: get in scalar context returned the last value, not the first.
          A handler returning a multi-value future got its tail.
        - FIX: a future method given an object of another class that is also
          a blessed IV read the wrong struct instead of croaking.
        - use Punk::Controller sets @ISA, strict and warnings; use parent
          'Punk::Controller' is unchanged.

0.39    2026-08-31
        - Fix t/1113-idempotency-flight.t compares the recorded pid as a value,
        which perl 5.16 spells as a string

0.38    2026-08-31
        - t/1040-stream.t drops its client on a stream with no end, so no
          socket buffer can hold the whole body first

0.37    2026-08-30
        - $c->stream sends the real chunk length wherever perl expands
          my_snprintf to a statement expression

0.36    2026-08-30
        - ADDS: $c->stream($content_type, $cb) + Punk::Stream - a response
          body of unknown length on the SSE transports (Hyperman detach,
          psgi.streaming, blocking psgix.io), chunk-framed on the socket
          transports so a die is visible truncation, never a short success
        - Punk::Stream drain - a Punk::Future settled when the write buffer
          reaches the kernel; awaiting it after each write bounds memory to
          one chunk while the worker keeps serving other requests
        - Punk::Test declares psgi.streaming in every request env, so
          get_ok drives a streamed route instead of collecting its 501
        - ADDS: the `last_modified` route option - Punk::Plugin::
          ConditionalGet's date validator, a coderef returning an epoch,
          answered 304 before the handler runs; If-None-Match wins, the
          date comparison is exact (the file path's convention), a future
          epoch is clamped to now
        - ADDS: Punk::Test `upload` - multipart/form-data requests: file
          parts from a path or a scalar ref, streamed through one read
          buffer however large, boundary verified absent from the content
        - t/0410-upload.t posts through the client, so the encoder and the
          streaming parser test each other
        - ADDS: `max_record` on Punk::Plugin::Idempotency - a response
          larger than it (default 1MB) is served with
          Idempotency-Recorded: false and not stored; the single-flight
          lock still covers the request
        - ADDS: `signed => 1` on $c->cookie, both directions - HMAC over
          name=value with the session's secret and verify, so a tampered,
          swapped or unsigned cookie reads as undef

0.35    2026-08-29
        - An SSE heartbeat that finds the client gone no longer re-arms
          itself on a stream it has just torn down.
        - Freeing an SSE stream drops its loop watchers before its socket.
        - t/0733-auth-sqitch.t skips unless `sqitch` is runnable, not
          merely installed.
        - t/1020-sse.t polls for the close rather than sleeping on it, and
          reports what answered and which backend when it does not.

0.34    2026-08-26
        - ADDS: pk_abi v4 - on_log_ctx, a log tap that is handed the context
          the line was logged against, so a telemetry layer can correlate a
          record with its request. on_log is unchanged; a consumer registering
          both receives every record twice.
        - The logger resolves its context before running its observers.

0.33    2026-08-25
        - Static mounts hold small files in memory per worker, so a hit does
          no file syscall

0.32    2026-08-23
        - ADDS: `punk serve [DIR]` - a directory of files over HTTP under
          Hyperman, on 127.0.0.1:8000 by default
        - ADDS: `index` and `list` options on Punk::Static, so the `static`
          keyword serves a tree of index.html files
        - Return _abi_ptr as a UV, not an IV.
        - `punk doctor` loads every Punk::Command::* on @INC before it
          prints, so a plugin distribution's register_doctor row appears
          without that plugin's own command having been named first.
        - REMOVES: the plaintext-secret guardrail in Punk::Config, and the
          `secrets` option that set its mode. A value written into the
          configuration file in plaintext is the decision of whoever writes
          the file
        - ADDS: $app->auth_config - the frozen auth configuration read back

0.31    2026-08-23
        - ADDS: kits. `punk new MyApp --kit diy` generates through
          Punk::Kit::Diy instead of the basic skeleton
        - A kit subclasses Punk::Generate, keeps its templates in skel/
          beside its own module, and declares the options it takes, which
          are read out of argv before Getopt runs so they parse and show
          up under `punk new --kit NAME --help` like the command's own 
        - ADDS: hook before_render. Runs once per $c->render, after
          Punk's own binds (url, csp_nonce, locale), with the data
          hashref the engine is about to receive - so a plugin can put a
          value into every template without the handler passing it.
        - ADDS: Punk::Command->load_app, the public form of what every
          application-loading command does, for a command in another
          distributio
        - ADDS: $t->request_header in Punk::Test - a header on every
          request from here on
        - ADDS: named routes. A route takes { name => 'book' }, and
          $c->url_for('book', id => 42) gives back its path 
        - In a template, {% url.books %} for a static route and
          {% row | url_for('book') %} for one with captures
        - ADDS: `layout` as a render option. $c->render($tpl, \%data,
          layout => undef) renders a template without the configured
          wrapper
        - ADDS: $c->fragment($tpl, \%data, %opts) - render with no layout
          and Cache-Control: private
        - ADDS: $app->databases - the configured databases read back, a
          deep copy keyed by name with `default` for the unnamed one
        - ADDS: $app->on_compile($code, $owner?) - a callback for to_app,
          run in registration order after every keyword has recorded and
          before anything is compiled
        - ADDS: filter operators on Punk::Model search and count, on both
          backends: = != < <= > >= in not_in like starts_with, as
          { column => { op => value } } beside the plain equality.
        - ADDS: order_by on search - a column, or [ column => 'asc'|'desc',
          ... ] pairs 
        - ADDS: count(\%filter) on the model and both backends.
        - ADDS: $c->txn(sub { my ($tx) = @_; ... }) and $c->txn($database
          => sub { ... }), with Punk::Txn

0.30    2026-08-22
        - `csrf: true`, `cors: true` and `headers: true` in punk.yml now
          register.
        - The config guardrail no longer flags a key that only names
          where a secret lives: `token_model`, `password_field`,
          `secret_path` - anything ending in _model, _class, _table,
          _field, _column, _header, _cookie, _path, _dir, _name or
          _kind - holds a model, a column or a location, not a secret.

0.29    2026-08-22
        - Punk::Plugin::Sitemap: a missing `base` now croaks at to_app
          rather than at `plugin`
        - ADDS: `host ..., allow => [...]` - the hosts that may stand in
          for the canonical one, and $c->origin / $c->host_allowed: the
          request's origin only when its Host is declared, the canonical
          origin otherwise, never the raw header.
        - ADDS: `favicon` - GET /favicon.ico from bytes frozen at
          to_app, with Cache-Control, a strong ETag and 304s. A missing
          file croaks at boot. Replaces the send_file boilerplate.
        - `host` and `favicon` are now DSL keywords: an application
          defining its own sub with either name sees a redefinition
          warning at boot.
        - Test fix: t/1011-ws-live.t and t/1030-room-bus.t read one
          frame per sysread and threw away the rest of the buffer

0.28    2026-08-21
        - ADDS: Punk::Plugin::I18n - translations and language
          negotiation. 
        - REQUIRES Template::Stencil 0.10, for tied hashes in a path.
          The `locale` hash is tied so a template lookup reads the
          shared catalogue directly
        - Punk::Cache single-flight fix: a loser that acquired the lock
          after the winner finished went straight to computing without
          looking again, so the herd computed twice.
	- Improve test coverage

0.27    2026-08-21
        - Fix SEGV/BUS on every request carrying a session cookie
        - t/0914-session-pool.t now names the signal when a forked
          worker dies, instead of reporting four uninitialized values
          and hiding the crash that caused them.

0.26    2026-08-21
        - `session store => ...` keeps the session server-side and the
          cookie carries a signed 128-bit id.
        - `$c->session_expire` now REVOKES a stored session: it deletes
          the entry, so a cookie somebody copied is dead on its next
          request. Without a store it is unchanged, and still can only
          ask the browser in front of it to forget.
        - `$c->session_rotate` keeps the session and gives it a new id,
          deleting the old entry.
        - `session sliding => 1` extends a stored session while it is
          being used
        - `session tier => $seconds` lets a stored session be read
          through the cache's memory tier, which it otherwise goes
          round. The number is how long a revoked session may keep
          working on a worker that missed the invalidation, so it is
          capped and checked against the store's own memory_ttl.

0.25    2026-08-21
        - Punk::Cache::stats no longer panics on a tiered store. The
          XSUB asked the value stack for four fewer slots than it
          pushes, which a DEBUGGING perl catches and turns into
          "failed to extend arg stack",
        - Punk::Plugin::Blob accepts a CODEREF `namespace` again. The
          required-option check read the length of a scalar that has
          no string body, which aborts a DEBUGGING perl outright.
        - Punk::Plugin::Blob resolves Apophis on every perl. The ABI
          pointer was popped with a macro that mentions its argument
          twice, so it came off the wrong stack slot and the plugin
          refused to load 
        - Punk::Plugin::Metrics reports its numbers on a long double or
          quadmath perl. 

0.24    2026-08-20
        - Large uploads no longer cost what they weigh. A
          multipart part over 64KB is written to a temp file as it
          arrives rather than copied into memory, and Punk::Upload
          carries a `path` and an `fh` to it. Measured end to end
          through a socket into a handler holding the upload, a 128MB
          upload costs a worker 15.5MB of RSS against roughly 275MB
          before.
        - ADDS: the `upload_dir` keyword, naming where a large part is
          spilled. It decides the filesystem, which decides whether
          save() is a rename, and it decides what shares a filesystem
          with attacker controlled bytes. Temp file names owe nothing
          to the client's filename, and every file is removed when its
          request ends.
        - ADDS: Punk::Plugin::Blob stores an on-disk upload without reading
          it into memory: hashed where it lies through Apophis's
          identify_fh, then moved into the store.
        - ADDS: Punk::Plugin::Idempotency - Idempotency-Key on unsafe
          methods, replaying the stored response. 
        - ADDS: Punk::Plugin::Metrics - a Prometheus /metrics endpoint
        - ADDS: Punk::Plugin::Health - /healthz and /readyz, 
        - ADDS: Punk::Plugin::CSP - Content-Security-Policy with a
          per request nonce.
        - ADDS: Punk::Plugin::ConditionalGet - ETags and 304s for
          dynamic responses. 
        - ADDS: Punk::Plugin::Blob, content addressed uploads on
          Apophis. $c->blob_put stores by contents, so a user's filename
          never becomes a filesystem path. $c->blob_send serves through
          send_file as a download.
        - ADDS: Apophis to the prerequisites.

0.23    2026-08-20
        - ADDS: Punk::Plugin::Sitemap sitemap.xml/robots.txt
        - ADDS: static mounts can say how fresh a file is. `max_age`
          and `cache_control` set Cache-Control on a plain URL, and
          $c->send_file takes cache_control too.
        - ADDS: content-addressed asset URLs. $c->asset('/static/app.css')
          returns /static/app.9f3a1c2b0d4e5f60.css. The digest - the first 8
          bytes of SHA-256 over the contents. 
        - ADDS: Punk::Plugin::RequestId per request identifier

0.22    2026-08-20
        - ADDS: Punk::Plugin::Sitemap, phase 1 - which of an
          application's routes are actually URLs. Punk compiles every
          route at boot, so the application already holds a complete
          list of what it serves, and a sitemap is that list with a
          filter over it. A route is listed only when it is a GET, its
          path holds no capture, it carries no guard, and it did not opt
          out. Out is the safe direction: a page missing from a sitemap
          is still crawled if anything links to it, while a page wrongly
          present is a crawler fetching a 404 or a login redirect on a
          schedule.
          The part a hand-written sitemap cannot do: a page behind a
          guard is excluded WITHOUT anyone maintaining a list, because
          `under($prefix, $guard)` copies its guard chain into every
          route declared inside it and the plugin reads it off the
          record.
          `base` is required and is configuration. The protocol wants
          absolute URLs, and taking the host from the request would let
          `Host: evil.example` produce a sitemap naming that host for
          every page on the site - delivered to search engines, and
          invisible to the owner, whose own request produces a correct
          file.
          New route option: `sitemap => 0` keeps a route out, and
          `sitemap => 1` puts one in despite a guard, because a scope
          guard may be an authentication check or an ordinary filter and
          Punk cannot read which. The method and the shape are not
          overridable - sitemap => 1 on a POST or on /users/:id is a
          mistake rather than an instruction.
          Rendering, dynamic sections and robots.txt are phases 2 to 4;
          the plan is in plan_punk_sitemap/.
        - FIX: change abi version checks from == to <=
        - Adds: Punk::Cache, Punk::Cache::Memory and Punk::Cache::File

0.21    2026-08-20
        - FIX: A Punk::WebSocket::Room now reaches the whole pool.
        - FIX: SSE also now works correctly accross workers.
        - ADDS: $c->publish and $c->subscribe hyperman event bus hooks

0.20    2026-08-19
        - The dynamic-route path no longer requires Open::API. It
          checked that api_mounts EXISTED rather than that it held
          anything, and api_mounts is always present in the compiled
          state - so an app that never declared an `api` still reached
          the API branch on any route that was not a static hit, and
          croaked there when Open::API's C ABI did not match.
        - Punk publishes a C ABI of its own, pk_abi.h, installed through
          ExtUtils::Depends and reached at runtime through
          Punk::_abi_ptr. 

0.19    2026-08-18
        - SECURITY: a session had no server-side lifetime. `expires` set
          only the cookie's Max-Age, which is a request to a client that
          is free to ignore it - the signature carried no time at all, so
          a cookie captured once (a shared machine, a log, a backup, a
          proxy) stayed valid for as long as the secret did, and
          session_expire only asked the browser to forget a value that
          still authenticated. The expiry is now stamped inside the signed payload
        - New $c->safe_path($path, $fallback?): returns the path when it
          is same-origin and relative, otherwise the fallback. Punk had
          no such guard while auth_guard hands every application a ?to=
          to redirect to, which made the obvious login form an open
          redirect. Same rules as Punk::OAuth2's same_origin_path, for
          the same reasons (CVE-2026-75628)
        - The static traversal guard splits segments on a backslash as
          well as a slash. On POSIX "..\.." is one ordinary filename and
          traverses nothing, but Windows reads it as a separator, so a
          guard that split on "/" alone saw one harmless segment where
          the OS would see two levels up. 
        - New hook phase `before_request`, which runs BEFORE routing:

            hook before_request => sub {
                my ($c) = @_;
                $c->stash->{t0} = Time::HiRes::time();
                return;
            };

          The earliest phase until now was before_dispatch, which runs
          only after the router has matched - so nothing could observe a
          request from its start, and nothing ran at all for a 404, a
          405, or anything answered by a PSGI or static mount.
          before_request runs for all of those.
        - The logger takes a record: a lone unblessed hashref is a set of
          fields, and its `message` key is the message.

            $c->log->info({ message => 'listing books',
                            books => 12, user => $id });

0.18    2026-08-18
        A security release. issue reported by CPANSec.
        - SECURITY (CVE-2026-75870): `session` declared without a secret
          signed the cookie with an EMPTY HMAC key. The keyword froze
          whatever options it was given without requiring a secret,
          warning, or refusing to start, and both the cookie read and the
          write-back defaulted that key to "". a secret is now required

0.17    2026-08-17
        - Fixed t/14-futures.t failing on every perl whose Future is
          older than the one it was developed against - reported by
          CPAN Testers against 0.16 on 5.20.0, 5.22.2 and 5.24.0 with
          "Expected __ANON__(Future.pm line 1140) to return a Future".
        - A route may now be declared as one hashref, with the handler
          under `cb`:

            post "/upload" => { cb => "Web::File#create",
                                max_body => 50_000_000 };

          instead of the positional form, which is unchanged and stays
          the one `punk new` generates:

            post "/upload" => "Web::File#create",
                              { max_body => 50_000_000 };

        - New route option { compress => 0 }, which opts a route out of
          the server response compression Hyperman 0.25 adds. Punk does
          not compress - that belongs to the write path - so the option
          is spelled as a plain response header, Content-Encoding:
          identity, which the server honours and strips. 
        - New `max_body` keyword and per-route { max_body => N }: refuse
          a request whose CONTENT_LENGTH is over a ceiling, with the
          same 413 an oversize API operation gets. 
        - Punk::Upload documents what an upload actually costs: it
          arrives whole in memory before a handler sees it, ->save is a
          write and not a stream, and `max_body` does not reduce the
          memory.
        - SECURITY: the `markdown` mount reflected the request path into
          the Location header of its canonical 301 (the redirect that
          strips a trailing slash or a .md suffix). PATH_INFO reaches a
          PSGI app percent-DECODED, so that echoed bytes the client
          chose: a decoded CR/LF split the response at any mount point,
          and at a root mount "//evil.example/" came back as a
          protocol-relative 301 off the site. Found while fixing the
          same class of bug in Punk::OAuth2 (CVE-2026-75628).
          The redirect now only names a path that IS a page - the
          stripped target is looked up in the page table first - so the
          only thing it can echo is one of the mount's own keys.
          Anything else falls through to the 404 it was always going to
          get. pmd_redirect also stops at the first control byte, in
          case a mount prefix ever carries one.

0.16    2026-08-17
        - New `proxy` keyword: reverse-proxy trust. Behind nginx, an ELB
          or a CDN, REMOTE_ADDR is the proxys on every request - and
          because rate_limit's counters live in Hyperman's shared arena
          they are exact across the whole worker pool, so every client
          landed in ONE bucket and a limit => 100 rule throttled the
          entire site at 100/min. block_ip, keyed the same way, it would ban
          the load balancer. `proxy` resolves the real client at the
          top of the dispatcher and OVERWRITES REMOTE_ADDR, so
          rate_limit, block_ip, the access log and $c->req->address are
          all correct with no change to any of them; the connecting
          address stays as $c->env->{'punk.peer_addr'}.
        - New $c->req->address: REMOTE_ADDR, which is the socket peer
          directly exposed and the resolved client behind `proxy`.

0.15    2026-08-17
        - A trailing slash on the request path no longer 404s:
          GET /account/ answers the route declared as GET /account.
          The retry runs only after every declared route, API operation
          and mount has missed, so nothing that already matched changes
          - a *splat still captures its trailing slash, and a mounted
          app is still handed the path it was sent, since only it knows
          whether /docs and /docs/ differ. Repeated slashes collapse,
          and "/" is left alone. A trimmed path that matches a
          different method still answers 405 with its Allow list.

0.14    2026-08-16
        - Requires from the C layer go through pk_require_once (an %INC
          check, then at most one eval per module per process) instead
          of a bare eval_pv per call. Perl's own eval_sv spends immortal
          references on every call on perls through 5.20 - whatever is
          evaluated, success or failure - and before 5.20 the immortals'
          refcounts are small and unprotected, so a path that
          re-requires per build (the markdown mount ran two) drains
          PL_sv_undef toward a segfault under punk dev's rebuild loop.
          Found by t/42-immortal-refcount.t on a CPAN smoker running
          perl 5.20.0; verified fixed on 5.20.3.
        - t/37-ua.t pins the per-request agent clones it compares by
          address: a freed clone's memory is the allocator's to hand
          straight back, so two DEAD clones can legally compare equal,
          which a smoker's malloc duly demonstrated. Held alive,
          distinct objects must differ.

0.13    2026-08-16
        - $c->send_file($path_or_scalarref, %opts): a finished download
          response
        - Static files inherit the send_file core
        - The punk command line rebuilt around a registry: commands are
          specs (abstract, options with docs and defaults, nested verbs,
          code), and help is generated from them 
        - Plugin subcommands: Punk::Command->register($name => \%spec)
          with owner-collision croaks; an unknown command gets one
          require of Punk::Command::<Ucfirst>, whose load registers it
        - In-process testing seam: $Punk::Command::OUT/$ERR replace the
          streams and main() returns the exit code.
        - routes: --method, --path GLOB, --kind, and --json.
        - dev: --workers, --watch (repeatable), --env; one watch loop.
        - console: a history file, --eval 'CODE', and q/exit.
        - New commands: `generate controller|model` into an existing
          application 

0.12    2026-08-15
        - Adds Punk::Auth. The `auth` keyword freezes the config at 
	  to_app (model, a fields map so existing schemas need no renames, 
          session_key, roles hook, rank ladder); croaks without a session, on any
          unknown option, and on a roles target typo - at boot.
        - Context surface: $c->login / logout / auth_id /
          current_user (memoized per request, model loaded through an
          await seam so the blocking DBI backend and the
          future-returning DBIx::Loop backend both work).
        - auth_guard for `under`: the bare is-anyone-signed-in form
          runs entirely in C. Denial negotiates - a browser is
          redirected to login_path with a relative-only ?to= return-to,
          an API client gets the house 401 - with on_denied '403',
          '404' or a coderef. The roles hook may return one role, a
          list or an arrayref; a required role on the rank ladder means
          "this or better", one outside it matches exactly (a staff
          role next to tenant roles). Guards record what they learned
          in $c->stash->{auth}.
        - Bare `model;` registers everything under MyApp::Model::*

0.11    2026-08-15
        - Security response headers (Punk::Headers): a `headers` keyword
          freezing a header policy at to_app, applied in C on the way
          out of the dispatcher.
        - Content negotiation ($c->respond_to): calls the handler for
          the most acceptable offered format (json/html/text/xml or a
          full media type) and returns its response. 

0.10    2026-08-15
        - Development error pages (Punk::DevError): in development a die
          renders an HTML debug page
        - The environment now defaults to production; development is an
          opt-in: punk dev sets PUNK_ENV=development for its server, or
          set it yourself in the Env.
        - Punk::Validate: request validation, all in C on the
          JSON::Schema::Fast C ABI. $c->validate($schema) collects into
          a Result - errors in the Open::API shape plus name, valid()
          hands back typed filtered params - and a bare $c->validate
          reads the Result a route-level check stashed. Routes take an
          options hashref: get '/x' => $target, { validate => \%schema }
          (or { schema, source, on_invalid }), compiled once at to_app
          and run as a C guard after any auth guards; failures answer
          the OpenAPI-mount-shaped 400 or the on_invalid target.
        - New on_not_found keyword: the on_error contract for a 404 so
          you can return a custom response
        - The getentropy probe now links instead of only compiling.
          FreeBSD before 12 has no getentropy and its old compilers take
          the implicit declaration as a warning, so 0.09's compile-only
          probe false-positived and the .so failed to load with
          "Undefined symbol getentropy".

0.09    2026-08-15
        - Adds Punk::Test, Punk::Test::WS and Punk::Test::WS::Conn
        - Flash messages, in C (punk_flash.h): $c->flash and
          $c->flash_keep over the session's reserved punk.flash key. 

0.08    2026-08-14
        - t/42-immortal-refcount.t failed the distribution on perl 5.18,
          where perl itself moves the refcount it measures. Before 5.20
          av_extend filled the slots it allocated with &PL_sv_undef and
          av_undef released every slot up to the fill.

0.07    2026-08-13
        - The router stored &PL_sv_yes bare in three set-membership hashes
        - New PUNK_SET_TRUE (include/punk/punk_compat.h) is the sanctioned
          way to put a true value in a set slot - it takes the reference
          first, so the container has one to spend, with no allocation.

0.06    2026-08-13
        - Rate limiting and IP blocking over Hyperman's shared arena
          (requires Hyperman 0.18, ABI v3; fails open without it).
        - New `rate_limit` keyword: installs a before_dispatch that answers
          429 (with Retry-After and the X-RateLimit-* headers) when a
          caller is over the limit. Key by client IP (default), a request
          header (by => 'header:X-Api-Key'), or a coderef; scope to a path
          prefix with `for`. Chainable for layered limits. The counters
          live in the shared arena, so a limit is exact across the worker
          pool rather than per worker.
        - New context methods: $c->block_ip([$ip [, $ttl]]) and
          $c->unblock_ip([$ip]) add or lift an IP on the edge denylist
          ($ip defaults to the request's REMOTE_ADDR), and $c->rate_hit
          for a raw counter check.

0.05    2026-08-12
        - Fixed a segfault on perl before 5.20. Six places handed one of
          perl's immortal SVs to something that would release it.
        - t/42-immortal-refcount.t asserts the invariant rather than the
          symptom: an immortal's refcount may not move across these
          calls. That fails on any perl, including the ones where the bug
          is invisible.
	- Update min version of deps

0.04    2026-08-08
        - Fixed the configure-time dependency declaration, which is why 
          0.03 could not be installed from CPAN at all.
        - $app->install_kw($name => $code, $owner?): a plugin can install
          a declaration keyword of its own into the application class 
          instead of assigning to a glob there.
         - params now takes names: `$c->params(@names)` and `$req->params(@names)`
           return just those, as a list of values in the order asked for, or - 
           in scalar context - a hashref of only the names that were passed.

0.03    2026-08-07
        - Punk::Model::DBIx::Loop, a non-blocking model backend. The same
          six-method contract, the same SQL and the same result shapes as
          Punk::Model::DBI, but every method returns a Punk::Future and the
          statement runs on DBIx::Loop over the worker's own event loop.
        - It runs on DBIx::Loop's C ABI (dbil_abi.h, via ExtUtils::Depends)
        - Fixed Punk::Model's delegation returning the caller's own
          arguments alongside the backend's result.
        - Punk::Model::DBI is ~40% faster per statement: quote_identifier
          and the fixed-shape get/delete SQL are memoised on the pooled
          connection instead of rebuilt every call. A get is 2.05us against
          3.61us. The table is part of the statement cache key - the cache
          is shared by every model on a dsn.
        - Add the `markdown` mount/keyword: point it at a nested directory of .md
          files and get a documentation site. Navigation reflecting the
          tree, a per-page table of contents from the headings, syntax
          highlighting, ranked search, and the images sitting alongside
          the markdown served as static files.
        - The site is built at boot. The tree is walked, every page is
          rendered through Markdown::Simple and wrapped by
          Template::Stencil, the search index is filled, and the finished
          bytes are frozen. A request is then a hash lookup and a triplet,
          with no markdown parse, no template render and no Perl frame.
          `reload => 1` re-renders a page whose source has changed, for
          the edit loop under `punk dev`.
        - Refactor the stat / conditional-request / sendfile half of
          punk_static.h out as ps_serve_file, so the markdown mount serves
          the assets in its tree through the same code rather than a
          second implementation that would drift from it.

0.02    2026-08-06
        - $c->ua: an outbound HTTP agent on the context
        - The agent is one per worker, not one per request: it owns a
          keep-alive pool and its DNS state.
        - More than one agent: `ua partner => { ... }` names a second, the
          same shape `database` uses for a second database, and
          $c->ua('partner') asks for it. 
        - Needs Fetch 0.11 for the per-request jar (Fetch->clone).

0.01    2026-08-06
        First version
