• v0.3.0 926881c357

    Postulo 0.3.0
    All checks were successful
    Dev image / image (push) Successful in 11m23s
    CI / test (3.14) (push) Successful in 6m17s
    Release / release (push) Successful in 19s
    CI / test (3.13) (push) Successful in 5m1s
    CI / test (3.12) (push) Successful in 5m3s
    CI / postgres (push) Successful in 3m50s
    CI / security (push) Successful in 1m42s
    CI / styles (push) Successful in 23s
    CI / browser (push) Successful in 11m56s
    Stable

    tiagoagueda released this 2026-09-16 17:22:26 +00:00 | 105 commits to main since this release

    🔒 Security

    • The capture limit now bounds captures, not the form. POSTULO_CAPTURE_RATE is the
      tightest limit Postulo ships — 30 an hour — because capture is the one thing that makes
      your server issue an outbound request to an address somebody else chose. It was applied
      only on the web form. The capture API makes the same fetch and was bounded by nothing but
      POSTULO_API_RATE, so the thing the tight limit exists for was available at twenty times
      the rate, and a token handed to something that misbehaves held the whole of that allowance
      on its own. The limit is now spent wherever the fetch happens, against the owner's account,
      so the form and the API share one ceiling and a second token is not a second allowance.

      A capture that arrives with its own html fetches nothing and is not counted: that is how
      a browser extension hands over a posting only a signed-in reader can see, and how forty
      from one results page arrive in one gesture. Those answer to the API rate, as they did. A
      capture refused here is a 429 carrying in detail the same sentence the form shows, and
      now a Retry-After header as well — what an API refuses is a program, and the useful answer
      to not yet is when. (#194)

    • Nothing captured from a stranger's page can act once it leaves Postulo. Three exports
      carried text somebody else wrote into a place that reads text as instructions. The report
      CSV exists to be handed to an employment office and opened in a spreadsheet, where a
      captured job title beginning = is a formula that runs on the clerk's machine; every CSV
      Postulo writes now passes each cell through one rule that says this is text. The
      calendar feeds put a contact's name in an ATTENDEE line, where a carriage return ended
      the line and let whatever followed be read as a property of its own — in every calendar
      subscribed to the feed, not only in the person's own. And addresses arriving through the
      API were stored unchecked and later drawn as links, so a javascript: company website
      ran as whoever opened the page; the API now refuses what the forms have always refused,
      naming the field rather than failing. (#218)

    • A browser notification is pushed to the open web only, says nothing the push service
      replied, and stops once a browser has withdrawn it.
      The notifier added in #209 used the
      client meant for connections somebody set up, so on an instance that allows private
      destinations the endpoint — which comes from a browser, not from anybody typing it — could
      have been an address on the operator's own network, and 200 characters of whatever answered
      were then shown on the connection and kept in its error. A push service belongs to the
      browser's maker and is public by definition, so it is fetched with the public-only client
      now, and a failure reports its status and nothing else.

      A subscription a browser has withdrawn (404 or 410) used to be retried on every event,
      failing every time. Plugins can now say that the other side has finished with a connection,
      with postulo.plugins.api.ConnectionUnusable: Postulo switches it off, shows the reason,
      and forgets the credential that is known not to work. Nothing else about the connection is
      touched, so allowing notifications again in that browser is one press. (#216)

    • Every outbound request now connects to the address it checked, and a logo is fetched from
      the open web only.
      The threat model has said since #112 that Postulo resolves a name,
      approves every address it answers with, and connects to one of those — one act, because
      two lookups leave a gap that a DNS record with a one-second lifetime can answer differently.
      Capture worked that way; the client every connected plugin uses did not. It checked, threw
      the answer away, and let httpx resolve the name again to open the socket.

      Fetching a company's logo was worse, because it used that client rather than the public-only
      one. On an instance where the operator had allowed private destinations — which is what
      self-hosters do to reach a Paperless on the LAN — a company website could answer the logo
      fetch with a redirect to http://192.168.1.50/…, and the picture it returned was stored and
      shown. A logo is public by definition, the same way a portfolio address is, so the operator's
      decision about connections was never an answer for it.

      robots_allow also opened a bare client when nobody passed it one. Nothing in Postulo calls
      it that way, but it is public in a plugin-facing module, and a way round the policy is worth
      closing before somebody finds it.

      Plugins that do not speak HTTP now have the same guard: postulo.plugins.api.approve_host
      resolves a name, holds every address to the instance's policy and hands back the one to dial,
      while TLS still proves the certificate against the name that was typed. check_destination
      and DestinationRefused are on the surface beside it, so a mailbox or a queue can be dialled
      as carefully as an HTTP request. (#215)

    • WeasyPrint 70, for CVE-2026-55073, and renderers that fetch nothing a document does not
      carry.
      The advisory, published on 9 September, is two write_pdf arguments —
      stylesheets and xmp_metadata — that ignored the document's URL fetcher and read local
      files or internal addresses into the PDF. Postulo passes neither, so it was not exposed
      that way, and both released versions are affected all the same: v0.2.0 and v0.2.1 lock
      WeasyPrint 69.0, and the dependency audit refuses them from that day. The requirement is
      now weasyprint>=70, so an installation cannot resolve to the old one either.

      Postulo had set no fetcher at all, so WeasyPrint's default applied — every protocol,
      redirects followed — and nothing came of it only because every template it ships inlines
      its CSS and embeds what it shows. That is now enforced rather than assumed, before a theme
      from a plugin puts markup nobody here wrote in front of the renderer: WeasyPrint may fetch
      data: addresses and nothing else, and Chromium refuses every request the page makes. A
      stylesheet, image or font a document points at by address is left out of the PDF instead
      of fetched.

      And the suite now draws real PDFs: every kind of document in every theme, the report, and
      a document set right to left. Until this it rendered every PDF through a stand-in, so an
      upgrade of the renderer — a new major version every release — was covered by nothing.

      Installing without the image? Add libharfbuzz-subset0 beside the Pango packages.
      WeasyPrint 70 warns at start-up without it and says a later version will require it; the
      image, CI and the install instructions all have it now. (#163)

    • Mail that Microsoft 365 and Google will still accept, before Microsoft stops accepting
      the old kind.
      Microsoft switches SMTP AUTH basic authentication off by default for
      existing Exchange Online tenants at the end of December 2026. On the code before this, an
      instance sending through Microsoft 365 would lose the ability to send password resets on
      that date, with no configuration that fixed it — which is people locked out, not a
      modernisation. XOAUTH2 now works on both halves of the mail plugin: the instance's own
      transport and a person's own outbox.

      It takes no dependency. XOAUTH2 is one SASL string carrying a username and a bearer
      token, and smtplib already speaks SASL; a provider's SDK on the path that delivers
      password resets would have been a large dependency and somebody else's HTTP client to
      solve a dozen lines. The subtle part is that smtplib base64-encodes what it is handed,
      and a string encoded twice is refused with a message that says nothing about why — so
      that is tested on the wire rather than asserted.

      Two grants for the instance, because two different people are consenting. Signed in
      once
      is an operator agreeing on the provider's page as the mailbox that sends, which
      needs no administrator of anything and works at both providers. The application sends on
      its own
      is Microsoft's client-credentials route, with nobody's session involved, which
      suits a server better but needs a tenant administrator. Google's equivalent is a
      different grant again, so the page refuses that pair before it is saved rather than
      letting it fail at the first password reset.

      A token can stop working on its own, and a password cannot. A provider may withdraw a
      grant when a password changes or a mailbox goes unused. When it does, the send fails with
      the provider's own words and is counted like any other mail failure — so the recovery lock
      that keeps email switched on while it is the only way back into an account stops counting
      email as that way once it has failed, instead of going on believing in a route that has
      quietly closed. A consent that comes back without a refresh token is refused on the spot,
      because it would work for an hour and then stop for good.

      The instance's consent comes back through the address a person's does, so an operator
      registers one address, not two; the two are told apart by a signing salt each, so neither
      can be passed off as the other, and only the administrator who started one can finish it.
      The client secret and the tokens are kept encrypted under the same key as every other
      secret, never in a column or a cache. Nothing removes the password: a relay of your
      own, Mailcow, Postfix and Gmail with an app password sign in exactly as they did, and an
      instance that never opens the Email page behaves exactly as it did. (#151)

    • The image is scanned now, by the project rather than by somebody remembering.
      Everything in the two issues beside this one was found by running Trivy and Grype by hand,
      on a machine that happened to have them pulled for something else. Neither finding was new
      and both had been in the image since it was built — which is the argument: pip-audit
      covers Postulo's own Python lock, and says nothing about the base image, the apt packages,
      what uv sync actually installed, or anything left behind in a layer.

      It runs before the push, because a gate after one is a report about something already
      published. And it runs the same scripts/scan-image.sh a person runs, rather than a second
      description of the same intent that can drift from the first.

      Two choices in how it is set up are what decide whether it survives. Both scanners, not
      one: on the image that started this they disagreed usefully, Grype finding the only
      actionable Debian update that Trivy did not mark fixable, Trivy finding Python packages and
      a leftover cache that Grype's scan did not see at all. And the gate is fixable findings
      rather than severe ones: six CRITICALs with no fix available is the ordinary state of a
      Debian base image, and a build that fails daily for reasons nobody can act on is switched
      off within a fortnight.

      The unfixable half is recorded rather than dropped, together with the secret and
      misconfiguration scans that came back clean — that half is what nobody writes down, and it
      is what says the image is in the state you think it is. Each run also keeps a CycloneDX
      bill of materials, which is worth more to somebody self-hosting Postulo than this run's
      verdict: it lets them scan the release next year, against a database that does not exist
      yet. (#156)

    • A telephone number now has a verified state, and on this release nothing is verified.
      That is the point rather than a shortfall. Numbers have been storable since they arrived,
      unique across the instance and first-come-first-served, with no way to prove one — which was
      fine while nothing depended on them. It stops being fine the moment a number can get somebody
      back into their account: every number typed before that day is a claim nobody checked, and
      whoever typed a stranger's number a month ago would be pre-positioned.

      So the migration adds a column and nothing else. No existing row is promoted. A test
      asserts the migration contains a single AddField, because granting verification
      retroactively is the kind of thing that looks like a convenience and is a way into somebody's
      account.

      Proving a number means sending a code to it, which needs a channel that can reach one, and
      Postulo has none. That is a strict order rather than a preference: no channel, no
      verification; no verification, no way back in. record_verified() exists and nothing calls it.

      Three rules are settled now so they are not settled under pressure later. A proof lapses
      after a year
      , because people give up numbers and carriers reissue them — an address is
      forever in a way a number is not, and a stale proof describes somebody who may no longer
      answer there. Editing the digits forgets the proof, dropped in save() rather than in a
      form, so a row written by the API or a shell cannot keep a verification that was never about
      the number now stored. And only numbers on the account's own profile are candidates: a
      recruiter's switchboard is a number this account owns and never one it is, which is a query
      and not a field because the holder is generic.

      An archive cannot carry a claim of verification in. The export writes the date, because it
      is the person's own record; the import reads it and throws it away, with a comment saying so,
      because a claim made by another instance is one this one never checked. FORMAT_VERSION is 6.

      Being told a number is already here is now rate-limited. The disclosure is unavoidable —
      instance-wide uniqueness cannot be enforced without it — and that was decided and written down
      when numbers arrived. What changes once a number identifies an account is the rate: the same
      honest sentence, asked five hundred times, is a list of which numbers have accounts here,
      which is the shape of email enumeration. Twenty an hour, and then Postulo says when the answer
      is available again rather than pretending the save failed for some other reason. Only the
      informative answer is charged for, so recording your own numbers never meets it.
      POSTULO_NUMBER_RATE sets it; empty switches it off. (#142)

    • A mail server is a host the server dials, and nothing checked where. Postulo already
      guards a URL somebody typed with three careful rules, and none of them were on the path a
      mail backend takes: Django opens a socket to a host and a port and asks nothing at all.
      That was fine while the host could only be the operator's own. It stops being fine the
      moment a person can type one, and the guard has to exist before the field does.

      All three rules now apply to SMTP. Private and loopback addresses are refused unless
      the operator has set POSTULO_CONNECTIONS_ALLOW_PRIVATE — the switch that already lets a
      connection reach a Paperless on the LAN, because a mail relay on the same LAN is the same
      case and one decision made once beats two. Every address the name answers with is
      checked
      , not the first, since a name resolving to one public and one private address is
      otherwise a way straight through. And the connection goes to the address that was
      checked
      — the rule people leave out, and the one that matters: a name with a one-second
      lifetime can answer publicly for the check and privately a moment later, so resolving again
      at connection time would pass on an address nobody ever contacted. The certificate is still
      proved against the name that was typed rather than against a number.

      POSTULO_EMAIL_HOST is exempt, and that is the project's ordinary environment-wins rule
      rather than a hole in this one: it is a line in a file only the operator can edit, and the
      default it carries is localhost. Checking it would refuse the default configuration of
      every instance that has never opened the Email page.

      The refusal is decided before anything is dialled. That is what keeps the Test button
      from being a port scanner with a form around it: connect and read the banner and you learn
      whether something is listening, so every private address gets an identical answer, because
      nothing ever finds out. A test asserts that four different private addresses produce one
      sentence between them — a different sentence for any of them would be a map of the network.

      docs/THREAT-MODEL.md gains the rule, and records that the encrypted-secret set now
      includes a mail password typed into the interface. (#148)

    • The image never took Debian's security updates, so a fixable High sat in it.
      libpcre2-8-0 had an update in bookworm that the image did not have. It arrives with
      Python rather than with anything Postulo installs, so no line in the Dockerfile would ever
      have touched it: the build installed what it needed and kept whatever the base image was
      built with, for as long as the base image went unrebuilt. It was the only genuinely
      actionable operating-system finding across two scanners, and it had been there since the
      image was first built.

      apt-get upgrade now runs at build time, in both apt layers — the second one matters,
      because an image built with POSTULO_EXTRA_PACKAGES refreshes the index again and must not
      end up quietly less patched than one built without plugins.

      This costs reproducibility and the trade is deliberate. Two builds of the same commit a
      week apart are no longer the same image. The alternative — pinning the base image by digest
      — buys that back, and makes Debian's updates arrive only when somebody remembers to bump
      the digest. A manual step nobody performs is precisely how this finding got here. Between an
      image that drifts towards being patched and one that reliably stays unpatched, Postulo takes
      the first; each release is published by digest, so the artefact anybody runs is still named
      exactly.

      Four tests read the Dockerfile and hold the ordering, because order is the whole of whether
      the upgrade does anything: before update it runs against the stale index that caused this,
      after install the packages just installed came from the old one, and in a RUN of its own
      it becomes a layer that is served from cache exactly when the index it needed had changed.
      One of them fails if the base image is ever pinned by digest without that decision being
      revisited.

      Most of what a scanner reports here has no fix and that is the normal state of Debian
      stable.
      So the documented gate is fixable findings rather than severity: a pipeline that
      fails on CRITICAL fails every day for reasons nobody can act on and is switched off within a
      fortnight. CONTRIBUTING.md records the two scanner invocations, why both are run, and why
      they disagree about severity; the pipeline that runs them is #156, which waits on a runner
      that can build an image at all. (#155)

    • POSTULO_SECRET_KEY=changeme started an instance perfectly well. The production
      settings refused to start with no key and said nothing about a bad one. Django notices —
      security.W009 is exactly this check — but the container runs check --deploy --fail-level ERROR and W009 is a warning, so it printed one line into a start-up log nobody reads
      while the instance served traffic. Not a deliberate exemption: the fail level was sitting
      one step above where the check was.

      It matters more here than in most Django applications, and that is the whole of why this is
      tier 1. That key does not only sign sessions and password-reset links: plugins/secrets.py
      derives the Fernet key protecting every stored connection credential from it — somebody's
      Telegram bot token, their Paperless password, their Nextcloud login — through a single
      unsalted SHA-256. A guessable secret key is a guessable encryption key for other people's
      passwords to other people's services, and guessing is cheap.

      So a key that is not one now stops the instance before the first request: shorter than 50
      characters, fewer than 5 distinct characters, or an obvious placeholder — changeme,
      secret, anything beginning django-insecure-. The two numbers are read out of Django
      rather than retyped, so the day it revises them this disagrees loudly instead of quietly
      enforcing the old ones. POSTULO_FIELD_KEY is checked the same way, which the issue did
      not ask for and is the same hole by a shorter path: whenever it is set, it is the key
      protecting those credentials.

      Every refusal says the thing that stops it causing a worse problem. The obvious
      response — generate a new key — signs everybody out and makes every stored credential
      unreadable, because the key that encrypted them is gone. The message says so, and says the
      order that keeps them: set POSTULO_FIELD_KEY to the current key first, then change the
      signing key underneath it. POSTULO_ALLOW_WEAK_SECRET_KEY=true starts a short key anyway,
      for the instance this catches at an hour when nobody wants to plan a rotation — it buys an
      afternoon, it is not an answer, and it does not open for a placeholder, because somebody who
      typed changeme has not chosen anything there is time to be bought for. (#111)

    • Nothing bounded how often an account could make the server fetch a URL, call the API, or
      scrape the log.
      None of it was reachable by a stranger — every surface needs an account or
      a token, and an audit confirmed the boundaries hold: capture refuses private addresses and
      revalidates on redirect, the API looks a token up by hash, /logs and /metrics compare
      their token with hmac.compare_digest. What was missing was a ceiling on somebody who had
      got past all that legitimately. Capture is the one that mattered: it is the only thing
      in Postulo that makes your server issue an outbound request to an address somebody else
      chose, and one account could ask for that as fast as the machine would go — which turns a
      self-hosted box into a modest scanner, or exhausts its own outbound connections.

      Three limits now, keyed on the account rather than the address, because an account is the
      thing being limited and sharing an office network should not mean sharing an allowance:
      POSTULO_CAPTURE_RATE (30/h, the tightest), POSTULO_API_RATE (600/h, per token, so
      one handed to something that misbehaves can be revoked without touching your own), and
      POSTULO_ENDPOINT_RATE (120/h, per address, since a shared token guards those and there is
      no account to count against). All raisable — three people and three hundred want different
      numbers — and any of them can be set empty to switch off. Anything unreadable also means no
      limit, deliberately: a mistyped rate should leave an operator with a working instance rather
      than a locked one.

      Written against the cache that already backs allauth's limits rather than by adding a
      dependency: it is thirty lines, and a rate limiter is not where a self-hosted application
      should acquire a supply chain. A refusal is a 429 carrying Retry-After, so a well-behaved
      client waits instead of hammering. The window is fixed rather than sliding and the count is
      not strictly atomic on the database cache — both stated in the module, and both erring
      towards letting somebody through, which is the right way round for a limit whose job is to
      stop a machine being ridden rather than to meter billing. (#112)

    • Django's admin is off unless you ask for it, and rate-limited when you do. ⚠️ This
      removes /admin/ from instances that have one.
      POSTULO_ADMIN_URL used to default to
      admin/, directly under a comment reading "the admin is a small attack surface worth
      moving off a guessable path"
      — so every instance whose operator had not read that line
      published Django's own username-and-password form at the first address anybody would try.
      It was found on the public test instance rather than read out of the source, which is the
      only way that kind of thing is ever found. The default is now empty and nothing is mounted:
      choosing to run the admin and choosing where it lives became one decision, made once, on
      purpose. To keep yours, set POSTULO_ADMIN_URL to a path of your own and restart.

      Off by default rather than merely moved, because Postulo's own Server settings already
      covers people, sign-in policy, plugins, email, logs and defaults. What is left is a
      developer's convenience, and on a self-hosted box mostly a second and less careful way into
      the same data.

      And throttled when it is mounted. allauth's rate limits are good ones and Postulo
      inherits them, but they apply to allauth's views; django.contrib.admin has a login view
      of its own and nothing was counting attempts against it. So the one credential form on the
      instance with no attempt limiting was the one that reaches every table directly. It now
      uses allauth's own limiter, cache and numbers — 10/m/ip, 5/300s/key, the same as a failed
      sign-in — configured as one more key in ACCOUNT_RATE_LIMITS rather than a second scheme
      that can drift from the first. A GET still costs nothing: reading the form is not an
      attempt, guessing is.

      Two smaller things went with it. A path written without its trailing slash used to produce
      a URL nobody could reach and no error saying why; it is tidied now. And Server settings →
      Overview
      , which linked to the admin as "the escape hatch", says plainly that there is not
      one and which variable turns it on, rather than linking to a 404. (#116)

    ✨ Added

    • Notifications in the browser, with nothing to set up on the server. The only notifier
      Postulo shipped was Email, which needs working mail, and everything else meant installing
      Apprise and learning its URLs. An instance with no mail relay — which is where a self-hoster
      often starts — could not tell anybody that a reminder had fallen due. The new built-in
      Browser notifier needs a browser that allows it and nothing else: add it under
      Settings → Connections and press Allow notifications in this browser.

      It arrives the way the person chooses. Pushed, it reaches the browser with no Postulo
      tab open, through the push service the browser's own maker runs; the message is encrypted
      for that one browser (RFC 8291, checked against the RFC's worked example byte for byte), so
      the service carries it without being able to read it. Only while Postulo is open, it
      waits on the instance and the next open tab shows it, and nothing leaves the instance. The
      second is also the fallback, so a push that fails — or a browser that withdrew its
      subscription — leaves the notification for a tab rather than losing it, and the connection
      still says what went wrong.

      No new dependency and no new key to keep: the encryption and the VAPID signature use the
      cryptography Postulo already has, the push goes through the client that enforces where a
      connection may dial, and the signing key is derived from the material that already encrypts
      connection secrets, so both change together or not at all. Plugins gain an optional
      form_attributes(), which is how this one hands the connection page its public key without
      a template that knows it by name. (#209)

    • A Calendar page beside Reminders. Everything dated was a list, and the lists were in
      different places: interviews soonest first on one page, reminders on another, and "what
      is on this week" meant opening both and reading dates. Calendar draws the month —
      interviews and reminders by day, each a link to its application — with Week, Day and
      Agenda as the same things in another shape, the agenda being what a phone wants. It is
      a table the server renders: no script, the period in the address so a month is
      bookmarkable, Earlier and Later as links. A cell shows two of its events and counts
      the rest, and the day opens on all of them. A reminder done and an interview cancelled
      are drawn struck through rather than dropped. Days are the person's own, in their time
      zone. What else goes on it — deadlines, listings closing, appointments at the employment
      office — is decided separately. (#204)

    • The employment service is a kind of company. France Travail, IEFP, the Bundesagentur
      für Arbeit, SEPE, Jobcentre Plus: the office a job seeker is registered with is dealt with
      throughout a search — an adviser, appointments, the report — and it is not an employer,
      yet the only way to record it was as one, where it was then counted as one. A company
      has a kind now, employer or employment service, and the company form offers the
      services Postulo knows by country: pick yours and leave the name blank, and the name and
      website are filled in; what you type wins. The office is left out of the report's
      tallies by industry, the report is addressed to it — For France Travail, adviser
      so-and-so
      — on the page and on the PDF, and Employment service is a new answer to
      applied through, counted separately on the report because that is the number the
      office asks first. The companies page marks the office beside its name and can narrow
      by kind. The export carries the kind (format 16); an older archive restores as
      employers, which is what every company recorded before there were kinds is. (#202)

    • A report you download is filed, and an email is not a kind. The two document kinds
      #133 deferred are decided. A job-search report was already composed, rendered and dated;
      pressing Download PDF now also files it under Sent documents as a report — the text
      it was built from, a checksum, a store copying it like a CV — because a report handed to
      an employment office is exactly the document somebody wants kept. It is frozen at that
      press and never on a page view: pressing twice on one day files one document, and
      opening the address directly hands back the same PDF and records nothing. An email is
      deliberately not a fifth kind: a message a transport carries is not a document, a kind
      that composed mail would sit behind the lock on the last transport, and what is worth
      keeping of one is what a letter's snapshot already keeps. (#162)

    • A plugin repository can make its catalogues, not only compile them. Every official
      plugin carried the same copy-pasted compiler and two catalogues made by hand, and no way
      to make the other sixty-six at all — so "in step with core" was not a matter of filling
      in translations; there was nothing to fill in. Postulo's own catalogue tool is a module
      now, postulo.core.messages_tool, installed as postulo-messages wherever Postulo is a
      dependency, and pointed at whichever repository it is run from: extract, extract --check, check and compile against a plugin's own src/<package>/locale/, creating
      the same sixty-eight slots core has. scripts/messages.py is the same tool pointed at
      this repository, and behaves exactly as before. (#187)

    • A plugin that says which Postulo it is for is held to it. A catalogue release has
      always carried requires_postulo, and the installer parsed it, stored it, and compared
      it to nothing: a plugin declaring >=0.5 installed into 0.3 and the first anybody knew
      was an ImportError in a log. It is the third fatal check now, before the download, and
      the refusal names both versions so it is clear which side has to move. What was
      declared is recorded with the plugin and asked again on every visit to Server →
      Plugins
      , so a core upgrade that leaves a plugin behind is marked for Postulo … there,
      and a catalogue listing that does not fit shows why instead of an Install button that
      could only refuse. A plugin that declares nothing keeps installing. And the rule for the
      project's own plugins is written down: they carry the version of the Postulo they were
      released beside, and the installer holds them to the major. (#186)

    • The companies table narrows on every column, and from a phone. What was asked for
      on this table was filtering and sorting in the table itself, and #135 built editing in
      place instead. The rest is finished now. The three counts take a least and a most —
      companies I have applied to more than once — which is a new kind of filter shared by
      every table; Last activity and Added take the from-and-to pair the applications
      table already had, narrowing by the day a moment falls on so that to the 13th includes
      the 13th; website and careers page sort and narrow; and the identifier columns, which
      could do neither, sort and narrow like any other, because each is a column of the query
      now rather than a lookup per row. Notes and industries stay unsorted, each with its
      reason written beside it. And below the md breakpoint, where the row of inputs in the
      table head is hidden because a row of inputs inside a table at 390 pixels is not a
      control, a Narrow fold under the search box offers the same filters — on every table,
      not just this one. (#173)

    • Forty captures, and not forty page loads. Reviewing what a board's search page
      produced meant opening each capture, deciding, and landing back on the list — a full
      page each — when most of the forty get the same answer in under a second. The review
      page moves on by itself now: Save and next and Discard and next beside the plain
      buttons, a Skip to the next, and a line saying how many are still waiting. Three keys
      do what those buttons do, through the buttons — d discards and moves on,
      j skips, Ctrl+Enter saves and moves on — and never while
      something is being typed. On the Listings page the captures block has a checkbox per
      capture and one Discard the ticked ones, which is most of triage with no form at all.
      And a currency or period the page never stated now says so beside the box, so a default
      is visibly a default rather than looking like something that was read. (#179)

    • Your details is one record, with its parts down the side. The page held three cards
      and a blurb sending you to Settings for your addresses — which had not been true for a
      while: the telephone numbers, the postal addresses, the identifiers and now the links all
      live on this page, in one long form. What was missing was any way to see that from the
      top. The sidebar Settings and Your career use now stands beside the parts as anchors, a
      count beside each list, sticky on a wide screen and a strip that scrolls sideways on a
      phone. A part a feature has switched off is not on the page and so not in the nav, and
      the blurb says what the page is: what Postulo knows about you as a candidate, as opposed
      to how it behaves for you, which is Settings. (#180)

    • Several social profiles, several code repositories, several websites — three plugins,
      one table.
      A person's presence online was four single boxes: a website, a LinkedIn and
      a code repository on your details, and a LinkedIn on each contact. Somebody with a GitHub
      and a Codeberg, or a personal Mastodon beside a professional LinkedIn, put the second in
      the notes or nowhere. The four columns are rows of one table now — a kind, an address, a
      name to call it by, and which one of each kind is the primary — attached to your profile
      and to every contact, exactly as telephone numbers and postal addresses are. Three feature
      plugins govern it, one per kind, because they are three decisions: an administrator
      content to let people list every forge they publish on may still want one LinkedIn and
      nothing else on a CV header. Each ships switched on; switched off, Postulo shows and uses
      the primary of that kind only, which is exactly the one box it had before, and tells you
      how many others it is keeping back. Nothing is deleted on the way out, an export carries
      every row regardless, and an archive from before this reads back in with its columns as
      rows. The kind names the sort of thing rather than the host — social profile, not
      LinkedIn — so the next network is not a release. A CV prints the primary of each kind
      where it always did; the company page lists a contact's links by the same rule; the API
      keeps linkedin_url where it was, as the primary social profile, with web_links beside
      it. Postulo never opens a link on its own — not to check it, not for a picture. (#189)

    • Capturing an advert you already hold says so, before it is made. Nothing checked
      whether a posting had been captured before: the same advert on Monday and again on
      Thursday made two captures and, reviewed, two listings for one job. Told now, not
      refused — a constraint would be wrong, since boards reuse addresses and an edited advert
      is worth capturing again. The web form says this address is already in your listings,
      since 3 September
      , with a link, before anything is fetched, and pressing the button again
      captures it anyway; the review screen says it too, and more softly names a listing with
      the same title at the same company, which is how a board that mints a fresh address per
      visit hides a duplicate. The browser extension asks POST /api/v1/captures/known — one
      request for one posting or forty — and says captured before in the popup. Addresses are
      matched as #176 matches them: without the scheme, the www. or a trailing slash. (#178)

    • Forty postings from one results page, announced once. The browser extension can now
      read every posting a page lists and send the ones you tick, each as its own capture from
      the same page, so a failure loses one and not forty. On this side that needed one thing:
      POST /api/v1/captures takes an optional batch — how many are coming and which this one
      is — and announces the first with the count, Captured 40 postings from example.org,
      rather than notifying forty times for one deliberate gesture. A capture read off a results
      page is a stub — title, company, place — which is what a listing to triage needs. (#177)

    • Your career lists its sections down the side, with a count beside each. Seven sections
      down one page, each a list with no ceiling, and nothing on the page said what was below the
      fold: the only way to learn it held Languages was to reach the bottom. The sidebar
      Settings uses now stands beside the sections as anchors — Experience 12, Languages 0 —
      sticky on a wide screen so it is there wherever you have scrolled to, a strip that scrolls
      sideways on a phone. A section with nothing in it is listed all the same; that is how you
      find out the page can hold it. The entry for the section on the screen is marked as you
      read, and the list is one template shared with Settings, so the two cannot drift apart.
      (#175)

    • A dev channel: every push to main publishes an image, so a change can be run before it
      is released.
      dev-image.yml builds, scans and pushes :dev alongside a pinnable
      :<version>-dev.<short sha> — quote the pinned one in a bug report, because :dev moves
      and says nothing about what somebody was running. It is not a release: unsupported, and
      free to change a database in ways a release will not.

      :latest is never touched, which is the whole reason this is a separate workflow
      rather than a second trigger on image.yml. That one exists to build a release: it
      checks out a tag, because "a release image built from a moving branch is an image nobody
      can reproduce", and it moves :latest. This one is the opposite in both respects, and the
      release path is worth leaving alone.

      The scan gate applies. A dev image somebody runs against their own applications is an
      image, and #155 and #157 were both found in one built and published without a scan. It
      calls the same scripts/scan-image.sh a person and a release both call. Built for the
      native architecture only: the release does linux/arm64 through QEMU, which is slow and
      one more thing to fail, and anybody needing another architecture wants a release.

      Why this may run on a push when the release image may not. image.yml is
      workflow_dispatch only because runs-on: docker reaches a daemon that is root on the
      host, and nothing but "nobody pressed the button" stopped a job getting there. That is no
      longer what protects it: the runner answering the docker label is a second runner
      instance registered to this repository alone
      , so Forgejo will not schedule another
      repository's jobs onto it — enforced by the instance rather than by every workflow author
      remembering. The workflow says the same thing twice, in the trigger and in an if on the
      branch, because a branch filter is one edit away from being wider than somebody meant.

      The first run found a fault in the release path too, and fixed it. Both workflows
      built the registry address as ${GITHUB_SERVER_URL#https://}. GITHUB_SERVER_URL is the
      address the runner reaches Forgejo at, which on a compose deployment is the internal
      http://server:3000 — so the strip left the scheme on, and the daemon that pushes runs on
      the host and cannot resolve a compose name anyway. image.yml has carried the same line
      since it was written and has never run, so it had never shown it. Both now read a
      REGISTRY_HOST repository variable and fall back to the server URL with either scheme
      stripped.

      And a second one: the shell scripts were not executable. scripts/scan-image.sh and
      scripts/check-image.sh were recorded as 100644, so ./scripts/scan-image.sh — which
      is how CONTRIBUTING.md tells a person to run it, and how both image workflows call it —
      failed with Permission denied on any Linux checkout. They were written on a machine with
      core.fileMode false, so the bit was never recorded and nothing that ran them had ever
      run from a fresh clone.

      And a third: the pinned Trivy did not exist. scripts/scan-image.sh pinned
      aquasec/trivy:0.68.0, and there is no such tag on Docker Hub — there never was. The
      findings behind #155 and #157 came from running the scanners by hand on a machine that
      already had Trivy pulled, so the pin was never the thing that fetched it. Corrected to
      0.74.0, with the one-line check for whether a tag exists written beside it. Grype's pin
      is real and eighteen versions behind; left alone on purpose, since a newer scanner is a
      gate that starts failing for reasons unrelated to the change being made.

      And a fourth, the subtlest: the scan reports went to the host. scan-image.sh gave
      the scanner containers -v "$OUT:/out". A bind mount is resolved by the daemon,
      against the host's filesystem — and this script now runs inside a container with the
      socket mounted in, so the scanner wrote its reports into a directory on the host that the
      caller could not see, and every cat after it failed. It worked on a laptop, where the
      shell and the daemon share a filesystem. Trivy writes to stdout now, which grype already
      did, and both mounts are gone.

      The first scan then found something real, and it is gone. The only fixable High in
      the image was msgpack 1.1.2 — pip's vendored copy, arriving with
      python:3.14-slim-bookworm. Not in uv.lock, not in the virtual environment, not
      importable by Postulo, so no dependency bump reached it. pip is removed from the
      runtime image
      : nothing there needs it, because plugins/installing.py prefers uv,
      which the image already carries deliberately. The pip branch of installer() stays — it
      is the right answer for an ordinary pip install postulo on somebody's server — and it
      now says plainly what is wrong if it ever finds neither, instead of producing
      No module named pip from a subprocess.

      The dev image builds for arm64 as well, reversing the native-only decision above for
      a concrete reason: the instance these images are run on is a Raspberry Pi, and an
      amd64-only dev image is one nobody can deploy. QEMU binfmt is registered on the runner, so
      the second architecture costs time and nothing else.

      And a fifth: the image name carried a capital. GITHUB_REPOSITORY is
      Postulo/postulo on this instance, and a Docker repository name may not have one, so the
      tag was refused outright — after a full multi-architecture build. Lowercased in both
      workflows.

      Five faults, none of them in this workflow, all of them in the release path, and all
      found by the simple act of running it. That is what a channel nobody had ever exercised
      was hiding — and two of them needed only a file to be read, not run, so
      tests/test_shell_scripts.py now reads every tracked shell script: it parses under
      bash -n, git records it executable, and it starts with a shebang.

      And old dev images are pruned. Every push adds a pinned tag, and nothing ever took one
      away. The job now ends by keeping the newest five and removing the rest — together with
      the per-architecture manifests only they referenced, because a manifest nothing names
      still holds its layers — and nothing else: releases, latest, dev, and anything
      untagged the run did not itself orphan, such as a push in flight from another workflow,
      are never candidates. scripts/prune-dev-images.py does it, and does it by hand with
      --dry-run.

      CONTRIBUTING.md § Giving a runner the docker label said to declare docker:host and
      was wrong for a containerised runner: host runs the job inside the runner container,
      which is Alpine with no node and no docker CLI, so actions/checkout fails before
      anything reaches the daemon. It now describes what actually works — a container label on
      an image that already carries the tools, docker_host: automount, and a second runner
      scoped to one repository, because container.docker_host is per runner instance and
      cannot be scoped to a label. (#190)

    • The sources read the posting the page is showing, in whichever way the board wrote it.
      Four faults, all found by reading real adverts rather than hand-written objects, and none
      of them catchable by any test that existed. A results page carries a JobPosting for every
      hit and the first was taken, so a capture from one brought back whichever advert the board
      listed first; the one naming the page's own address now wins. A posting hung off an
      ItemList, which is how several large aggregators publish, was invisible — only @graph
      was followed. Microdata and RDFa are read, so the older recruitment systems and
      public-sector boards that publish itemprop markup and no script stop falling through to
      the fallback and coming back with the page's <title> as the job title; it is the same
      schema.org vocabulary in the spellings the standard also defines, so it is the same source
      reading it rather than a second one. And fields were read from one place where boards write
      them in two: the office named on hiringOrganization.address rather than jobLocation was
      lost outright, estimatedSalary was never read, applicantLocationRequirements — which
      schema.org defines as where an applicant may be for a job done remotely — never counted
      as remote, and a currency was kept with no amount behind it, so captures arrived saying
      "USD, per year" and nothing else because boards write a 0–0 placeholder into every advert.

      For the boards that publish nothing a standard can read, a recipe. LinkedIn embeds no
      JSON-LD and no microdata and marks the company and the place with class names only, and
      Greenhouse — which hosts a very large share of what people apply through — publishes
      nothing either. plugins/builtin/boards/ holds one module per board behind a source that
      runs first and lets the standards fill whatever it left empty, so a board that starts
      publishing JSON-LD improves without its recipe being touched and a recipe that rots because
      a board redesigned costs the fields it used to fill rather than the capture. A recipe states
      only what it is sure of: LinkedIn shows its date as "2 months ago", relative and in the
      reader's language, so no date is stated at all. Its employment type is read the way this
      sort of thing should be — every value in the criteria list is offered to Postulo's own
      vocabulary and the one it recognises wins, so "Full-time" is read, "Mid-Senior level" is
      not, and a page in a language whose words Postulo does not know leaves the field for a
      person rather than guessing.

      Two fixes came out of the same page and help every board. Where a page gives exactly one
      <h1>
      outside its furniture, that is a better job title than the one it declares for
      sharing — LinkedIn's is "company hiring job in place | LinkedIn". And link density
      reaches what no landmark rule can: "similar searches" and "people also viewed" are plain
      <section>s marked with a class name and nothing else, and a block whose text is
      overwhelmingly inside a lot of links is a list of links whatever tag it uses.

      All of it is held to whole pages now, not hand-written objects. tests/fixtures/postings/
      carries eight, each with the expected reading beside it and a note saying what that board
      does differently; every quirk in them was taken from a live page. The browser extension
      carries the same eight
      and is held to the same values, because a page read in somebody's
      browser has to come out identical to that page read here when it is sent — and every field
      of all eight was checked between the two, and against live LinkedIn, Greenhouse and We Work
      Remotely pages. To make that comparable, htmlutil assembles the tag stream into a small
      tree before walking it: still the standard library's parser, still no C extension, but
      microdata nesting and "is this paragraph inside the navigation" are questions about nesting
      that a stream cannot answer without guessing differently from the DOM the extension walks.
      (#176)

    • A capture can be looked at before it is sent, and corrected on the way. The browser
      extension used to send a page and hope: POST /captures read and stored it in one go, so
      the first anybody saw of what the parser made of it was the review screen, a tab and a
      sign-in away. POST /captures/preview answers with what a page would be captured as, and
      which source read it, and stores nothing and tells nobody; the extension shows that in its
      popup. POST /captures takes the fields the person changed as data: the page is still
      read, so the capture's source stays the one that read it, and each field given replaces
      what was read before the whole is checked exactly as a source's own output is. An unknown
      field or an empty title is a 422.

      What a captures token may do has not moved. A corrected capture is still pending,
      and the review screen opens with the corrections filled in: corrected is not reviewed, and
      a listing still exists only once somebody has saved it there. (#171)

    • A portfolio is a document Postulo makes, and what kinds of document exist is said in one
      place.
      The first of the three stages the issue itself proposes — portfolios need the
      polymorphic link and the theme rule and nothing else
      — and both of those shipped already:
      a render points at whatever made it and a copy at whatever it copied (#130), and a theme
      declares which kinds it can set (#132).

      A portfolio is a CV with a different shape, not a second model. It is a selection
      from the career record with its own layout, and it uses every line of the same machinery —
      the same entries, the same tailoring, the same included switch. Two near-identical models
      would have been two forms, two lists, two exporters and two of every future change. The
      precedent was already here: a letter has carried four shapes told apart by a field since
      it arrived. And portfolios of different kinds — a developer's, a designer's, a
      researcher's — is answered by the theme and by what is selected, which are the two things
      they actually differ in and are already the person's to choose.

      The kind vocabulary was doing two jobs and now derives from one. It labelled an
      uploaded file and a rendered one, with a letter mapping its own shapes onto it by hand —
      two vocabularies with no relation, waiting to disagree. Everything said about a kind now
      comes from one registry, and the pickers read it through a callable, so a kind a plugin
      registers reaches every menu and every store's per-kind switch without a migration. That
      is what makes "a kind is a plugin" something other than a phrase.

      And a store stopped asking what it was holding. The last two places that checked
      render-or-upload now ask the document itself, so a third thing that holds a file needs no
      branch — proven by a test that describes a class the store code has never heard of.

      Reports and emails are deliberately not here; #162 records what each still needs settled
      and why guessing at it now would be designing against nothing. (#133)

    • An employer can be a structure, and an application can say which part of it the attempt
      was for.
      The last of three: a company inside a company shipped with #55, a department
      inside a company with #137, and this is the attachment, the switch, and the question a
      tree makes every count answer.

      "Attached to any of the four" turns out to be three fields, not four. A posting's
      company is the company applied to, whatever height of the tree it sits at, so parent
      company
      and child company are the same column holding different rows; a contact was
      already a direct optional link. Only the department was missing.

      The shape it was given is the decision the issue turned on, and the plugin toggle
      settled it. Naming a department enriches the posting's company rather than replacing it,
      so that column stays required and every application has exactly one employer however this
      is set. Had the employer link itself been made polymorphic — a posting pointing at a
      company, a department or a person — then switching the feature off would leave applications
      attached to departments with no company to fall back to, which is a toggle that breaks a
      page rather than a toggle. A department anywhere in the employer's group may be named,
      because an application through the Irish arm can be for the group's engineering team;
      anywhere else is refused in a sentence naming the employer it is actually at.

      Every count now says which reading it is showing. A company page counts that company,
      exactly as it always did, and offers across the group beside the heading where there is a
      group — in the address, so a page counting a whole group can be bookmarked and sent.
      Changing what a number means without being asked would be the other half of the same
      mistake a hierarchy is meant to fix, so the default did not move. The companies table gains
      a Part of column whose name narrows the table to that whole ownership tree,
      grandchildren included.

      All of it is one feature plugin, Employers with a structure, on by default so an
      upgrade takes nothing away. Off is exactly what Postulo did before any of this existed —
      one company per posting and nothing else — and deletes nothing: the parents, the
      departments and the attachments stay recorded and come back untouched. (#138)

    • A report about a period, to hand to an employment office or to read yourself. Two
      different people ask for this and they want the same document. Unemployment benefit in most
      of Europe is conditional on actually looking, evidenced — and until now the only way to hand
      those facts over was to copy them out by hand, which is exactly the work that makes people
      stop keeping records at all. The other reader is the person searching: a month of looking
      feels like nothing happened, and eleven applications across four weeks, none in the week of
      the 12th
      is the difference between a feeling and a fact.

      Regularity is what it is named for, so the cadence is the top of the page — a bar per
      week, the empty weeks saying none in words rather than being an invisible gap, the average
      per week, the longest gap and the run of weeks up to now. Underneath is the evidence: every
      application sent in the period, with where it was found, the address of the posting, its
      status and the date of the last thing that happened.

      Two questions, kept apart rather than added together. Sent in this period reads
      applications by when they went out. What came back reads the event log by when the event
      happened, so a reply arriving in September to an August application is September activity —
      which it is. Folding the two together would give a figure that is true of neither.

      The period is in the address, which makes a report for a particular month a thing to
      bookmark and to send to somebody: the same line this project already draws between a
      question and a preference. There is no link into the future, because a report about next
      month is a blank page pretending to be a document.

      Out as a PDF carrying the person's name, the period and the day it was produced —
      because a document with no date is not evidence of anything — and as CSV for anybody who
      wants to do their own sums. Nothing is stored: a report is computed when it is asked for,
      from records that are already the truth, which is what makes it a snapshot of the record at
      a moment.

      Postulo sets no target anywhere on the page, and says so on it. How many applications a
      period should hold is a benefit regime's rule or the person's own, never this software's.
      Drafts never sent are left out, and the page says how many, so the absence is stated rather
      than silent. (#56)

    • An address is now checked — and named — by the rules of its own country. Which parts
      are usually needed, what a postcode there looks like, what each field is called, and what
      order the whole thing prints in.

      Naming the fields turned out to be the harder half, and it is not a translation problem.
      Somebody reading Postulo in Portuguese who enters a United States address should see
      Estado; entering a Portuguese one, Distrito; a Japanese one, Prefeitura. The label
      depends on the address's country and the language depends on the reader, and both
      vary at once — nothing else here has that shape, because every other string is chosen by
      the reader's language alone. So the table names a key and the catalogue supplies the word.

      Nothing refuses. Every rule is a note. BFPO addresses, rural routes, informal
      settlements, temporary accommodation, and a table that is simply wrong about somewhere: an
      address that fits no rule is saved exactly as typed, because an application that will not
      accept your address is telling you something about who it was written for. Ireland expects
      no Eircode, because Eircode arrived in 2015 and plenty of addresses predate it.

      The rules are a curated table, and what was ruled out is written down. Google's
      libaddressinput is the set everybody reaches for, and a project that exists as an
      alternative to services answering to somebody else's jurisdiction does not put that
      jurisdiction in its address form — recorded in the module so nobody proposes it again. What
      is left is the shape this codebase already uses for the country list, the language names and
      the plural rules: rows decided rather than derived, with the reasoning per entry, no
      third-party licence and no update pipeline. It records when it was last reviewed.

      A country with no row gets no rules — free-form entry, neutral labels, and the parts printed
      in the order they were entered — which is also exactly what switching the feature off does.
      Off is the same code path rather than a second one nobody has seen. (#147)

    • Postal addresses have somewhere to go. Several per account, exactly one primary, on a
      person and on a contact — with the invariant in a database constraint rather than in
      whichever form saved last. Profile.location keeps working and keeps its meaning; this is
      for the parts of an address that had nowhere to be.

      An address is not unique across the instance, and that is the point rather than an
      omission.
      A telephone number belongs to one person; a home does not. Spouses share one,
      flatmates share one, an adult child at home shares one, and two siblings on a family
      instance share one — and a family instance is exactly the kind of small self-hosted
      deployment this project is built for. A uniqueness constraint would refuse the second
      member of a household their own address and disclose, in refusing it, that somebody else
      on this server lives there. So: unique per owner, so nobody lists their own home twice,
      and freely shared between accounts. The page says so, because the numbers beside it make
      the opposite trade.

      Valid cannot mean verified. Deciding whether an address exists needs a per-country
      reference database or a paid lookup — a network dependency, a cost and a stream of updates
      — and Postulo has no use for the answer, because it is not going to post anything. An
      address typed oddly is saved exactly as typed; only the comparison folds case and spacing.
      Nothing here has a verification and nothing here can become a way back into an account.

      Postulo was throwing real addresses away, and has stopped. A Europass file carries a
      street and a postcode; the importer kept the town and the country and discarded the two
      lines that make an address an address. Somebody exported their CV, imported it here, and
      they were silently gone. They land now — filling blanks only, never arguing with an
      address somebody typed.

      The archive carries addresses (format 9), an import brings them back, and two people can
      import the same one. The CV header still shows a town and a country and never a street:
      guidance across most of Europe is that a precise address invites a reader to draw
      conclusions about somebody from where they live, and putting one on a document sent to
      strangers is not a default anybody chose. docs/THREAT-MODEL.md now says a home address is
      the sharpest thing this application holds. (#92)

    • Several email addresses is a feature you can switch off — and it governs the page, not
      the addresses.
      Everything the request asked for already worked: allauth gives an account
      several addresses, exactly one primary, each verified independently, and that is the shape
      telephone numbers were built to copy. What was missing was the framing, and framing it
      raised a better question than it answered.

      A feature may govern a page it does not own the data behind. The model belongs to a
      library, with its migrations behind it and its own flows reading it — verification,
      password reset, signing in by address, linking a social account — and a plugin that cannot
      verify an address cannot honestly own one. So off means Postulo offers the primary
      address and stops offering the management page, and it deletes nothing because it owns
      nothing. That is the same thing phone-numbers does: a feature governs what Postulo offers
      and uses, and neither of them destroys anything to do it.

      What off could have cost is the reason there is a floor under it. Somebody keeps a
      second address because the first is a work account they are about to lose; hiding the page
      does not remove that address, but it removes their ability to promote it on the day they
      need to — a lock-out arriving through a setting nobody thought was about getting back in.
      So the page is never withheld from an account that already has more than one address, or
      from one whose only address is unverified, whatever the policy says.

      Worth saying plainly: with those floors, the visible change is a link disappearing. (#145)

    • Mail has two halves now: the instance's and yours. The instance's was already there —
      the SMTP transport, ungoverned on purpose, configured under Server settings → Email, and
      refusing to be switched off while it is the last way anybody could get back in. The other
      half is Sending as yourself: your own server, your own address, switchable by you or by
      an administrator, under Settings → Connections.

      It is a new kind of plugin rather than a second transport, and that is the whole point.
      Getting back into an account reads transports. An outbox is not one, so a person switching
      their own mail off cannot thereby remove their own way back in — the failure that made
      transports ungoverned in the first place, arriving through a door nobody had locked. By
      construction rather than by anybody remembering, and asserted.

      Sending as somebody is the half that needs settings of its own. Putting your address on
      a message that leaves the instance's server is spoofing: SPF says that server is not
      authorised for your domain and a receiving server bounces it or bins it. So it goes over
      your server with your address on it — your domain, your reputation, and replies and bounces
      coming back to you, which is right. Postulo never rewrites the sender: a message whose
      From was quietly replaced is a message that looks forged, so a mismatch is refused
      instead. The same destination guard as the instance's own mail, and a per-account limit,
      because this is the one surface where a mistake reaches strangers.

      Switching it off means you cannot send from your own address here — not that you cannot
      be notified. Everything the instance sends on its own account is untouched. (#149)

    • Server settings → Plugins now lists what the instance can actually do, and says where
      each part of it came from.
      It used to list what an administrator had installed — so the
      two built-in capture sources, which are classes in the image and never pass through the
      installer, were missing from the page somebody would look at to answer "can this instance
      read a posting off a page". They are there now, marked internal, with no button that could
      not work: a built-in has no line in the record for Remove or Switch off to act on, and is
      governed for people through the policy rows above instead.

      The hard half is labelling an upload, and it comes down to one sentence: a zip is a zip.
      A file somebody uploads carries no evidence of who published it, and calling it official
      because it is named after an official plugin would be worse than not labelling it at all.
      So the label describes evidence: a signed index publishes each release's SHA-256, and a
      wheel whose bytes match one is byte-for-byte the file that repository signed, however it
      reached the instance. One byte of difference and it is not. When no repository can be
      reached — switched off, unreachable, an index that will not verify — the answer is
      uploaded rather than a guess.

      Which repository counts as official is decided by a key, never by a name, because
      anybody can call their repository postulo and nobody else can sign with Postulo's key.
      Postulo publishes no catalogue yet, so nothing is official today and every installed plugin
      is custom or uploaded — which is the truthful thing for the page to say, and it changes by
      adding a key rather than by writing this again.

      The badge does not quietly mean "safe". Installing a plugin runs somebody else's code
      inside Postulo, and that is as true of an official one; the page still says so. Nor does it
      cover dependencies: the signature and the checksum are about the plugin's own wheel, and its
      requirements come from PyPI at install time. Neither sentence beside a badge says otherwise,
      and a test holds them to it. (#94)

    • A plugin can carry a logo, and Postulo serves it. The manifest has had a logo field
      since #97 with nothing rendering it. Now a plugin names a file inside its own package and
      it appears beside the plugin's name.

      Three constraints shaped it and all three had been settled once already. It cannot be a
      static file: plugins are installed at run time, collectstatic ran when the image was
      built, and the manifest storage raises on a file it never learned rather than returning a
      dead link. It cannot be a URL: the production policy is img-src 'self', and an image at
      the plugin author's server would tell them which instances run their code, how many people
      use it, and when. And it is raster only, because SVG can carry scripts and a direct visit
      to the file is not the <img> context where a browser refuses to run them — what is
      served is a PNG Postulo produced from what the plugin shipped, not the plugin's file passed
      through.

      Having no logo is the normal case, not a failure. The interface falls back to the
      initials tile it already uses for a person with no picture and a company with no logo, and
      a declared file that is missing, oversized or unreadable falls back the same way with a
      line in the log. Nothing is ever a broken image.

      Postulo ships no logo for any plugin of its own, and that is the decision rather than
      the backlog.
      Displaying somebody's mark to say "this reads Europass files" is nominative
      use; redistributing the file under AGPL-3.0 would be sublicensing a mark the project does
      not own, and the Commission's reuse decision explicitly excludes logos from its scope. The
      rule is written in docs/PLUGINS.md rather than decided logo by logo, because one that
      only works for the marks a project happens to like is not a rule. (#106)

    • A plugin Postulo ships can hold its own translations now, and holding them costs it
      none of the coverage that keeps them translated.
      docs/PLUGINS.md has always said a
      plugin's strings are never added to Postulo's catalogues. That was true of every
      third-party plugin and false of every plugin Postulo ships, because there was one
      catalogue in the repository and everything was in it.

      The guarantee was the whole difficulty. One test says the twenty-four European Union
      languages stay complete, and it walked one directory. Move a built-in's strings out and
      they leave its sight — and for a plugin Postulo ships, Postulo is the author, so the
      outcome is not "translated by somebody else", it is "quietly untranslated". So the
      tooling learned about several sets of catalogues instead: extract writes each string to
      the set that owns the file it came from, check, stats and compile walk all of them,
      and every promise the tests made about the catalogues is now made about each set. Sets
      are found on the filesystem rather than listed anywhere, so a plugin that moves its
      strings is covered from the moment the directory exists.

      Which catalogue wins is now a decision rather than an accident. Two catalogues can
      translate the same English word, and Django resolves that by the order of the locale
      paths — first one wins, and a plugin's is appended. So Postulo's own rendering is always
      the one shown: a plugin can add a word to the interface and cannot change one. Asserted,
      because it was previously true by luck.

      The two built-in capture sources moved first, with their thirty-nine translations
      unchanged and the HTML helper they were the only user of. They were also the plugins the
      surface test called wholly independent — which turned out to be an artefact of the test
      reading only absolute imports; resolving the relative ones as well found three more real
      dependencies that had been hiding behind a dot, each now written down as work #129 has
      left to do. (#127)

    • A written, enforced answer to "a plugin should not depend on the core": depend on
      postulo.plugins.api, and nothing else.
      The imperative could not be checked — or even
      argued about — while there was no answer to depend on what, then. Now there is one module
      that is a promise, everything else is this month's internals, and a test walks every plugin
      Postulo ships and fails on one that reaches past it.

      Total independence was never the goal. A plugin holding one person's data must scope it
      with for_user() or one person sees another's; a redirect that skips safe_next bounces
      somebody off the instance; an outbound request that skips the guarded client dials where the
      server should not; a consenting connection that keeps a token has stopped refreshing it.
      Those four are reasons to depend on Postulo, and the imperative is served by making them a
      small named set rather than by pretending they are avoidable.

      The promise about breakage is made now, deliberately. Having no surface was free, and it
      stops being free the moment #129 moves shipped plugins into their own packages — a package
      outside src/postulo has to know what it may import, and deferring the promise means either
      deferring that work or setting a boundary by accident. From here a change to any of these
      names is a deprecation entry first and a removal later, never a silent rename.

      Enforcement is what makes it real, and it reads the source rather than importing it: a
      lazy from postulo.core import site inside a method is exactly as much of a dependency as
      one at the top of a file, and is the shape most of the remaining ones take. The plugins that
      still reach past the surface are recorded with what each needs — that list is the map of what
      #129 has left to move, a new entry has to be written on purpose, and a stale one fails too,
      because an entry nobody removed hides the next real dependency.

      The two built-in sources import nothing from Postulo at all and the telephone-numbers feature
      imports only the surface, which is the check that the surface is not so wide as to mean
      nothing. (#126)

    • A connection can say it needs consent rather than a password. Every connection until
      now authenticated with something a person could type — a field, a form, a stored secret, a
      Test button — and OAuth is not that. It is a round trip through somebody else's website,
      two HTTP requests, a browser redirect and a token that has to be renewed before every use,
      none of which is a field. Consent sits beside FieldSpec as part of the vocabulary a
      plugin uses to say what it needs, and Postulo conducts the round trip.

      Tokens live in the connection's own encrypted secrets, not in allauth's. Reusing the
      sign-in grant would have saved a great deal and been wrong twice over: it carries the scopes
      asked for at sign-in, and asking for a mail scope at sign-in so it might be useful later is
      exactly the over-broad consent this project should not be teaching — and allauth holds one
      token per account per application, so a second grant has nowhere to sit beside the first. Two
      grants, asked for when each is needed, in one store under one key.

      The callback address is shown, not described. OAuth needs a redirect URI registered in
      advance, and it is this instance's own public address — something a self-hosted application
      behind a proxy may not know about itself, and which changes when somebody moves it. Getting
      it wrong ends a consent screen in an error nobody can read, so the connection's page prints
      the exact string. One address for the whole instance rather than one per plugin: registering
      it by hand once is the difference between usable and a chore repeated per provider.

      The scopes are on the page too, in the provider's own words, because a list nobody can
      read is a list nobody consented to.

      "Consent was withdrawn" is a distinct failure, which is what the Test button now means
      for a connection like this: it proves the grant still stands before it proves anything else.
      Nothing is misconfigured, nobody typed anything wrong, and the fix is to agree again — none
      of which a mail server can tell you. A provider's other refusals are kept apart from it.

      The refresh goes through the guarded client, because it is an outbound request in the
      middle of delivering somebody's mail and deserves the destination policy, timeout and
      redirect limit every other outbound request gets. And the state carried through the round
      trip is signed, short-lived and checked against whoever is signed in when it comes back: a
      callback is a request another page can cause.

      docs/THREAT-MODEL.md gains the line the issue asked for — a refresh token outlives a
      password and is not changed by changing one, and only the provider can revoke the grant.
      CONTRIBUTING.md has the shape for plugin authors, including why not to reach for the
      sign-in tokens. (#150)

    • Tables can act on several rows at once — tag applications, move them to a status, put
      companies in a field of activity. Four decisions were forced by the shape of the feature and
      each is taken in the code rather than left to a template.

      Owner scoping moves, and that is the whole risk. Every view until now fetched one
      object where a foreign id is a 404; a bulk action receives a list of ids from a browser,
      which is exactly the shape of request that goes wrong. Every id is re-scoped and the action
      works on the intersection — never on the list that was sent, and never by refusing the whole
      batch, because refusing is an answer and an answer tells the sender which ids exist. Forty
      ids of which thirty-nine are somebody else's changes the one and says "1 changed". Sixteen
      tests in tests/security/ hold that, including that the message never reports how many were
      sent: the difference between sent and changed is a count of somebody else's rows.

      Additive only. Deleting forty things is a different act from deleting one, and for a
      company it cascades to every posting under it — which the interface says plainly for one
      company and would be saying about an unseen number for forty. That confirmation deserves its
      own design; until it has one there is no bulk delete.

      A changed query clears the selection. Tick twelve, narrow to four, press the button —
      every answer surprises somebody, and acting on twelve while showing four is the surprise with
      consequences. The checkboxes are form state on a page and a filter change loads a fresh one,
      so the rule is also what happens naturally and cannot drift out of true.

      Select-all means this page, and it is a button rather than a header checkbox. A checkbox
      in a header row is inert without script, and an inert control is worse than a missing one; the
      script adds a button saying what it does. "All 340 matching" is not offered — it is a
      different promise that has to work from the query rather than a list of ids.

      It is a form before it is anything else: checkboxes sharing a name and a submit button are a
      complete implementation, and everything scripted sits on top. The count is spoken through a
      live region, because a selection that exists only as a column of ticks is not a selection.
      Status changes go through the event log rather than update(), so forty applications moved
      are forty applications that can say when they moved. (#134)

    • A rule for what happens to a plugin's data when the plugin goes, written before any
      plugin owns data.
      That timing is the point. Everything the newest plugin governs lives in
      core, so the question has never had to be answered — and the moment one is made
      self-contained it becomes a Django app with a migration history of its own, and three
      questions arrive at once with no answers.

      A plugin that owns a table may not be uninstalled while that table holds anything.

      Postulo refuses, names how many records are in the way, and leaves both the package and the
      data alone. Two other answers were open and neither is safe. Keep the table leaves data
      nothing can read, export or restore — present in every backup, absent from the export of the
      person whose data it is, invisible to migrate; that is the failure the export exists to
      prevent. Delete it behind a confirmation makes removing a package a data-destroying act,
      when somebody may only be swapping it for a newer build of the same plugin — and Postulo
      promises in thirty-nine languages that switching a plugin off deletes nothing, so putting
      uninstall on the other side of that promise is a distinction nobody holds in their head at
      the moment it matters.

      Refusing is also the shape this codebase already uses three times over: the last
      administrator, the mail transport that is the last way back in, the plugin that is somebody's
      only recovery route. A refusal naming what holds it is something a person can act on.

      Off and uninstalled are now different acts, and the difference finally carries weight.
      Off keeps everything and offers nothing; uninstalled is refused while there is anything to
      take away with the code.

      And an archive cannot be quietly incomplete. A plugin that owns a person's data says how
      to put it in their export; one that owns data and cannot say gets its models named in the
      archive under not_carried, because a silent gap is discovered on restore and a stated one
      while the original still exists. A plugin that raises while exporting is named the same way
      rather than taking somebody's whole archive down with it. FORMAT_VERSION is 8.

      The refusal lives on remove() and not only on the page, so a management command or a shell
      meets the same rule. CONTRIBUTING.md has the rule for plugin authors, including why
      export_for is not optional in spirit and why owning a table means shipping migrations. (#128)

    • An instance can offer signing in with a code sent to the primary address. allauth has
      carried the feature all along and it was switched off; what was missing was the decisions.
      Server settings → Sign-in now has the switch, off until an administrator says otherwise.

      A code and not a link, which is where this parts company with the request. A link is a
      bearer credential that works from anywhere, and corporate mail scanners follow links —
      Defender, Proofpoint and their kind fetch every URL in a message, which spends a single-use
      link before the recipient has read the mail. Postulo's people correspond with recruiters, so
      some of them have exactly that mail. A code typed back into the browser that asked for it
      cannot be burned by a scanner, cannot be usefully forwarded, and cannot be used from a device
      that is not the one signing in.

      It is never a second factor, and there is no setting to make it one. Somebody with an
      authenticator is still asked for it — asserted in tests/security/ rather than assumed,
      with its companion proving the flow works at all so the assertion is about the factor and
      not about a broken path. This is a deliberate departure from sso_is_second_factor: that
      setting exists because an identity provider may itself have checked identity carefully and
      Postulo cannot see how, while a code out of an inbox has no provider behind it to trust. So
      there is nothing for an operator to decide and no switch to leave in the wrong position.

      Offered only where mail is actually delivering, not merely configured — which is what
      the mail-health work was for. A sign-in page promising something it cannot do, to somebody
      who may have no other way in, is the worst place to be optimistic. The switch and the page
      both read the same answer, and both close again if mail starts failing.

      Three minutes, three guesses, three resends, and the browser is never remembered — a one-off
      code must not become a standing credential on a machine that may not be theirs next week.
      The code goes to the primary address, so Settings → Account now says that changing which
      address is primary moves it. And the whole thing makes the mailbox more load-bearing, which
      the settings page says in as many words rather than leaving to be discovered. (#153)

    • A confirmed telephone number can be the way back into an account, joining email and a
      passkey — and on this release no number is one anywhere, because nothing can confirm a
      number until an operator installs a text gateway. The route exists and refuses to pretend,
      which is the same shape the two pieces underneath it took.

      It is deliberately not the primary number. The primary is what a CV and a letter print
      and what a recruiter dials; making the same row the way back in would mean somebody who
      changes the number on their CV silently changes how they prove they are themselves. A
      separate choice removes the coupling instead of warning about it, and one per account is
      enforced by a partial unique index rather than by whichever form saved last.

      Only a confirmed number of your own. A recruiter's switchboard is a number an account
      recorded, never one it is, and a number nobody answered on is a claim rather than a channel.
      Both refusals sit on the model rather than only on the form, because the API, a management
      command and a shell all reach it. Editing the digits takes the nomination away with the
      confirmation it rested on, in the same breath.

      Switching the plugin off cannot remove somebody's way back in. That is a boundary worth
      naming: recovery is instance policy and a feature plugin is a per-person preference, so an
      administrator switching several telephone numbers off for one account must not quietly
      decide whether that account is recoverable — which is the failure the transport interlock
      exists to prevent, arriving through a different door. The plugin governs what is shown and
      used, exactly as it always has, and recovery reads past it.

      The count of accounts with nothing but email grows a second clause rather than an
      assumption, and both halves are required: an account has a route when it has nominated a
      confirmed number and the instance has something that can reach one. An archive cannot
      carry a nomination in either — the importer drops it with the confirmation it depends on,
      or a file could nominate a way into an account on an instance that never checked the number.
      FORMAT_VERSION is 7. (#144)

    • An administrator can issue a way back into an account, so mail is no longer the only
      one.
      Email was the single route, which made the interlock refusing to switch the mail
      transport off while it is the last way in correct and permanent: on an instance with no
      second route it could never open. Now it can.

      The route needs no third party at all, and that is why it is first of the three candidates
      rather than SMS. An administrator makes a single-use link and hands it over by whatever
      means they already trust — in person, on the telephone. No gateway, no vendor, no cost, and
      it is the answer for the family or small-team instance where the administrator is in the
      same room.

      A link like this is a whole account in a URL, so four properties carry the weight.
      Short-lived — an hour, because a URL that still works next week has been sitting in a
      chat log for a week. Single-use, and opening it does not spend it: a link-preview bot
      in the chat an administrator sent it through would otherwise burn somebody's only way back
      in by fetching it. Shown once, to the administrator who asked, and never mailed, logged
      or displayed again — handing it over is their job, and doing it over the channel this
      replaces would be absurd. Recorded, and the record outlives the link: who issued it, for
      whom, when, and what became of it, because taking somebody's account back should not be
      possible without a trace.

      It sets a password; it does not sign anybody in. The smallest blast radius available.
      A person who uses one still signs in afterwards and still meets their second factor, so a
      link that goes astray is a password change on an account whose other factors are untouched.
      The token never appears in the address of the form it leads to either, so it does not travel
      in a Referer header or a synced browser history.

      An administrator cannot usefully issue one for themselves, because making one needs
      signing in and the person who forgot their password cannot. So the route reaches everybody
      but the issuer: two administrators cover each other, one administrator covers everybody
      else, and a lone administrator without a passkey is the single account it does not reach.
      Counted rather than assumed, so the answer changes the moment somebody is made an
      administrator.

      And it covers getting existing people back in, not admitting new ones. A new account
      still has to verify an address before it exists, so an instance with mail off can recover
      its people and cannot take on more. The Email page says that where somebody is about to act
      on the lock being open, rather than leaving them to discover it when the first sign-up
      fails. (#103)

    • Postulo can carry a text message, and ships nothing that sends one. Both halves are
      the feature. There was no SMS anywhere — one comment naming it as a route that had not
      landed — and a telephone number cannot be confirmed until something can reach one.

      It is a transport, not a notifier, and that was most of the decision. A notifier's
      credentials belong to the person: their Twilio account, their Apprise endpoint. Somebody
      locked out of their account is exactly the one whose own gateway may be unreachable, and
      "the account holder configured the channel that proves they are the account holder" is
      circular. A channel carrying a way back in has to be operated by the instance — which is
      what a transport already is.

      One kind with two mediums, not a second kind. Selection, configuration, the interlock
      that stops the last way in being switched off, the exemption from per-person policy and the
      page that lists them are identical for both; what differs is the payload. That is what a
      field describes and what a kind would have duplicated. A transport that does not say what it
      carries carries mail, so nothing written before this has to be edited.

      No gateway ships, and that is the answer rather than a gap. Every one of them is
      somebody else's jurisdiction: a number, a message and a timestamp reaching Twilio, Vonage or
      a national aggregator on every send, from an application whose argument is that a
      self-hoster's data answers to them. Some operators will refuse that and they are right to.
      The kind exists; the package is somebody else's, exactly as it is for mail over an HTTP API.

      And SMS is deliberately not the first way back into an account. It needs a third party,
      costs money per message, and is defeated by a SIM swap. An administrator issuing a recovery
      link needs nobody else and answers the same question for the self-hosted instance with one
      administrator. So a text message becomes a recovery route only when there is a gateway and
      every active account has a confirmed number — the same bar the passkey route is held to, and
      for the same reason: this list decides whether mail may be switched off, so a route covering
      nine accounts in ten would strand the tenth.

      Two rate limits rather than one, because two different mistakes want bounding: per account
      is somebody driving a resend button, per number is a stranger whose number was mistyped into
      a form and who has no way to make it stop. Mail that fails costs nothing; this costs money
      and annoys somebody who never asked to be involved. A gateway failure is logged without
      the number or the message body
      , because that line ends up in an operator's log and the
      body is a code that gets somebody into an account. (#143)

    • One contract for every kind of contact detail, so they stop each having their own.
      Postulo holds three and they were at three different stages: an address that allauth
      validates and confirms over SMTP, a telephone number checked less than syntactically on
      purpose and confirmed by nothing, and a postal address that is neither. Nothing could ask
      them the same question — and the question is about to matter, because a number that becomes
      somebody's way back into their account has to be one they proved they hold.

      Checking now has named depths, and the middle one is refused on purpose. Shape —
      does this parse. Plausible — is this dialling range assigned, does this domain publish an
      MX record. Real — did somebody answer. phones.py refused the middle depth because it
      needs the numbering plan of every country and Postulo was never going to dial anything.
      Half that argument expires the moment a number is a channel Postulo sends a code to; the
      other half does not. The cost is unchanged, and the thing that settles the question is
      confirmation, which is free and conclusive. So the refusal is made again, deliberately,
      rather than inherited.

      What did change is that telephone checking is now syntactic rather than less: a number
      carries a country and between four and fifteen digits, which is what E.164 allows. A
      number that fails is still saved
      — the number a recruiter dictated over a bad line is
      still the only one anybody has, and refusing to record it would be the worst outcome
      available. The check reports; the one caller that will refuse is the one asking whether a
      number may be a way back in.

      Validated-but-never-provable is a legitimate answer. A postal address can only be
      proved by posting something to it, which this project is not going to build, and a contract
      treating that as a gap would keep the postal channel looking permanently unfinished. It is
      kept apart from a second kind of empty answer — a channel that is provable and has nothing
      to prove it with yet, which is where telephone numbers sit until something can reach one.
      Two different facts, and the code that has to explain itself to somebody locked out of their
      account needs to tell them apart.

      allauth is described, not rewritten. The email channel satisfies the contract by
      delegation: allauth keeps the token, the expiry, the resend, the link and the verified flag,
      and this says so. A contract an existing working implementation cannot be described by is a
      contract nobody adopts.

      A channel names what would carry its confirmation rather than reaching for something, so
      the interlock protecting the mail transport has the same answer to read. POSTULO_CONFIRMATION_RATE
      bounds resends across every kind at once, because the kind that costs money per send is not
      the one whose own setting anybody would remember to configure. And the one line a code
      arrives on names the instance and says nobody will ask for it — a stranger's message
      containing six digits is the shape of every scam there is. (#146)

    • An instance can offer fewer languages than Postulo speaks. Thirty-nine of them in one
      picker is a long list for an instance whose people all read two, and until now there was no
      way to say so short of deleting catalogues from the image. Server settings → Defaults
      now carries the list; untick a language and it leaves everybody's picker.

      Empty means all of them, and that is the design rather than a shortcut. Storing the list
      of every language ticked today would have frozen the set on the day somebody first opened
      the form: the instance would go on offering exactly those thirty-nine while a later release
      added a fortieth that nobody would ever see. Ticking them all stores nothing, so an instance
      nobody has narrowed keeps gaining languages as Postulo does.

      Narrowing never rewrites what somebody chose. A person whose profile says French reads
      the instance default from the next page onwards once French stops being offered — and their
      setting stays exactly where it is, so offering French again brings them back without their
      noticing either event. Blanking the profile instead would have been one line shorter and
      irreversible: an operator narrowing thirty-nine to four would silently overwrite every
      account that had chosen one of the other thirty-five, and there is nothing to put back.

      Two saves are refused rather than accepted quietly — offering nothing, which leaves nobody
      able to read anything, and withdrawing the language new accounts start in, where the message
      names that language and says to change the default first. An administrator is not exempt
      from the narrowing either: what the instance offers is what the instance offers, and two
      rules where one will do is how the two drift apart. (#120)

    • A contact can be in a department. Which team somebody is in was going into role
      — "Engineering — hiring manager" — or into the notes, or nowhere, because there was
      nowhere else. Now there is: type the name on the contact form and it joins that company's
      list, type one already there and it is reused, clear the box and the person leaves the
      team while the team stays.

      A department is deliberately not a company with a parent, though it would have fitted
      the relation added beside it. It has no website, no logo, no identifiers, no industries and
      no postings of its own that are not the company's; modelling it as a company would fill the
      companies list with things nobody applied to and teach every count to exclude them. More
      code, fewer lies.

      Both ends are optional, which is the normal case — most contacts have no department, and a
      department with nobody in it is a team somebody applied to before knowing anybody there.
      Going away takes the right things in each direction: a department deleted leaves its people
      where they work, because they are still at the company, and a company deleted takes its
      departments with it. A contact cannot borrow another company's department, and saying so is
      a sentence rather than a silent correction.

      Typed rather than chosen, because making somebody create a team before they can name one is
      a form standing in front of a form — the same reasoning Industry.named already follows.
      (#137)

    • A company can be part of another one. Applying to Google, to DeepMind and to Waymo is
      applying to three companies the person already knows are one group; Postulo counted three
      unrelated employers, and searching for the group's name found none of them. One nullable
      parent on Company fixes that: a tree rather than a graph, because at most one owner is
      what an ownership structure is and it keeps every question answerable by walking rather
      than searching.

      Nothing is inherited, which was the open question in the issue and is answered no. A
      subsidiary keeps its own industries, logo and notes. Inheritance is a rule people then have
      to hold in their heads; naming the parent on the page is what the reader wanted.

      Three refusals, each with the reason. A company cannot be part of itself. A chain
      cannot close into a loop — and the message names the company whose link would close it,
      because "not allowed" leaves somebody looking down a list of subsidiaries guessing which
      one. And a chain cannot exceed ten, which is far past any real group and stops a mistake
      becoming a page that walks for ever. The form goes further and does not offer what would
      be refused: a company's own subtree is not in its parent list, since refusing something the
      form suggested is worse than never suggesting it.

      The walk protects itself as well as the form does. clean() cannot run on a
      QuerySet.update, so a loop written straight into the database — by an import, by a
      migration, by hand — would otherwise hang every page that asked which group a company
      belongs to. group and descendants() both stop rather than spin, and a test writes that
      loop deliberately to prove it.

      The export is format 5: a company names its parent by name, because identifiers in an
      archive are local to the file, and the importer resolves them in a second pass once every
      company in it exists — a child may be read long before its parent is. An archive written
      before this names no parent and still imports. (#55)

    • Several telephone numbers per person and per contact, behind a plugin that can be
      switched off.
      Profile.phone and Contact.phone were one CharField each: one number,
      no primary, no uniqueness. Email addresses have worked the way this asks since allauth
      arrived — several per account, exactly one primary, unique across the instance — so there
      was a shape to copy and its edges were already known.

      It is a plugin, and that is a new kind. Every plugin kind until now talks to something
      outside: a source reads a page, a notifier sends, a store keeps files, a sync exchanges, a
      transport mails, an importer reads a file. A feature talks to nothing. It answers one
      question — is this on for this person — and the application asks before offering the part
      of itself the feature covers. It exists as a plugin rather than as a settings checkbox
      because Postulo already has one place where a capability is switched on for a person,
      overridden by an administrator, and explained on their settings page with a record of who
      decided; a second mechanism for the same question would be a second set of edge cases and a
      second thing to remember at every call site.

      Switching it off deletes nothing, and that was the point worth arguing about. The
      interface promises exactly that, on two pages, in every language Postulo speaks, and the
      first kind whose subject is Postulo's own tables is not where that promise gets an
      exception. Off shows and uses the primary number only — precisely what the single field did
      — the page says how many others are being kept back so nobody concludes a checkbox ate
      them, an export carries all of them either way, and switching it on again finds them in the
      order they were left. Saving the one box while it is off touches the primary row and
      nothing else: rows a person cannot see are not theirs to lose by saving a form that never
      showed them.

      A number is kept once across the whole instance, which is the maintainer's decision and
      a disclosure worth naming rather than dressing up. Refusing a number because somebody
      already holds it tells whoever typed it that an account on this server has it. There is
      no way to enforce the rule without saying so, and a vaguer message would disclose exactly as
      much while leaving the person guessing, so the message says it in those words. Nothing else
      is disclosed — not whose, not where, not when. Two things sit outside the rule by design: a
      number that never reached international form has nothing comparable and collides with
      nothing, and pairs that were already recorded before the rule arrived are grandfathered, the
      migration keeping both and the form telling you the first time one is edited. Unique but
      unverified, because Postulo cannot send an SMS and should not start; the first account
      to type a number holds it.

      The rows are a generic relation for the reason CVItem gives — the holder is heterogeneous
      and two nullable foreign keys would need a migration every time a third kind of holder
      appears — with a GenericRelation on each holder so deleting one takes its numbers with it.
      The primary is a partial unique index rather than a rule a form remembers, and the radio
      that chooses it names a form prefix rather than a key, because somebody adding their first
      two numbers is choosing between rows that have no keys yet. The export format is 4: a
      phone_numbers list where a phone string used to be, and the importer still reads an
      archive made yesterday. (#90)

    • Postulo now speaks Ukrainian and Turkish. The catalogue set stopped at the borders of
      the European Union, which is a political boundary rather than a linguistic one: it left out
      the language with the most speakers of any in Europe that the Union does not administer, and
      it left out Turkish, which sits inside several member states without being official in any
      of them. Neither omission was ever a decision — Europe was simply taken to mean the Union
      because that was the list that came to hand.

      Why the continent comes first is a decision, and this is it. The maintainer chose to
      give the languages of continental Europe precedence over the rest of the world, in the
      service of European digital sovereignty: Postulo exists as an alternative to hosted
      services that answer to somebody else's jurisdiction, and an alternative that only speaks
      the languages those services already speak has not moved anybody very far. Africa (#70)
      and the rest of the world follow in later releases; the order is deliberate, not
      alphabetical.

      Ukrainian brought back the plural rule that has to be got right. Three forms, chosen by
      the last digit rather than the value: 1, 21 and 101 take the first, 2–4 the second, and
      11–14 the third despite ending in 1 to 4. Copy the two-form rule and every count on
      every page is wrong for four numbers in ten. Turkish went the other way and is worth saying
      out loud, because the tidy answer is also the wrong one: the noun after a numeral does not
      inflect at all — bir başvuru, iki başvuru — so both forms carry the same word, and the
      split earns its keep only in the rest of the sentence around it.

      What comes next is what stops being tidy, so the ground was laid here rather than at the
      language that first needs it. FLAG_COUNTRIES used to be able to claim that every language
      had one uncontested home, which was true of the Union and is not true of the continent:
      Basque, Catalan, Galician and Welsh are coming, and each sits in a state whose flag already
      stands for a different language on the same list. The rule written down at this point was
      that such a language carries none: flag_country() answers with nothing and the picker
      closes the row up — behaviour already there, written for a phase beyond Europe that turned
      out to begin inside it. It did not survive contact with Catalan; four paragraphs down is
      what replaced it.

      Norwegian Bokmål and Icelandic followed, and the second is the one that repays reading
      the rule rather than the form count. Icelandic has two plural forms, so _TWO looks right
      and is wrong: the last digit decides, not the value. 21 and 31 take the singular like 1 —
      tuttugu og ein umsókn — while 11 takes the plural like 12. A two-form language whose rule
      is not n != 1 is exactly the case a form count cannot catch, which is why the rule is
      written out per language instead of inferred.

      Bosnian and Serbian brought the second script, and a temptation worth naming. Both
      take the same three-form rule Croatian already carries, and the tidy move is one shared
      constant for all three — which is exactly how a language ends up inheriting a rule nobody
      checked for it. They are written out separately. Serbian is offered in Cyrillic, its own
      name in the picker reading српски, so the list now carries three scripts and every one
      of them still says which language it is in.

      Albanian and Macedonian finish the Balkans. Macedonian is the third language here whose
      rule is two forms decided by the last digit rather than the value — Icelandic's shape, in a
      different family — which is now enough of a pattern to say out loud: nplurals=2 says
      nothing about which two.

      Georgian and Armenian each bring an alphabet of their own, used by no other language on
      the list and by almost none anywhere else. That costs nothing at render time — the interface
      has carried lang on every language option since #43's first pass, for WCAG 3.1.2 — and it
      is worth naming because the list is now a place where somebody who cannot read the current
      language has to find their own, and ქართული and հայերեն are unmistakable to the person
      looking for them in a way an English label never is. Armenian also does not take _TWO:
      it counts zero with the singular, the way French does.

      Catalan and Basque made the flags admit that a language can be at home somewhere that is
      not a state.
      The table beside the picker had always mapped a language to an ISO 3166-1
      country, and for the Union that worked because every language there had one. Catalan's
      honest answer is not Spain — that flag already stands for Spanish, two rows up — and the
      rule written down for this case said the language would simply get no flag. That was the
      wrong call, and the maintainer said so: it is ES-CT, Catalonia's own, and Basque's is
      ES-PV, the ikurriña. So the flag table, assets/flags.txt, the tag's validation and the
      sync script all take ISO 3166-2 subdivisions now, Galician and Welsh will use theirs when
      they arrive, and the telephone field is untouched — a dialling code is a state's, and
      Catalan's is still Spain's.

      Galician and Welsh arrived to find their flags waiting, ES-GA and GB-WLS, which is
      why the table was widened a commit early rather than at the language that finally forced
      it. Welsh brought the plural rule nothing about a form count predicts: four forms, and the
      only rule on this list that singles out particular numbers. 1 and 2 take their own, 8 and
      11 share the fourth, and every other number falls to the third. There is no reasoning from
      nplurals=4 to that shape — it is copied from the language or it is wrong.

      Luxembourgish finishes the fifteen, and it makes the point the whole issue rests on
      better than any of the others: it is a national language of a member state that is not a
      language of the Union, because Luxembourg files its Union business in French and German.
      The Union's list and Europe's list were never the same list. Thirty-nine catalogues now,
      every one of them complete, across three alphabets, with four subdivision flags beside the
      thirty-five countries.

      The test that pinned how many languages there are was deleted rather than edited fifteen
      times
      . A number in a test is a fact restated in a second place, and every language in
      this issue would have had to visit it; what has to be true is that settings.LANGUAGES
      offers exactly what the table holds, and that is what it now asserts. (#118)

    • The languages of Africa, and everything a language needs before its words arrive.
      "Every language of Africa" is some two thousand of them, so the rule drawn is official
      or national status in at least one African state, plus the cross-border lingua francas
      that outrank most of those in speakers
      : twenty-nine languages, and a documented rule
      rather than a list assembled by feel. Each arrives with the things that have to be right
      before anybody translates against them and are expensive to correct afterwards — its
      own name for itself, its gettext plural rule (Arabic has six forms; Wolof, Igbo, Shona
      and Bamanankan have one), and its script. Arabic is the first right-to-left language
      Postulo carries, and the layout for it landed first (#67). Thirteen carry no flag, and
      that is the answer rather than a gap
      : Europe's stateless languages got the flag of the
      place they are actually at home in — Catalonia's, the ikurriña — and these are the ones
      where not even that would be honest. Arabic is twenty-two countries, Swahili four, Hausa
      two, Sesotho is Lesotho's as much as South Africa's; a wrong flag against somebody's
      language is not a small wrong. The image can now draw them: it installed
      fonts-dejavu-core, which covers Latin, Greek and Cyrillic and stops there, so an
      Amharic or Tigrinya CV would have rendered as a page of empty boxes; fonts-noto-core
      is added and a test holds the image's fonts to the languages actually offered. A
      language is offered once somebody has begun its catalogue, and not before
      — all
      twenty-nine exist and are empty, and listing them would be offering somebody their own
      language and handing them an English interface. Translate one string and it appears,
      with its completion beside its name. The suite's rule changed to match: the European
      Union set must stay complete, every language must have a correct plural rule and a page
      that renders, and completeness is a promise about a finished phase rather than about a
      language added yesterday. The 45,037 translations themselves are the rest of #70. (#70)

    • The interface is laid out right to left when the language is. The dir attribute
      has been emitted since the first release and has been ltr on every page ever rendered,
      because every language Postulo speaks is read left to right — so what happened under
      rtl was unknown rather than known-good. It is known now, and the layout work is done
      before the first such language arrives (#70) rather than after somebody reports it.
      Sixty-five classes across forty-two templates and the stylesheet stopped naming a side
      and started naming a reading edge — ms/me, ps/pe, start/end,
      text-start/text-end, border-s — which mean exactly what they meant under ltr. The
      action buttons at the end of a heading row, the timeline rule beside the event log, the
      menus that hang from a corner, the skip link and the board's columns all move to the
      other edge; icons that point sideways are mirrored by name, and vertical ones are left
      alone because they mean the same either way. Text that is not in the reader's script
      is isolated
      : company names, job titles, addresses and letter subjects in <bdi>, and
      every <code> span by one stylesheet rule, so a Latin name inside an Arabic line does
      not throw the separator to the wrong end. A rendered document has its own direction,
      from the language it is written in and not from the language of whoever made it — an
      English CV written by somebody using Postulo in Arabic is an English, left-to-right PDF.
      One list of right-to-left subtags serves the interface and the documents alike, and it is
      Postulo's own rather than Django's, because #43 goes well past the languages Django ships
      with. Held by two things that will still be there next year: a lint that fails on any
      template or stylesheet class naming a left or a right, and a browser suite that reads
      the application in a right-to-left language — in both themes, through axe — and measures
      that the buttons, the board and the skip link actually moved. (#67)

    • Every plugin carries a manifest, and the six Postulo ships fill it in. #97 settled
      what a plugin declares — a short name, a full name, an author, a version, a description, a
      source link — and then Postulo's own declared almost none of it. SchemaOrgSource was two
      lines, name and version = "1.0", under a module that says a source's version exists so
      a capture can be traced to the code that made it
      . It could not be traced to anything:
      "1.0" meant 1.0 the day it was written and would have gone on meaning it through every
      change to the parser underneath. A rule this project asks of other people and not of itself
      is not a rule.

      The shape changed with it. The facts now live in one Manifest rather than one
      optional attribute each, with @declares attaching it and setting the protocol members
      from it, so the identifier the registry keys on and the one in the manifest cannot
      disagree. That is not tidiness. A loose attribute per fact can never be required —
      runtime_checkable protocols check data members, and the registry drops anything failing
      isinstance, so adding label to SourcePlugin would have silently unloaded every source
      anybody had already written. One optional attribute carrying any number of facts has
      neither problem, and a field added later costs a plugin that has not heard of it nothing.

      manifest_of() is now the single place anything asks who a plugin is, and it looks in
      three: the manifest, the loose attributes plugins used before there was one, and the
      wheel the plugin was installed from
      . That last one is what makes "one place to look" true
      rather than aspirational — a third-party plugin that never heard of manifests still has an
      author and a licence in its packaging, and Postulo already reads them.

      So Server settings → Plugins now shows the version, the author, the licence and a link to
      the source for every plugin, built-in or installed, on the same line in the same place.
      #97's complaint was that an administrator could see who wrote a plugin on the day they
      installed it and never again; the built-ins could not be seen even then. The mail transport
      is the one plugin that page does not list — it is not governed per person — so the same
      line appears on Email, beside the transport carrying the mail.

      Their version is Postulo's own, which is the truth: these ship with the application and
      change when it does. Their identifiers did not move. schema.org and page-metadata
      are written into the source field of every capture anybody has made, and renaming one
      orphans that history; there is a test pinning them. There is also a test that walks the
      built-ins rather than naming them and fails on a missing field — which is the part that
      matters, because the issue asking for this counted four and there are six. (#98)

    • Delivering mail is a plugin now, and SMTP is the one that ships. A new kind,
      transport, under postulo.transports. It exists for a specific person: the self-hoster
      whose provider blocks outbound 25, 465 and 587 — most residential connections and several
      hosts — who today has no route except finding a relay that speaks SMTP. They can install
      one that speaks an HTTP API instead. Postulo ships exactly one transport and names no
      vendor, which is the plugin system doing what it says it is for.

      A plugin cannot supply MAILERS, and that shapes the whole thing. Django reads that
      setting when the settings module is imported; entry points are not loaded until the app
      registry is ready, which is later. So "SMTP is a plugin" cannot mean "the plugin defines
      the mail settings". What it does mean: core names one backend, and that backend asks which
      transport is selected and how it is configured at send time — the same mechanism the
      Email page needed anyway, so the two are one piece of work rather than two.

      Installing a transport does not silently redirect the mail. The registry prefers
      third-party plugins for sources, because a plugin written for one job board knows more
      about it than a general parser does; that argument does not transfer to where an
      instance's mail goes. An administrator chooses, and until they do the built-in one carries
      it. A choice naming something no longer installed falls back rather than failing.

      The lock. A transport may not be switched off — nor its package removed — while it is
      the last way anybody could get back into their account. That is written as a rule that is
      evaluated, never as if plugin == "smtp": refuse. A hardcoded exception is one nobody
      deletes, so the day another recovery route lands the lock would stay shut out of inertia,
      and an instance running a second transport should be able to switch this one off.
      recovery_routes() lists the ways in that exist: email, and a passkey, which signs
      somebody in without the password they have forgotten. A two-factor recovery code is
      deliberately not on that list — it is a second factor, so it helps somebody who still
      knows their password and does nothing for somebody who does not. The refusal counts the
      accounts it is protecting and names them, the way People refuses to remove the last
      administrator, and it opens by itself the moment the list has something else in it.

      A transport is nobody's to decide. The per-person policy has four states and none of
      them means anything about mail delivery; forced off would be an account nobody can
      recover. All four are refused for a transport rather than merely left off the page,
      because the page is not the boundary.

      Three things this did not disturb. The environment is still a complete route, so a
      fresh instance with an empty database can send the verification email that has to come
      before the first account exists. SMTP keeps its own named columns rather than the generic
      configuration blob, because each is overridden individually by its own variable and
      because of that first boot. And the email notifier is still a different plugin: it
      decides something is worth telling somebody and writes the words, the transport gets those
      words off the machine, and merging them would make notification settings and delivery
      settings the same form. (#104)

    • Email can be configured from the interface, and the environment still wins. Server
      settings → Email
      was a read-only summary and a Send a test message button, so changing
      where an instance sends mail meant editing a file and restarting a container — on an
      application whose whole premise is that you run it yourself, often on a machine you reach
      through a browser and nothing else. The server, port, username, password, STARTTLS,
      timeout and from-address are now all on that page, and a .env written for 0.1.0 goes on
      meaning exactly what it meant: where a variable is set it wins, and the field shows the
      value read-only, with the variable that pins it named beside it, rather than an empty
      box and a shrug. Readonly rather than disabled, deliberately: a disabled input leaves the
      tab order and is announced inconsistently, and a value an administrator wants to copy
      across to their relay's own configuration must not be one some of them cannot reach.
      Pinned fields are also dropped on the server, whatever a request contains, because a
      readonly attribute is presentation and a form can be posted without a browser.

      The engineering is not the form. MAILERS is built once, when settings are imported,
      so a page that wrote to it would have saved, said so, and changed nothing until a restart
      — worse than no page. Django builds a fresh backend for every message and caches none, so
      Postulo's own backend resolves the settings then; the from-address is stamped there too,
      because DEFAULT_FROM_EMAIL is read at send time by Django's code and allauth's and
      neither offers a hook. The password is encrypted at rest under the same key as a
      plugin connection's secrets, and is never rendered again — not the value, not its length;
      the page says only whether one is set, and forgetting one is a separate, deliberate
      checkbox rather than an empty field.

      Beside Send a test message there is now Test the connection: it opens the socket,
      negotiates STARTTLS, signs in, asks the server to do nothing and hangs up, proving the
      credentials without sending anybody an email they then have to ignore. It takes what is
      on the screen
      , not what is stored, so a new relay can be tried without first overwriting
      the one that works; and a failure does not block saving, because an administrator may be
      configuring a relay that is not up yet. The SMTP host is deliberately not subject to
      the private-address rule that governs capture — that rule exists because a capture URL
      comes off a stranger's page, and a relay on 10.0.0.0/8 is the ordinary case here.

      One state gets said out loud rather than left to be discovered: when a setting is both
      stored here and pinned by a variable, the page says so, because removing that variable
      hands over to the stored value and mail starts going somewhere else without anybody having
      edited anything. Only STARTTLS is supported; implicit TLS on port 465 is not offered yet,
      here or in the environment, and the field says so. (#84)

    • Postulo speaks Brazilian Portuguese. Not a copy of the European catalogue under
      another code, and not a second translation from English either: seeded from pt-pt and
      adapted
      , because every string had already been translated once by somebody thinking about
      this application and what a variant wants is that work carried across. Each of the 1,615
      entries carries the draft flag, where it means something precise — this came from the
      other catalogue and nobody who speaks this one has read it
      . A mechanical pass then changed
      the terms that are simply different words — ficheiro to arquivo, palavra-passe to
      senha, definições to configurações — word-boundary anchored, because rato is
      Brazilian mouse and also the tail of contrato, and an unanchored substitution produced
      "Contmouse a termo" before the boundary went in. Where the Portuguese was ambiguous the
      English settled it: Guardar is sometimes "keeps" and sometimes "Save", and only the msgid
      knows which. ligação — which means both a Connection and a link here — and every
      question of gerund, clitic and register were left alone on purpose, because they are a
      speaker's to answer. The plural rule is n > 1 and not the European n != 1: Brazilian
      treats zero as plural, and copying that one line without looking would have made every
      count on every page ungrammatical for the language's largest population. The picker's
      heading changed with it, from Machine translation, awaiting review to Awaiting review by
      a speaker
      — a catalogue seeded from its sibling is not machine-made, and what every
      language in that group actually has in common is that nobody has read it yet. (#110)

    • A person can see which plugins are running for their account, and who decided.
      Settings → Plugins, a new section. Connections answers "what have I set up"; it says
      nothing about the parsers that read a posting off a page — they need no connection, so they
      appeared nowhere at all — and nothing about why a plugin is available. This page exists
      mainly for one of its rows: the one an administrator decided. A control that is not yours to
      change is shown disabled with the reason beside it and their name, rather than hidden,
      because hiding it would be the quiet version of exactly what the page is against. A plugin
      made unavailable does not appear, which is what unavailable means; one forced off does,
      because being told it was switched off for you is the whole difference between the two. A
      form and a Save button, so it works with no JavaScript at all. The page says per account,
      not per browser
      — the request had asked for "in their session", and plugin state survives
      signing out and does not change when you sign in elsewhere; a page saying session while
      meaning account would teach people something untrue about where their settings live.
      (#96)

    • An administrator can decide a plugin for a person, and cannot decide it quietly. Four
      states — available, unavailable, always on, always off — set for the whole instance under
      Server settings → Plugins, or for one account from its row under People. Three of the
      plugin kinds exist to move somebody's data elsewhere, so this is a real power over another
      person's account, and what makes it acceptable is that it is visible: whatever is decided is
      shown to that person with the name of who decided it. Unavailable and always-off are
      deliberately different
      — the first means not part of your Postulo, the second means you
      can see this exists and somebody switched it off for you
      , and the second is the more honest
      of the two. Nothing is ever deleted. Switching a plugin off stops it being used;
      connections and their configuration stay exactly where they were, and reversing the decision
      brings them back unchanged — a policy that destroyed data on the way would be a delete button
      with a confusing name. A person's own choice survives being overruled and returns when the
      overrule is lifted, and there is a test for that. Forcing a plugin on does not make it
      run
      : a notifier, store or sync needs credentials only its owner can supply, so on means
      "available to you and you may not switch it off" — the page says so rather than leaving it to
      be discovered. Mail transports are exempt from all of it, because delivery is instance
      infrastructure and always off for one would be an account nobody could recover. Who decided
      and when live on the row, and every change is logged: Server settings has no audit trail of
      any kind, and a decision about what somebody's account may run was the wrong place to wait
      for one. (#95)

    • Plugin repositories are rows an administrator manages, not one environment variable.
      Catalogues already worked — a signed index, an Ed25519 key, a checksum for every wheel, and
      several of them supported at once. What did not exist was any way to manage one: they came
      from POSTULO_PLUGIN_CATALOGUES as name|url|key, so adding a catalogue meant editing a
      file and restarting the container. Server settings → Plugins now lists them in three
      tiers. Internal is the plugins that ship inside Postulo, shown as a repository so the
      list reads as one thing and synthesised rather than stored — a row would be a fact about a
      place, and there is no place. Official is one row, and it ships switched off and
      pointing nowhere
      , because Postulo publishes no catalogue: doing so means a signing key
      kept safe for the life of the project and an answer for rotating it if it leaks, which is
      taken deliberately or not at all. Custom is however many an operator wants. The
      environment still wins and is shown doing so, the way four settings already work. A key
      is checked when it is typed rather than only when a fetch fails weeks later, and replacing
      one says so plainly and is written to the log — it is not an edit like changing a label, it
      replaces the only thing standing between an index and code running here. Switching a
      repository off stops installing and updating from it and does not touch what it already
      installed
      ; that code is on the volume and the registry never consults a catalogue. There
      is a test for each of those, and a migration that turns whatever an instance already had in
      its environment into rows, so nobody loses a catalogue. (#93)

    • A company's name can be changed where it sits, and a refusal has somewhere to go.
      Every edit in Postulo was a page — fine for a company nobody looks at twice, tiring for a
      list of forty where the thing wanted is one word in one cell.

      The interesting half is the refusal. A form has somewhere to put one: under a labelled
      field, in a form with a heading, which is the arrangement aria-describedby and
      aria-invalid were wired for. A cell four columns wide has nowhere — under it breaks the
      row, a toast is gone before a screen reader reaches it, a tooltip is never announced. So
      the answer, decided once rather than improvised per column: while it is being edited the
      cell is a form, and the refusal goes exactly where every other refusal in Postulo goes.
      That turned out to need almost nothing new — partials/field_feedback.html already
      carries role="alert", with a comment saying the role earns its place through htmx,
      because an error inserted into a live page is what it is for. A cell arriving through a
      swap is precisely that.

      Every editable cell posts to the form the page already uses, narrowed to one field, so
      a cell refuses exactly what the page refuses in exactly the same words. A cell that wrote
      the field directly would be a second way of saving to keep in step with the first, and the
      day it drifted would be the day something was saved without its rules.

      A column that cannot be edited cannot be edited by address either. Column.editable
      sits beside sort and filter in the table definition, and the view checks it — a count
      is not editable because it is a count, a date read off a posting belongs to the posting,
      and a status is not offered here at all because it goes through a service that writes a
      timeline entry.

      Two tabs, both editing, is answered rather than ignored: the editor carries the row's
      timestamp and a save whose stamp has moved is refused with what the row says now. Focus
      follows the edit and comes back to the value afterwards; Escape abandons; and with no
      script the cell is a link to the form, which is exactly what it was before. (#135)

    • The list of areas of activity is a classification now, not thirty-two names somebody
      wrote down.
      The old list had Gaming and E-commerce and no Mining, no Water
      supply
      , no Arts, and nothing at all for a third of the economy. The fix is not more
      names: a hand-made list of two hundred would be two hundred names in thirty-nine
      languages, carried by this project for ever.

      So the suggestions are seeded from NACE Rev. 2.1, the European Union's statistical
      classification of economic activities, at division level — 87 two-digit codes under
      22 sections. That depth is a choice between three, not the file that was easiest to parse:
      its 21 sections are coarser than the hand-made list ever was (Information and
      communication
      was one word for software, telecoms, publishing and film), and its 615
      classes are a form nobody fills in.

      The translations are the argument more than the taxonomy is. Eurostat publishes NACE
      in all 24 official EU languages, so 87 × 24 names arrive already written by the body that
      maintains them, and not one of them is a string this project translates — they are
      reference data in the same sense that a language's own name is. Where the EU publishes no
      name, the English one stands: inventing NACE names for Catalan or Ukrainian would be
      Postulo asserting a classification it does not maintain.

      It is a seed and never a closed list, because NACE classifies the business rather than
      the job.
      Somebody applying to a bank's software team is applying to Financial and
      insurance activities
      , which is true of the employer and useless to the applicant. So the
      vocabulary stays one set per person in their own words, Fintech remains a perfectly good
      industry, and the short familiar names Postulo always offered stay at the top of the list
      — those are what people type. A name that happens to be a division quietly carries its
      code, which is what makes a report legible to an employment office that thinks in NACE.

      The licence was checked rather than assumed. Reuse of Commission documents is
      authorised under Decision 2011/833/EU, and the default licence for Commission-owned
      content is CC BY 4.0 — attribution required, changes to be indicated. Both travel with the
      data in jobs/data/LICENCE.md, which also says what was changed and how to replace the
      file when Rev. 3 arrives. The revision is recorded inside the data rather than in a
      variable name, because a list copied into a Python module gets copied again.

      Nothing anybody already had was renamed, merged or given a code retroactively: the
      migration adds a column, widens a name that was capped at sixty characters, and fills in
      nothing. (#140)

    • One registry of external identifiers, with a matrix saying which each one identifies.
      The separation asked for already existed and was the least interesting part: a company's
      scheme could not appear on a person because of which module a form imported its choices
      from. That is a convention, and a convention holds until somebody wires a form up
      differently. It is a guarantee now — a scheme says which subjects it identifies, and a row
      that disagrees is refused at the model by every route in, objects.create included.

      What the separation cost is the interesting part. The two sets were not disjoint.
      ISNI identifies contributors and organisations by its own definition; Wikidata has items
      for both; LinkedIn has company pages and personal profiles alike. Two files that never had
      to agree had quietly picked a side for each, so a researcher could not record their
      Wikidata item and a university could not record its ISNI — which is exactly the identifier
      an EU application form asks an institution for. Nobody had filed a bug about either; both
      simply followed from the shape.

      One scheme identifying two subjects also means one scheme needing two addresses, which two
      registries could never have expressed: a LinkedIn company page and a personal profile are
      different links, and both are now right.

      A new plugin kind, and one that governs nothing. Every other kind answers is this on
      for this person
      ; a registry answers what does this key mean, so switching it off would
      leave every stored identifier without a label, a link or a check. It is ungoverned
      alongside transports, and internal-only — enforced rather than intended, since the kind
      advertises no entry-point group at all. A plugin per national company register is the
      obvious next thing (register is one generic scheme for SIRET, NIF, Companies House, KvK
      and Handelsregister alike), but a third-party contract is a promise about breakage and
      that one is not written yet.

      It can be a plugin because a scheme owns no rows. The two identifier tables stay in
      core, owned and migrated by core; what the plugin contributes is vocabulary. That is the
      difference from the contact-details half of #100, which needed a plugin to own a table.

      No validation got weaker: every pattern and both checksums came across, and the existing
      tests pass unchanged rather than adjusted to fit. No key changed, so no row moved. (#109)

    • A career entry can say the same thing in more than one language. At the document level
      this already worked: a CV declares what it is written in, and the PDF is hyphenated,
      justified and laid out for that rather than for whoever made it. One level down it did not.
      The career record held one text per field, so a CV declaring fr-fr printed exactly the
      same English job titles as one declaring en-gb — and the honest way to keep a CV in two
      languages was to keep two careers, typed twice, drifting apart the moment a date changed.

      A second language is now a translation rather than a second record. One row per entry,
      per language, per field, and the entry itself is untouched: correcting an employer or a
      date on the master copy corrects it in every language at once, which is the whole reason
      there is a master copy. The database holds the rule that a JSON blob on the entry could
      not have — one text per field per language, decided there rather than by whichever save
      happened to be last.

      Which fields may be said differently is a decision per field, not a mechanism applied
      uniformly.
      An employer's name and an institution's are not translated: Universidade de
      Lisboa
      stays that on an English CV, and rendering it otherwise invents an employer who
      never existed. Certifications translate nothing at all, because a credential's name and
      the body that issued it are that body's wording. What is offered is either the person's
      own words or something that genuinely differs between languages — a job title, a city, a
      qualification, a grade on a scale that does not exist elsewhere. Offering the box is what
      would have caused the harm, so the boxes that would have caused it are not there.

      Falling back is visible, or it is a trap. An entry with nothing in the CV's language
      prints its original, because a blank line where a job used to be is worse than a line in
      the wrong language. The CV's own page then lists which entries did that, with a link to
      each, before the export button — the same rule themes settled one issue earlier, for the
      same reason. Discovering it in the PDF an employer already has is not discovering it.

      So the profile gains one small field: which language the career record itself is written
      in. Without it, a CV in the record's own language would report every entry as untranslated
      and the warning would be read once and ignored thereafter.

      Every theme renders this without knowing it exists — a plugin's template written against
      entry.item.role gets the French job title and could not have been asked to do anything
      else. The archive carries the translations (format 11) and brings them back. (#131)

    🔧 Changed

    • Applications and Board are one page in two shapes, and the switch keeps your filters.
      Two views answering the same question — which of my applications am I looking at? — were
      two addresses, two entries in the navigation, and a bare link between them that threw
      the filters away: narrowing the table to quiet applications at Acme and pressing
      Board showed everything. One entry now, one address, and a Table / Board switch on
      the page beside the Columns control that changes the shape of what is below it and
      nothing else. The search, filters and sort survive the switch; the shape you choose is
      remembered with your other table preferences; ?view=board asks for a shape without
      changing it; and /applications/board/ redirects, carrying whatever it was given. The
      board still shows only what is still live, and a filter that matches settled
      applications is said on the board, with a link to the same filter in the table, rather
      than shown as an empty board. Anybody who had hidden Board from the navigation has
      that preference forgotten, since the entry no longer exists; the board itself is a
      switch away for everybody. Dragging a card between columns is untouched. (#102)

    • The arrows on Your career move an entry past its neighbour, and the order number
      is hidden unless you ask for it.
      The arrows nudged a number by one and swapped with
      nothing: pressing up on an entry at 0 did nothing, pressing down once put it behind
      every other entry at 0 wherever it had been, and in experience, education and
      certifications — sorted by date first — they changed nothing you could see unless two
      entries shared a date. The number box on every entry's form was the one control that
      reliably did anything, and it asked for an integer meaning "lower first". Up now swaps
      with the entry above and down with the one below, the section is renumbered so the
      numbers are exactly what the page shows, and the arrow at either end is greyed out. The
      three dated sections take the number too, seeded once from their dates so nothing moves
      on the day of the upgrade; a new dated entry still lands where its date puts it. The
      number box is gone from the forms, with a sentence saying where the arrows are, and
      comes back as Show the order number on each career entry under Settings →
      Appearance
      — an accessibility choice, for anybody who cannot use the arrows or would
      rather type. The preference travels with the export. (#203)

    • Arrange puts the dashboard itself into an editing mode, and Settings → Dashboard is
      gone.
      Arranging was a list of widget names on another page, with four arrows and a
      Take off beside each, and you switched back to see what a move had done. Pressing
      Arrange now keeps you on the dashboard: every widget stays where it is and grows the
      same four arrows and Take off in a bar above it, the widgets not shown are offered
      below the grid with their sentences, and Done is a plain link back. The mode lives in
      the address (/?arrange=1), so a reload keeps it and the back button leaves it; nothing
      remembers that you were arranging, because the mode is a moment and not a preference.
      Every action still works with scripts off — buttons that post, focus following the
      widget that moved, a sentence saying where it landed — and dragging a widget into place
      is the same addition it was, on the grid cells now rather than on list rows. What is
      stored has not changed. (#201)

    • The account menu is the top-right corner, and the theme switch is a row inside it.
      The last thing at the top right of every page was the sun-moon-monitor button, with the
      account menu one control in from the edge — so the corner a person reaches for their
      own name, their settings and Sign out held a preference toggle instead. The switch
      lives in the menu now, as a row with its words beside the icon, above Sign out; it is
      the same form posting the same way, applied the moment it is pressed as before, and
      Settings → Appearance remains the explicit version. (#197)

    • The header stays at the top while the page scrolls. On any page longer than a
      screen the wordmark, the navigation, the search and the account menu were gone after the
      first flick, and getting anywhere else meant scrolling back up. The header floats now.
      Its height is not a constant — the row wraps on a phone and in a language with longer
      labels — so the script measures it once and on every resize, and everything that has to
      clear it reads that one value: the sticky sidebars on Your career and Your details,
      the skip link, every anchor through the root's scroll padding, and the line the section
      navigation reads its position from. Where scripts do not run, the same rules fall back
      to the header's usual height. (#195)

    • Your details, Settings and Server settings use the whole screen. Each is a sidebar
      beside a page, and each capped the pair well short of a wide monitor — Your details at
      896 pixels inside the 1280 the base template already keeps, the two Settings frames at
      1024 — with the sidebar taking a good share of what was left, so a form drew in roughly
      640 pixels on a 2560-pixel screen and the server's people, plugins and logs wrapped the
      way the tables did before #188. The frames empty the page's measure now, as the tables
      and the board do: a frame is a grid even when the page inside it is a form. The
      one-question server forms keep their own narrow column, which is a different thing. (#198)

    • The tab says which Postulo it is. A page's title was the page's name alone —
      Dashboard, Your details — so two instances side by side, or Postulo beside anything
      else, were tabs nobody could tell apart, and a screen reader announced a page with no
      application behind it. The title reads Postulo > Dashboard now, with the
      administrator's instance name first, put on once in the base template so a page added
      later cannot lose it. Levels are separated by a chevron — Postulo > Settings > API
      tokens
      — and a middle dot qualifies a name inside one level, Report · September 2026,
      so the two never read as the same thing. (#196)

    • Server settings → Overview ends with how to keep Postulo going, not the health check.
      The last card held two lines an administrator reads once: the health-check address and
      the Django admin's escape hatch. The address moved up into the Software card, where a
      fact about the software belongs, and the admin hint lives in the wiki's Configuration.
      In their place, one card on the page addressed to whoever runs the instance: money, at
      buymeacoffee.com/tiagoagueda with the QR beside the link; code, with the repository and
      how a change lands; translation, since every language but English is a machine draft
      waiting for a speaker. The README and FUNDING.md promise reads, exactly, that nothing a
      person is shown while looking for work asks for money — and names this page as the one
      place inside the application that mentions support at all. (#199)

    • What Postulo ships is the administrator's to switch, not yours — and is out of the
      way until you ask.
      Settings → Plugins offered a checkbox on every plugin, the built-in
      ones included, so Several telephone numbers, the email notifier and the local store
      were switches a person could untick for themselves. They no longer are. A plugin shipped
      inside Postulo is decided by an administrator, for one person or for everybody, and the
      page lists only what was installed on the instance unless Show the plugins Postulo
      ships
      is ticked — and then shows them without a switch. The one thing that always shows
      is a built-in an administrator decided for your account: a decision held over an account
      never hides behind a check mark. Breaking: a choice you had made against a built-in
      plugin no longer counts, and the upgrade forgets it. (#200)

    • The support link is a QR code now, because a button can only be pressed by whoever is
      already holding the device.
      A README is read on a laptop, shown over a shoulder,
      projected in a talk, pasted into a screenshot. In every one of those the Buy me a coffee
      button was visible and unusable. The code is the same link in a form a second device can
      pick up, and it replaces the button in the README, in FUNDING.md and on the wiki's home
      page. The wiki sidebar keeps its text link: a sidebar has no room for a code.

      It was read back rather than trusted. The code has little margin — stylised round
      modules with the cup over the centre — and scaling it is not a matter of picking a round
      number: a strict decoder reads the 3000px original at 300, 330, 375 and 440 pixels and
      fails at 360, 400, 500 and every size above. 330 is shipped because it decoded under every
      resampling tried, and assets/support/NOTICE.txt records that, along with what was done
      to the file and what it resolves to. A phone is far more forgiving than a strict decoder;
      anything that regenerates or resizes this file should still read it back first.

      TRADEMARKS.md covers the code as well as the banner now. The banner file stays in the
      tree, unshown, because links to it exist outside this repository. Nothing about the
      promise changes: this is still the only place Postulo asks, and nothing inside the
      application ever will
      . (#172)

    • A plugin's kind and where it came from no longer wear the same badge, and the kind is a
      word in your language.
      Both pages that list plugins drew two different facts as
      identical grey pills side by side, so nothing said that importer and official answer
      different questions — and the kind was the raw slug, so a French reader was shown "source"
      and "feature" in English. The person's own Settings → Plugins did not show where a plugin
      came from at all, which is the page where it matters most.

      The kind now takes colour, because it is a category from a small fixed set and colour is
      what makes eight of them scannable in a list. Where it came from stays grey, and that
      is not a default: server/plugins.html has said since #94 that provenance is "deliberately
      not styled as a reassurance — installing a plugin runs somebody else's code whatever this
      says"
      , and a green Official badge would undo that sentence. Both pages now draw both
      tags through one partial, so they cannot drift apart again, and the origin appears on the
      settings page for the first time.

      Colour is never the only carrier: every tag says its own word, each tone reaches 4.5:1
      against its own ground in both themes, and the palette repeats across the eight kinds on
      purpose — eight hues told apart at a glance is more than a palette honestly gives, so kinds
      rarely seen together share one and lean on the word. A kind this version does not know
      shows its slug in the neutral tone rather than nothing, because a third-party plugin may
      declare one added after this release. (#184)

    • Grids use the whole screen; everything else keeps its measure. Every page sat in a
      1280-pixel column — max-w-7xl on <main> — so on a wide monitor a table with ten chosen
      columns scrolled inside a box with grey on both sides, two nested scrolls to read one row.
      <main> takes its width from the page now: the tables, the board, the dashboard and the
      lists empty the cap and take the screen; forms, detail pages and prose keep the measure
      they had, because a wide page is not a wide paragraph. The one criterion about line length,
      SC 1.4.8, is level AAA, and its eighty-character measure is what the forms' max-w-2xl
      already is. The header and footer span the width, so a masthead never sits narrower than
      the table under it. Below 1280 pixels nothing changes. (#188)

    • Server overview: the card at the bottom has lost its heading. It said Also, which
      names nothing — the other headings on that page are Overview, Software and Data, each
      saying what is under it, and this one said only that there was more, which the reader can
      already see. It is the heading somebody writes when a card has collected what did not fit
      elsewhere, and that is what the card is: a link to the health check, and a link to Django's
      admin or a note saying it is off. Both say what they are and where they go, so the card
      carries itself. (#183)

    • Twenty-four languages are no longer translated before anybody has read the English
      once.
      test_every_european_union_language_stays_complete held the twenty-four European
      Union catalogues at 100% on every commit, which made adding one user-facing string cost
      twenty-four translations — made by the same hand that wrote the English a minute earlier,
      on wording that had not settled, for no reader. The promise was always about a published
      version
      : what somebody installs should not have a gap in a language Postulo offers them.
      It was never about a branch halfway through a feature.

      There is now a release marker — a promise that must hold in a published version, not in
      every commit
      — deselected by default exactly as e2e already is, and
      uv run pytest -m release is step 1 of Making a release, before the changelog is
      moved or anything is tagged. The same twenty-four must be complete, checked by the same
      assertions; the check is simply first and deliberate rather than implicit in a suite that
      had already run. Ordinary work translates English, French and European Portuguese, which is
      enough to see a change in three languages and catch what only shows up in one — the long
      German string that broke a row in #165 was found that way.

      Nothing else is marked. Plural rules, placeholder consistency, catalogue currency, the
      "a started catalogue actually translates" gate and the render-in-every-language walk still
      run on every commit, because those catch mistakes rather than measure completeness. The
      risk is stated plainly in the issue: somebody can now commit a string only three languages
      have, and that is only safe while the release step stays a hard gate. (#182)

    • Every API Postulo has is in the wiki, and a test says when one is not. The API named
      every call but kept the scope of each in prose, and the three addresses that answer machines
      about the instance — /healthz, /metrics, /logs — were rows in Configuration that
      never said what they return. They have Health, metrics and logs now, down to each metric.
      The plugin guide was the core repository's docs/PLUGINS.md, not the wiki, and it never
      named the interface each kind of plugin satisfies; it is Writing a plugin in the wiki, with
      every kind, its entry-point group and its interface, and every name postulo.plugins.api
      promises. docs/PLUGINS.md stays as a pointer, because every plugin's metadata links to
      it. tests/test_wiki_surface.py reads the wiki beside the checkout against the code, both
      ways, so a call, a metric or a promised name without its line fails. (#170)

    • The wiki is written where it is read. Its pages lived in this repository's wiki/ and
      reached the Forgejo wiki only when somebody ran scripts/publish-wiki.sh. By the time
      anybody looked, four pages — Accessibility, Hardening, Listings, Reports — had
      never arrived, the other seventeen were five days old, and the images had never been
      copied at all, so the wiki's front page had shown its logo broken since the day it was
      added. The pages now live only in the postulo.wiki repository, the script and the copy
      are gone, and CONTRIBUTING.md says how a page is written there. (#169)

    • The dashboard is a grid of four columns, and a widget can be dragged into place.
      The last of three: #123 settled where an arrangement is stored, #124 settled how a widget
      is moved by somebody not using a mouse, and this is the grid itself and the gesture.

      Four columns rather than five, because four is the count in which the three widths
      every widget already declares — a quarter, a half, a whole row — mean what they say. Five
      has no half; a half-width widget in five columns is two columns or three, and every widget
      would have had to be re-measured against a grid that divides by nothing.

      A widget has a width and a place in the order; the row falls out of the two. That is
      the whole model, and the reason it is worth stating is what it makes impossible: there is
      no way to leave a hole in the middle of the page, no way to put two widgets in one cell,
      and so no validating, no repairing, and no answer needed for what happens when a plugin is
      uninstalled and its widget goes — the rest close up. A coordinate model would have needed
      all three.

      Dragging is added on top of the four arrows and does not replace them, which is the rule
      this project has followed since the board learnt to drag: drag and drop fires on neither a
      touch screen nor a keyboard. A drop posts to the same address the arrows post to and gets
      the same sentence back saying which row and place it landed in, so a page arranged by
      dragging and a page arranged by pressing arrows are the same page, saved the same way.
      Nothing is draggable until the script makes it so, because an affordance that does nothing
      is worse than none.

      Widths stayed out of the person's hands on purpose. A width is the widget's own statement
      about how much room it needs to be legible — a six-stage funnel is unreadable in a quarter
      of a row — and what is being arranged is the order. A narrow screen gets one column, read
      downwards; #73 is where the phone gets its own attention and this does not assume it
      solved.

      The "internal widgets plugin" the issue asked for is the registry that already exists,
      named as one. Making a widget a plugin kind would put seventeen rows in Server settings
      → Plugins
      for an administrator to switch off, and would need an entry-point contract
      before anybody outside has asked for one; the part of that contract that actually matters
      — a key that cannot collide with another provider's — shipped with #123. (#125)

    • A column header clicks back to no sort at all, and a column can be dragged wider.
      Two of the four features Dispatcharr's channel table had and Postulo's did not; the other
      two arrived with their prerequisites — selection with bulk actions, and editing in the
      cell — which is why these two were left until last rather than done first.

      Sorting was two-state and never returned to the table's own order, so there was no way to
      undo a sort except by editing the address. It cycles through three now. A column that is
      the table's default sort keeps two, because there is nothing to go back to and a third
      click that changes nothing is worse than two honest states. And since the third state
      takes the arrow away, every header now says in words what clicking it would do.

      A width is a preference, so it lives where preferences live. Beside which columns show
      and how many rows a page holds, on the profile, following the person to every device
      rather than cluttering every link — which is the line this project already drew between a
      question and a preference, and the one thing from that table that was deliberately not
      adopted: theirs keeps the query in session storage and the sort in memory, so a reload
      loses the sort and a filtered view cannot be sent to anybody.

      The handle is the one place a script is unavoidable, since a width is a pointer gesture.
      It is still an addition rather than a replacement: without a script no handle exists at
      all and the columns size themselves exactly as before. And it is not pointer-only — the
      handle is a button, the arrow keys widen and narrow it, and Home lets the column size
      itself again, because a column dragged too narrow once must not be too narrow for ever.

      Row reordering was not taken and is not meant to be: a channel list has an order somebody
      chose, a company list has an order somebody sorted, and dragging a row in a sorted table
      means either abandoning the sort or lying about it. (#136)

    • The two halves of choosing a company's sector meet, and the consequences are handled.
      The list is NACE Rev. 2.1 and the picker is chips; what was left was everything that
      follows from a vocabulary that can now be long.

      The companies table draws industries as labels rather than a comma-separated run, showing
      four and counting the rest — a bank that is also an insurer and a software house is three,
      a conglomerate is more, and the cell is already narrow. Filtering stays a box you type in
      rather than a menu, which is the same control at thirty-two names and at three hundred and
      the only one that stays usable at both.

      Companies → Industries gains a search that matches a name or a NACE code, shows each
      code beside the person's own word for it, and says Edit or merge rather than Edit,
      because merging is exactly the tool somebody needs after picking from a standard list
      beside their own words. Only what has actually been given to a company appears there — the
      classification is a list of suggestions, not a list of rows, which is what made it safe to
      make the suggestions long.

      Nothing renames a word somebody wrote themselves, and a company in three fields still
      counts in three. (#141)

    • Industries and tags are chosen as labels now, not as tick boxes. Industries were a row
      of checkboxes; tags were Django's default scrolling box you ctrl-click. Neither was a
      label, and nothing in Postulo drew a chip — so this is a new control rather than a
      restyling of an old one, and it is written once and used in both places.

      Layered, never substituted. The checkboxes, the multiple select and the box for a name
      that does not exist yet are all still in the page, still submitting; the chips are drawn
      over the top and those are hidden. With the script blocked the forms are exactly what they
      were, which is the rule the board's dragging already follows.

      A name that is new looks new before anything is saved — outlined rather than filled —
      because otherwise people create Fintech, FinTech and fintech and find out afterwards
      that the slug collapsed them. Tags gained the same "add one that does not exist yet" that
      industries always had, matched by slug, and a tag made this way keeps no colour: colours
      are chosen on the tags page, where there is room to see them beside each other.

      The keyboard vocabulary is a decision, and one of its conventions is deliberately not
      followed.
      Enter commits what was typed rather than submitting the form; Escape abandons
      it; the arrows walk between labels, mapped through the reading direction; every remove
      button carries the name inside it, so eight of them are eight different announcements
      rather than eight identical ones; and adding or removing one says so in a live region.
      Backspace does not delete the last label — it is the convention, and it is also a way
      to delete something by pressing the key you press to correct a typo, in a control whose
      values are somebody's own words.

      The remove button is 24 by 24 with room around it, which SC 2.5.8 has caught this project
      over twice already, and the × sits at the end edge rather than the right. (#139)

    • A widget can be placed in two dimensions without a mouse. Move up and move down
      are a complete vocabulary for a list and not for a grid, and app.js states the rule that
      makes this a prerequisite rather than a refinement: drag and drop does not fire on touch
      screens and is not reachable from a keyboard, so it is an addition to the control that
      works everywhere, never a replacement for it. The grid cannot ship until the control
      exists.

      The dashboard is a flow rather than a matrix, and that chose the mechanism. Widgets
      have widths and fill rows in order, so there is no cell to name — which rules out a
      row-and-column picker, and rules out a "move this one, then choose a destination" mode
      that would need two interactions and state between them to work with scripts off. What is
      left is four directions over the order: left and right move one place, up and down
      move a whole row. On a narrow screen there is one column and the two axes are the same
      move, which is what up means when there is only one column.

      Every one of them is still a form that posts. An arrow that cannot act is disabled rather
      than absent, so the cluster keeps its shape and the arrow somebody reaches for is where it
      was last time; the redirect carries a fragment so focus lands on the widget that moved;
      and a message says which row and place it landed in, because a move that happens in
      silence is a move somebody using a screen reader has to go looking for. Two new arrows
      joined the icon set, and the stylesheet already mirrors that pair for right-to-left. (#124)

    • Every account owns its dashboard arrangement from the day the account exists. Nothing
      was ever shared between accounts — two people who had never arranged anything were looking
      at the same list of keys, each computed against their own records — but the arrangement
      itself belonged to nobody until somebody touched the setting. It is stored now, from the
      moment the profile is, which is what a grid needs before a widget can be dragged into
      anything.

      The null that meant never arranged was load-bearing, and what it carried is now
      written down.
      It made a widget added in a later release appear for anybody who had never
      arranged their dashboard. With every account holding a list, nobody is ever "never
      arranged" — so the rule moves to a seen set: every key an account has already decided
      about, and a key in neither list is new to that account. That is the one of the three
      candidates that also works when the new widget arrived with a plugin installed on a
      Tuesday rather than with a release, which a generation marker could not.

      The trade is made knowingly. A new widget no longer walks onto a page by itself; it
      waits on the arrange page under New, with the dashboard naming what is waiting, and
      saying no is an answer that is remembered. Strictly that is one fewer thing happening
      without being asked — the old behaviour changed somebody's page during an upgrade.

      And the key namespace is decided while it is free: a bare key is Postulo's, anybody
      else's is provider:key, and registering the wrong shape is an error at start-up. A key
      lands inside every stored arrangement, so a collision found after people have arranged
      their dashboards is a data migration of every one of them. The arrangement travels in the
      archive now too (format 12), and the seen set with it — without that, a restore would
      announce every widget in Postulo as new to somebody who has been reading their own
      dashboard for a year. (#123)

    • A theme says which kinds of document it sets, and nothing offers a pairing it cannot
      produce.
      A theme used to be two things at once: two choices frozen into the model, and a
      directory holding one template per kind. Two themes and two kinds is four templates; five
      kinds is ten, and a theme that had never been taught a kind resolved to a path that did not
      exist — a missing-template traceback at the moment somebody pressed Export PDF, which is
      the worst possible time to find out.

      A theme declares what it sets by having a template for it, so the two cannot drift
      apart, and the picker on each form offers only the themes that set that kind. Falling back
      to plain instead would have put somebody's Classic CV beside a plain portfolio in one
      envelope, and the pair would not have looked like one person's application — the same bad
      outcome, said too late to do anything about. Refusing is not the harsher answer here; it is
      the same answer, in time.

      A theme name nothing recognises is a different question and gets the opposite answer. A
      theme that cannot set this kind is a live choice somebody could still make. A theme left
      behind by a plugin that was uninstalled is a row remembering something that has gone, and
      refusing there would mean removing a plugin had quietly taken somebody's CV with it. So
      that one falls back and still exports.

      Theme stops being TextChoices, because choices are a migration boundary. They are
      written into every migration that touches the field, so a theme arriving from an installed
      plugin could never have been one of them. The column is a validated name now and every
      existing row keeps its value; the validator travels with the column rather than living only
      in the form, because the form is one of several doors.

      Themes may now arrive from an installed plugin, and from nowhere else. A plugin
      declares them and ships a templates/ directory beside its package, exactly as it already
      ships locale/; registering the plugin puts that directory on Django's search path,
      appended, so a plugin can add a page of markup but never replace one of Postulo's. There is
      no upload form for themes and there will not be one: rendering executes the template, so an
      uploadable theme is remote code execution with a file picker on it. A plugin's markup runs
      because an administrator installed the plugin, having read its author, licence and source —
      and the picker says so, naming a plugin's theme with whoever provides it, because the menu
      is the only place a person meets a theme.

      Theme and ThemeKind join the plugin surface, which settles one of the two questions
      postulo.plugins.api had listed as open. Honouring the document's language and direction
      is part of the contract a theme takes on, stated rather than hoped for, and each of
      Postulo's own is held to it by a test that renders an Arabic CV and looks for the
      direction. (#132)

    • A rendered document points at whatever made it, rather than at one of two columns. The
      documents app already held two authored kinds, and the plumbing around them named both:
      RenderedDocument had a cv and a cover_letter, DocumentCopy had a rendered and an
      upload, and archiving.py asked isinstance for what the columns could not say. A
      portfolio, an email or a report would each have been two more nullable columns on two
      models, a branch at every reader, and a migration.

      CVItem decided this the other way one model over, and its docstring is the argument: six
      nullable foreign keys with a check constraint say the same thing less clearly and need
      widening every time a kind is added. Both links are generic now, so the next kind of
      document is a package.

      The two on_delete behaviours are not the same one, and a generic link has neither, so
      both are written out.
      Deleting a CV must not delete the PDF an employer received — that
      is the whole point of the model — so a receiver clears the link, which is the SET_NULL
      the column used to carry. Deleting that PDF must delete the rows saying where its copies
      went, so the cascade lives on a GenericRelation at each document. Getting either
      backwards would lose somebody's record of what they sent, and each direction is a test.

      The archive carries a kind and a local id instead of two columns (format 10), and the
      importer reads both shapes. Listing documents with where their copies went is still two
      queries rather than one per row — a generic link has no join to follow, so the batch
      lookup is what answers for it. (#130)

    • Choosing a language is one control now, and it says how each translation was made.
      Thirty-nine radio rows sat beside a time zone field that is a single line, and the list
      will only get longer. It is a disclosure: closed it shows the language in use — flag, name
      and state — and open it is the same rows as before.

      Not a <select>, and that is not a shortcut taken. An <option> holds text and
      nothing else. It takes lang on itself, so a dropdown can say this option is Greek and
      no more: a flag inside the option text is read out beside a name that already says what it
      is, a symbol saying how the translation was made would be announced as part of a string
      claiming to be Greek while being neither Greek nor a word, and there is nowhere at all for
      the percentage a partly-translated language shows. The request asked for a flag, a name and
      a symbol in one control — which is exactly the combination an <option> cannot hold.

      So the shape is what changed. Each language keeps its own lang, the flag stays hidden
      from screen readers, and everything about the state of a translation now sits outside that
      span in the interface language — including the percentage, which used to be appended to the
      language's own name for want of anywhere else to put it.

      Every symbol is accompanied by words. A glyph alone means nothing to somebody who
      cannot see it and is a guess for anybody who has not learnt it, so each has a phrase read
      out beside it and the legend is on the page rather than in a tooltip a keyboard cannot
      reach. And the words are written, not yet read by a speaker rather than anything about
      machines: pt-BR was seeded from pt-PT and adapted by hand, and what is true of every
      language in that group is that no speaker has read it.

      The keyboard is the cost of not using a dropdown, and it is paid. Arrow keys come from
      the radios themselves; Home, End, type-ahead on each language's own name, Escape and
      returning the focus are given back in a few lines. None of it is needed for the control to
      work — the disclosure opens and the radios submit with scripts off entirely. The browser
      suite walks it with a keyboard. (#119)

    • The image now contains what it runs, rather than everything used to build it. The
      Python side of the build was a single stage, so every intermediate was already sealed into
      a layer by the time anything could remove it — a later RUN rm frees nothing, because the
      bytes are below. That is how 296 MB of uv's download cache shipped, and it is why 20 MB of
      .po source catalogues shipped too: Django reads the compiled .mo, and nothing at run
      time has ever opened a .po.

      Building the environment and compiling the catalogues in a stage of their own makes the
      deletion real rather than decorative, and makes the same true of whatever the build needs
      next: what is not copied forward is not there, with no flag for anybody to remember. The
      extra-packages step gets simpler as a side effect — git is left behind with the stage
      instead of being purged — and collectstatic runs as plain python, so the lock and the
      manifest no longer have to be present in the shipping image purely to satisfy uv run.

      Two savings are deliberately not taken, and the reasons are beside them. uv stays: 45 MB
      of binary, and dropping it would not break installing a plugin through the interface but
      would make it slower and hand an operator a resolver this project does not test with.
      Django admin's collected assets stay: the admin's URL is read at run time and
      collectstatic runs at build time, so making it a build argument would hand somebody an
      image whose admin page loses its stylesheet the day they enable it. Neither is worth what
      it saves. (#157)

    • Every plugin Postulo ships is now its own package, and a test says so. They were
      scattered through the applications they happened to be useful to — the notifier and the
      transport in notifications, the store in documents, the importer in resume, and the
      telephone-numbers feature written entirely inside core a week after the plugin
      documentation said not to. Each is now postulo/plugins/<name>/, with its manifest, its
      catalogues and its code in one place, exactly like a plugin somebody else writes.

      The measure was never that the directories moved. It is that the next one cannot be
      written in the wrong place without something failing, so the test takes its list from the
      registry rather than from a list in the test: a built-in added tomorrow is checked
      tomorrow, including the one nobody remembers to add to a list. It fails on a plugin
      outside postulo.plugins, one with no catalogues of its own, and one that imports
      anything from Postulo but the declared surface without a written reason.

      Nothing Postulo ships touches the database any more, which is the part worth keeping.
      Getting there meant drawing two lines the code had already argued for: the career record
      an importer fills in is Postulo's shape rather than Europass's — so Postulo defines it,
      every importer fills the same one, and writing it moved to resume.importing — and the
      store contract a plugin author writes against joined postulo.plugins.api, so the local
      store imports it from where everybody else does. Ownership scoping done wrong in a plugin
      is how one person sees another's data; the way not to get it wrong in seven places is not
      to need it in seven places.

      Twenty-seven strings moved into six new catalogues, each keeping the translation it
      already had in all thirty-nine European languages. Four dependencies on core disappeared
      outright; the five that remain are recorded with the reason each is a reason to depend on
      Postulo rather than a failure of discipline. Built-ins still register through
      register_builtin() rather than an entry point, deliberately: an entry point would be a
      slower import of a module in the same distribution, and would cost the documented
      ordering that lets a third-party plugin take precedence over Postulo's own. (#129)

    • A plugin's state is drawn around its checkbox, not only inside it. Settings →
      Plugins
      is a column of rows, and reading which of a dozen are on meant looking at each
      13-pixel tick in turn. A ring of colour — green around a ticked box, red around one that
      is not — makes the column readable in one pass.

      It says nothing the checkbox was not already saying. The tick is what a screen reader
      announces and what a keyboard toggles, so the colour is redundant by design and WCAG
      1.4.1 never comes into it; this would be a different change if the tick had been replaced
      by a colour. box-shadow rather than outline, because :focus-visible owns the outline
      throughout this stylesheet and the two would otherwise fight — a keyboard user tabbing
      down the list would lose the focus ring on every row. Shadows and outlines paint
      separately, so both show at once.

      A row an administrator decided keeps the colour and loses the halo. A full-strength
      red ring around a control that is not yours to change reads as a fault you are being
      blamed for, which is the opposite of what that row is there to say. Both themes are
      defined rather than left to whichever ground the glow lands on. (#122)

    • Reading a Europass CV is a plugin now. The reader was already shaped like one — it
      decides which of the two formats it has and dispatches, which is the same split sources
      have used since the beginning — so this mostly says so out loud: a new importer kind, and
      Europass registered into it the way the built-in notifier and document store already are.
      The import page asks the registry what this instance can read instead of naming Europass,
      which is the whole point: the next format somebody wants is a plugin rather than a patch to
      a view. One importer, two formats, because that is what the code is — both readers
      produce the same record and apply() is shared, so two plugins would be one thing
      described twice. On the way, the refusals that keep a hostile file away from a parser moved
      from Europass to the kind: an empty file, anything over the cap, and a DOCTYPE in
      anything that looks like XML
      , which is where entity expansion lives. Those are a threat
      every importer faces rather than one Europass happened to think about, and "every plugin
      author remembers" is not a control. apply() stays in core — an importer turns bytes into
      a record and never touches the database, which is what keeps one from needing ownership
      scoping of its own. (#99)

    • TRADEMARKS.md says what the licence cannot. The code is AGPL; the name and the logo
      are not, and until now nothing said so — a reader had no way to know what a fork may call
      itself. That matters here more than in most projects, because the README makes four
      promises and a licence cannot enforce a promise: somebody could fork this, put a feature
      behind payment, keep the name, and everybody who had heard never paywalled would have
      been told something untrue. Reserving the name is the only instrument that speaks to that,
      and it is AGPL-3.0 §7(e)'s own provision rather than a restriction bolted onto a free
      licence. The document leads with what needs no permission, because that is nearly
      everything: run it, fork it, redistribute it unmodified under its name, say truthfully
      that your thing works with it, name a plugin postulo-something — eight repositories
      already do, and a note that put them in the wrong would cause exactly the harm it was
      written to prevent — and call your instance whatever you like. Another name is asked for
      in one case: a materially modified fork that somebody could install thinking it was this.
      It also closes a real gap rather than a hypothetical one. assets/support/buy-me-a-coffee.png
      has been in the tree since the support banner landed, with nothing beside it saying whose
      mark it is
      ; it now carries a notice, as the flags already do. Lucide and flag-icons were
      fine — those are copyright licences and were satisfied. A mark is not licensed at all,
      which is why it needed a different kind of note. Nine tests hold the document to the
      artwork actually in the tree, so vendoring something new without a line for it fails.
      (#107)

    • FUNDING.md says what the funding link means. .github/FUNDING.yml held one URL,
      and a URL cannot say what is being asked for, what it changes, or — the part that matters
      — what it does not. So the companion says it: that nothing is ever paywalled and no
      feature will be held back and sold separately; that money buys no influence, moves no
      issue up the list and does not outweigh a good bug report from somebody who has given
      nothing, because a project that sells priority has quietly become a product with
      customers; what support actually pays for, in categories rather than amounts, since
      publishing a figure implies a threshold and a threshold implies something happens when it
      is missed; and what is worth as much from somebody with no money — which, for a great many
      people looking for work, is the honest position. It ends by telling anyone who would
      rather give to a person in worse need to do exactly that. (#89)

    • Export has left the account menu. It is a settings section of its own — Settings →
      Your data
      , beside deleting the account — so the header was a second permanent route to
      the same page, in a menu of five items where every other entry went somewhere different.
      A menu with two ways to one place is a menu people learn to stop reading. Nothing is
      harder to reach: the settings section is the home, the delete-account page still offers
      the archive
      (leaving without your data should never be the easy path, and that is the
      moment it matters), and the dashboard's Shortcuts widget keeps its link for anybody who
      put it there — which is not the same as the application putting it in everybody's header.
      Taking data out remains one of the four commitments; what upholds it is that the export is
      complete, documented and one button, not the number of links pointing at it. (#86)

    🐛 Fixed

    • The interface stopped deciding in English what it would say in sixty-eight other
      languages.
      Four habits, each invisible to anybody reading Postulo in English, which is
      why each had lasted. The bulk bar counted ticks in the browser and chose between "One row
      ticked." and "%(count)s rows ticked." with n === 1; that is the English rule and thirteen
      of the languages offered disagree with it, Polish needing a third form for 5 and up and
      Ukrainian putting 21 back in the first. Nothing portable fixes that in JavaScript, and
      nothing has to: a page holds a known number of rows, so the server now writes out the
      sentence for every count the bar can reach and the script indexes it.

      The password meter appended zxcvbn's own advice to the translated strength word, and only
      the English zxcvbn pack is vendored, so a French reader was told "Fort · Add another word
      or two" — inside a polite live region, on every keystroke. The advice is now shown where
      the pack and the page agree on the language and left out where they do not; the day
      language-fr.js is vendored, naming it on the template turns it back on. The live region
      is the strength word alone, and the word is written only when it changes, so typing a
      password no longer interrupts a screen reader five times a second.

      Seventy-three date formats were spelled out in templates and views — j M Y, j M Y, H:i,
      M Y — which fixes day before month before year and the hour at 14 rather than 2 p.m. for
      every language at once. That is already wrong for Hungarian, which writes the year first,
      and for Lithuanian, which marks it; it would be wrong for most of what #71 adds. They ask
      for Django's own names now, which resolve against the reader's language. Only Django's
      names: get_format hands an unrecognised name back to the page, so an invented format that
      one locale has not defined prints the literal word SOME_FORMAT to whoever reads in it,
      and there is a test over languages.LANGUAGES that every name answers in every language
      offered. Thirty of those languages have no format module in Django at all and used to land
      on its American default; they now fall back to the British English the interface is written
      in, which is what they were already seeing. British English keeps its 24-hour clock through
      a format module of its own, because Django's en_GB says "2.30 p.m." and no template
      Postulo has shipped ever did. A test_template_lint.py rule fails on a format written out
      in a template or a view, so these do not come back one page at a time; a bare Y and the
      Y-m-d an <input type="date"> parses stay written out, with the reason beside them in
      the lint. Percentages were
      fixed as {{ share }}%, which is neither the French "42 %" nor the Turkish "%42", and are
      now one string the catalogue can rearrange.

      And four sentences were being assembled from pieces. An API token said "created" and then a
      date, "expires" and then a date; a translator was handed half a clause and no promise about
      which side the date would end up on. The board's explanation ended at a semicolon, with
      "see them in the table" translated on its own and the full stop written in the template, so
      the link could not be moved and the sentence could not be ended anywhere else. The quiet
      threshold had a bare "days" after a number field, with no singular for 1 and nowhere to put
      a second plural. Each is now a whole sentence with the date, the address or the count as a
      placeholder.

      An English reader sees three differences, all of them Django's en_GB rather than
      Postulo's: a calendar heading and the three detail-page dates that spelled the month out
      now abbreviate it, the compact 16 Sep in rows and CV columns now spells it out, and the
      comma between a date and a time in six places is a space. The dashboard's next-interview
      column was widened to hold a month with its name in it, which is what most languages were
      always going to need. (#225)

    • Paging to the last page no longer costs you your place. The sort and pagination
      controls were given ids so that htmx could put focus back after a swap, which works for a
      control that survives the swap and not for one that removes itself: pressing Next onto
      the last page takes Next off the page, so there was no longer anything for focus to
      return to and it fell back to the top. Somebody working through a long list by keyboard
      reached the end and started again at the skip link. A control that can be swapped away now
      says which group it belongs to, and focus goes to whatever is left of that group — here,
      Previous. (#227)

    • The page script stopped failing in silence. Four things went wrong without saying so,
      and what they had in common is that the page went on looking correct afterwards. A filter,
      a sort or a page link is an htmx request that replaces a table, and htmx does not swap a
      500 — so a request the server refused, and a request sent from a train with no signal, both
      left the previous rows sitting where they were. Nothing listened for either failure and no
      template had ever named an indicator, so the honest reading of the screen was that the
      filter had found those rows. There is one role="alert" region on every page now, empty
      until there is something to report, filled by a handler delegated from the document; it
      says which status came back, because 503 and 500 are different problems, and it says that
      nothing on the page changed, because that is the part nobody can see for themselves. The
      part being replaced carries aria-busy while its request is in flight — the element htmx
      marks is the sort link, and what a person is waiting for is the table.

      A session that had expired filled the table with the sign-in page. An XMLHttpRequest
      follows a redirect without telling the script it happened, so htmx never saw the 302 that
      means sign in first: it saw the 200 the sign-in page answered with and did what it does
      with a 200. Leave a filtered list open over lunch, touch a filter, and a masthead, a footer
      and a password field appeared inside the table, on a page that still looked signed in. A
      redirect to the sign-in page answering an htmx request is now HX-Redirect, which sends
      the browser there as a page and keeps the ?next=, so signing in comes back to the list
      that was open. Only that one destination: every other redirect a view makes is one it
      meant, and the swap that follows is what it was written to expect.

      The back button gave back controls that no longer worked. A table swap pushes an
      address, and htmx kept a copy of each of those pages in sessionStorage. Restoring one put
      the column-resize handles, the Select all button and the label chips back into the markup
      with none of their listeners, and the functions that would have attached them then skipped
      them because the markers were already there — so Back produced a page whose controls looked
      exactly like themselves and did nothing at all. It also left applications, companies and
      people in sessionStorage, where signing out in the same tab does not touch them. htmx
      keeps no copy now and Back asks the server, which costs a page load and is worth it twice
      over; the views have answered a restore with a whole page since they were written. An
      instance upgrading to this clears what is already in that store the first time a page is
      saved.

      And the export buttons locked after the first press. The guard that stops a double
      click marks a form and lets go again on pageshow — but a form whose answer is a file
      never leaves the page, so pageshow never came. Download the archive, Export PDF and
      Download PDF were one-shot buttons for the rest of the visit, including the one on the
      page that asks you to take a copy of everything before deleting your account. Those forms
      say what they are, and the guard lets go of them a few seconds later. A delay rather than
      an exemption: the accident it exists for is a double click, which happens inside a second,
      and a second export a minute later is not an accident.

      Underneath, the six functions that add something to swapped-in markup share one helper
      instead of writing their own three registrations each — the two written last had forgotten
      the swap, so a dashboard widget that came back in one could not be dragged. The count on
      the server log page is no longer a live region, since that page filters with a whole page
      load and had never had a change to announce. (#226)

    • One slow request no longer stops the whole instance. Every request was a transaction,
      and on SQLite Postulo opens transactions immediate — the write lock is taken before the
      view runs and given back with the response. For a page that is milliseconds, and it is the
      price of two requests never colliding. But a capture waits up to ten seconds for somebody
      else's web server and five more for their robots.txt; a CV can be a whole Chromium; an
      export reads every record and every file an account owns. Each of those held the write lock
      for every second of it, and the three gunicorn workers, the scheduler and the task worker
      queued behind. Anything still waiting after twenty seconds failed with database is locked,
      which is how one capture from a slow job board became somebody else's error page.

      Those views have left the request's transaction and wrap their own writes instead — the
      capture row, the logo, the snapshot and what saving it schedules, and what an application
      is told was sent with it. The rate limit is deliberately outside: an allowance spent making
      the server fetch a page has been spent, and rolling it back with a failed request is how a
      limit becomes no limit. The export is the one read worth protecting, so the archive's
      manifest is still read inside a transaction while the files, which never had that
      guarantee, are copied outside it. This is view by view rather than, say, all GETs at once,
      because leaving a transaction is a decision about what has to succeed or fail together and
      there is no answer to that which is true of every view of a given method.

      The two pages that say what an export contains built the whole export to find out.
      Every record the account owns, read, nested and turned into JSON, so that eight numbers
      could be printed — on the page offering the download, and on the page asking whether you
      really mean to delete your account, which is the one page in Postulo meant to be read
      slowly. They count now.

      Pressing Send with a CV and a letter starts one Chromium rather than two. Launching
      the browser is most of what rendering costs on that backend, and it was launched and torn
      down once per document. A report downloaded twice is also drawn once: that PDF is handed
      over and filed nowhere, so the bytes are kept against the SHA-256 of the HTML they came
      from, for a few documents, in the worker that drew them. Never for a snapshot — what an
      employer received is drawn afresh, because a record that is a copy of something else is not
      a record.

      And the container gives a worker two minutes rather than gunicorn's thirty seconds.
      Thirty is a budget for a page and not for drawing a PDF on a Raspberry Pi, where the worker
      was killed part way through with no answer and nothing in the log but a silent restart.
      GUNICORN_CMD_ARGS is the one lever over any of this, it is documented, and the image's
      command deliberately repeats none of what it sets. (#220)

    • A plugin reaches every process now, whatever kind it is, and comes back from a restore as
      it went in.
      Four things about installing, switching off and restoring a plugin contradicted
      what Writing a plugin and the Plugins page promise, and the audit found them together
      because they are one story told four times.

      The installer knew four kinds out of eight. The entry-point groups that make a package a
      plugin were typed out when there were four of them — sources, notifiers, stores, syncs — and
      never grew with the registry. A wheel declaring postulo.transports, postulo.outboxes,
      postulo.features or postulo.importers was refused for declaring no Postulo entry point,
      about an entry point Postulo's own documentation had told its author to write; and those same
      four were the only kinds rebuilt after an install, so one that did get in was invisible until
      something else happened to refresh it. Both places ask the registry now, so there is one list
      and it cannot go out of step with itself.

      A change reached only the process that made it. The image serves from three web workers
      and schedules from a container of its own, each with its own idea of what is installed, and an
      install, a removal or a switching off rebuilt that idea only where the request happened to
      land. A plugin an administrator had just switched off went on running in the other three —
      with whatever credentials somebody had given it — and a notifier just installed was "not
      installed" to the scheduler that was meant to send with it. The record on the data volume is
      the one thing all of them can see, so every write moves its stamp and every process rebuilds
      from it the next time it looks a plugin up: at the moment the answer is used rather than on
      somebody's timer. Nothing here writes, which is why it sits beside #221 rather than undoing
      it — that kept the scheduler out of the boot-time sync because two containers were writing the
      record at once, and this is the reading half of the same problem. A first install is also what
      creates the directory, so catching up puts it on the import path of a process that started
      before it existed.

      A restore put back something else. plugins sync reinstalls after an upgrade from what
      the record says, and it dropped two of the fields it was reading. A plugin the administrator
      had switched off came back switched on, which is not a decision an upgrade gets to make; and
      the marker saying which Postulo the plugin is for — which only a catalogue can state, never
      the wheel — was cleared, so a plugin that no longer fits this Postulo looked on the page as
      though nobody had ever asked. The fetch then downloaded whatever the catalogue was offering
      that day and checked it against the checksum of the version that had been installed, so a
      restore failed the moment the catalogue moved on, and would have upgraded the plugin behind
      the administrator's back if it had not. It asks for the recorded version.

      And somebody else's import happened inside somebody's page. A plugin was loaded lazily, in
      whichever request first needed one of its kind, guarded by except Exception — which is no
      guard at all against SystemExit, the thing a module raises when it dislikes its
      configuration, so a broken plugin ended a worker mid-request instead of ending itself. Every
      group is loaded at start-up now, where the log is and where an administrator is looking, and
      the guard catches everything except Ctrl-C. A plugin the registry then turns away for not
      providing the interface it claims no longer keeps what it registered on the way past: its
      templates had already reached the renderer and its catalogue the translator, which are the two
      things a plugin can do to every page on the instance, and both now happen after the checks.

      What the page, the command and Contributing say about this is true as well. A plugin is in
      use everywhere the moment it is installed, and a restart adds nothing — because a plugin with
      pages or tables of its own cannot be installed from a wheel at all. INSTALLED_APPS is fixed
      when the process starts, nothing mounts a plugin's URLs, and migrate has run long before
      anything looks at a plugin, so such a plugin has to be built into the image. Contributing
      says that now, where it used to say "be in INSTALLED_APPS" as though an installed package
      could put itself there. (#228)

    • A document now speaks its own language, and a message speaks the reader's. Nothing
      anywhere switched translation away from the language of the request, which is the wrong
      answer in exactly the two places it matters.

      A CV or a letter declares a language. The words the person wrote were translated by #131;
      the page around them was not, so a French CV exported by somebody reading Postulo in
      English came back headed Experience over Mar 2021 – present, and a letter could carry
      two dates in two languages — one from the template, one from the {{ date }} placeholder,
      which was built by strftime and so was always English whatever anybody had chosen. Both
      are now rendered in the document's own language, falling back to its owner's and then to
      the instance default, and the date is written the way that language writes dates.

      A copy sent to an external store was labelled with the owner's interface language rather
      than the document's, so a French CV arrived in Paperless filed as English — and finding it
      again is the whole reason it was sent there. A render is now filed under its own language;
      an upload, which has no language of its own, still follows the person who uploaded it.

      The title a PDF viewer shows in its title bar, and that a screen reader announces, was the
      variant's name — Backend, English — which is the person's private filing and is marked in
      Postulo as being for them and not for the employer. It is the holder's name and what the
      document is now, and so is the file name that gets attached to portals and emails.

      And a notification is worded in the language of whoever receives it. A reminder announced
      by the scheduler had no request to take a language from, so it came out in the instance
      default however the person had set Postulo up; one announced by a capture arriving through
      the API followed the Accept-Language of whatever tool sent it. Since a notification
      carries words that are already written, switching language as it was sent would have been
      too late — the message is built inside the override now, and the sentence a person reads at
      three in the morning is in their own language. (#223)

    • The PDFs Postulo writes are documents now, rather than pictures of documents. Nothing
      they contained had any structure: a screen reader had no headings to move between, whatever
      an employer's applicant tracking system read the file back with got the words in the order
      they happened to be drawn in, and the language every document has declared since #67 reached
      a reader through a tag tree that was never written. Both renderers are asked for one now —
      WeasyPrint for pdf/ua-1, Chromium for tagged and outline — and the markup gives them
      something to build it from: a job title is a heading under its section's heading instead of
      a bold paragraph, a letter's subject is the letter's one heading, and the file carries its
      author, which WeasyPrint reads out of the document rather than taking as an argument. A CV
      whose contact block is deliberately switched off still names nobody, in the file's properties
      as on the page.

      Deliberately not PDF/A, which is tagged as well and archival besides. PDF/A is a promise that
      the file will still render identically in fifty years, and it is kept by embedding an ICC
      output intent and every font the document uses. Postulo cannot make that promise about a
      theme a plugin ships (#132), and a conformance claim that cannot be honoured is worse than
      one that was never made.

      A long address used to run off the edge of the page and out of the file with it: a
      hundred-and-twenty-character link has nowhere to wrap, and the column it sat in was sized by
      its own content, so it pushed itself past the margin. Every theme breaks one now. The contact
      line's separators were a CSS ::after, and generated content is painted onto the page and
      never written into its text — so whatever read the PDF back got the telephone number run into
      the email address with nothing between them. They are real characters in the markup now, and
      a theme picks which character by overriding a block instead of redeclaring a rule.

      The arrows on a CV still had the bug #203 fixed on the career page. They nudged the order
      number by one, so up at the top did nothing at all, one down could jump past every entry
      that shared a number, and two entries two numbers apart needed two presses, the first of them
      invisible. They swap with the neighbour the page drew and renumber the CV densely afterwards,
      which is the same thing the career page does and the same code doing it; at either end the
      arrow is greyed out rather than removed, so the pair keeps its shape. An entry added to a CV
      takes the number after the last one instead of the count, which after a removal was a number
      something already on the page had, and the new entry landed in the middle of it.

      A cover letter can be read the way the employer will read it. The preview was reachable
      only without an application — the one version of a letter nobody ever sends, because every
      placeholder in it is empty — so the letter's page now offers the applications to read it
      against. A placeholder Postulo knows and has nothing to fill is drawn as a marker in the
      preview rather than as nothing, which is the difference between seeing a gap and reading
      "Dear ,". Send puts the filled letter in front of you before freezing it where there is a
      gap, and only where there is one: a step everybody has to press through is read once and
      clicked past for ever after. ?application=abc used to reach the database as a primary key
      and come back as a 500; it means what it says now, which is no application. And there is a
      {{ contact }} placeholder, because the follow-up starter has asked for a name in square
      brackets since it was written and the application already knew whose.

      The Europass import stops inventing things. A file that states no CEFR level for a
      language had one invented for it — B1 — and printed on a CV; a level is a claim about
      yourself that somebody will test in an interview, so a language may now say that its level
      was never stated, and says nothing at all when it does. Every export names the language it
      was written in and nothing here read it, so a career typed in Portuguese arrived with a blank
      record language and the fallback warnings from #131 then fired on every entry of a CV that
      needed no translation whatsoever. And the skill headings an import writes — "Digital",
      "Job-related" — were fixed English words even on a Portuguese record; they are translated
      now, out of the europass plugin's own catalogues, because core never translates a plugin's
      strings. (#235)

    • Keyboard and focus failings that every accessibility check passed. Seven of them, found
      by using Postulo rather than by scanning it. axe reads a document: it cannot press Tab and
      say where focus landed, it cannot tell that one letter fires an action nobody can switch
      off, and it cannot look at a high-contrast theme. Every page passed while all of this was
      true.

      Focus stopped being dropped. Sorting a table, turning a page and switching theme are
      htmx swaps, and htmx puts focus back after a swap only for an element that carries an id.
      None of those three had one, so Enter on a column header sent focus to the body and the next
      Tab started again at Skip to content — which is the whole page to walk back through, on
      every sort and every page turn.

      A single key can now be switched off. "d" discarded a capture, "j" skipped it and "/"
      took the search box, wherever you were and with no way to stop them. Somebody dictating to
      their computer says every letter of every sentence, and "d" was a listing gone without a
      question. There is a switch under Settings → Appearance, on to begin with because the
      review screen is worked through forty times in a row and the keys are why that is bearable.
      Shortcuts that need Ctrl are not single keys and are untouched, and a keystroke that is part
      of a character an input method is still composing now belongs to the character.

      A field shows where focus is in a high-contrast theme. outline-none on every input,
      select, textarea and table filter compiled to outline-style: none, which beat the rule
      that draws the focus ring and left a border colour and a shadow — the two things forced
      colours throws away. So focus was invisible on every form in Postulo for anybody using one,
      and nothing Postulo runs asks that question.

      A card on the board says which application it is. Thirty menus all called Change
      status
      are thirty controls nobody can tell apart; each is named by its role and employer
      now. The menu also stopped saving on the way past: arrowing down a closed list in Chromium
      on Windows fires a change at every status it goes by, and each of those was a status change,
      a timeline entry, and a job search that did not happen. A choice made with the keyboard is
      saved on Enter or when the menu is left; a choice made with the pointer saves at once, as it
      always did, so advancing a card is still one click. The help explaining the board hung off
      the card, which nothing can focus, so it had never been read to anybody — it hangs off the
      menu it is about. And the cards are made draggable by the script rather than by the
      template, which is the rule the dashboard already followed: with scripts off, nothing offers
      a gesture that cannot happen.

      The heading on a settings page is the page. Settings was the h1 on about twenty
      pages and each page's own name was an h2 level with its sections, so jumping to the first
      heading said "Settings" wherever you were. The sidebar's label is a label now, the page's
      name is its heading, and its sections sit under it.

      The column resize handle tells the truth. It was called Widen Name although the same
      control narrows it with ArrowLeft, an arrow press said nothing at all, and what it did say
      it said through a <caption> — which is a table's accessible name, so announcing a width
      renamed the table to it. It is a splitter with a neutral name and a width it reports, it
      says what it did from a region outside the table, and a drag the browser takes away no
      longer leaves it resizing a column nobody is holding.

      One red button, and Cancel goes somewhere. Four spellings of "this is the dangerous
      one" across twenty-odd templates are .btn-danger and .btn-danger-ghost, so the answer to
      "is this the destructive one" is in one place rather than in whichever template you happen
      to be reading. And Cancel on a confirmation page goes where the view says instead of
      following the browser's Referer, which is absent from a bookmark and from a fresh tab and
      left the dashboard as the fallback — the one place somebody halfway through deleting
      something did not mean to be. (#227)

    • The API's catch-up cursor could not actually be walked. #230 gave every list an
      updated_since cursor — ask what changed since a moment, take the updated_at of the
      last row you read, ask again from there — and two things stopped it working. The moment it
      handed out was rounded to the millisecond while the value it compared against was stored to
      the microsecond, so the timestamp a caller read back named an instant before the row it
      came from, and that row arrived again on the next page. And a moment alone cannot get past
      a run of rows saved in the same one, which is what an import or a bulk edit writes: where
      the run was longer than the page, every page after it was rows the caller already had.
      Together they meant a client either looped or, if it stopped when a page brought nothing
      new, silently never read the rest of the account.

      Moments now go out whole, so a value the API gives is a value it takes back, and the cursor
      has a second half: after_id, the id of the last row read, which turns it from a moment
      into a position in the order the list is already sorted by. Sent without it, a list answers
      exactly as before. The merged /documents list takes no after_id and says why: its ids
      come from two tables, and one id used against both would silently drop files. (#245)

    • Insights, the report and the salary column counted the wrong things. Five figures that
      people read and believe, each of them measuring something slightly different from what it
      said.

      An application went quiet the morning after an interview. Quiet means nothing has
      happened and nothing is planned, and an interview stopped counting as planned the moment it
      ended — so an interview booked three weeks ahead, attended, and not yet written up left an
      application that had been silent for twenty-one days, and the notifier said so. An
      interview still waiting for its outcome now means the application is waiting, whether its
      time has passed or not.

      Withdrawn applications counted as waiting on a reply for ever. Only ghosted was excluded,
      so the Outcomes widget carried a number that could only grow, and withdrawing — which is
      the person saying they have stopped waiting — did nothing to it.

      The report and Insights disagreed about interviews. Insights read them from the timeline;
      the report counted only interviews settled through the diary, so somebody who wrote their
      interviews down as they happened was shown a number by Postulo and a nought on the document
      an employment office reads. Both now use one counter, and an interview typed onto the
      timeline counts as much as one settled from the diary, which is what the handbook always
      promised.

      Moving an interview left its reminder saying the old time. The reminder arrived at the
      right moment naming the wrong one, which is worse than not arriving; the words are now
      rebuilt from the interview whenever it moves.

      And salaries. The spreadsheet importer wrote EUR onto everything while stripping the $
      and £ that said otherwise; 50-60k came in as fifty against sixty thousand, because the
      k was read for one side only; the range separator matched a bare a anywhere, so
      "Salary" split in the middle of a word; and the period was never read, so 15 €/h was
      stored as fifteen euros a year. All four are fixed, the mapping page now offers the
      currency a sheet is in for cells that do not say, and a cell that does say wins. A salary
      on screen names its period — thirty to forty with nothing after it read as a year's pay —
      and the salary column sorts by currency first and then by the figure brought to a year,
      instead of putting every hourly rate below every annual one and mixing dollars in with
      euros. Currencies are three letters, upper-cased as they are typed and refused when they
      are not a code at all. (#224)

    • Every page of the API was built by reading everything first, retrying a capture made a
      second capture, and the schema answered anybody who asked.
      Five faults in the one
      surface the browser extensions and postulo-mcp are built on, found in the September
      audit.

      Each list shaped every row it could see and then let django-ninja cut a page out of what
      came back. Asking for a hundred applications on a thousand-application account ran the
      subqueries, walked the tag prefetch and built an absolute address for all thousand, and
      an agent paging through the lot paid that on every page — so reading a search end to end
      cost the square of its size. A list now hands over the query itself and only the rows
      that survive the cut are shaped. The count is still of the whole list.

      GET /captures was not paginated at all, though The capture API has said since it was
      written that lists take limit and offset and come back as {"items", "count"}. It
      returned the first fifty rows in whatever order the database offered them, so a review
      queue nobody had kept up with simply stopped at fifty with nothing to say so. It is a
      page like the others now, newest first, which changes its shape: a client reading the
      bare array has to read items instead. A test now reads the API's own schema and fails
      on any list that hands back everything at once — the check that was missing, since the
      test reading the wiki page checks paths and scopes and never shapes.

      Capturing was not idempotent, while the code's own comment took retrying for granted. A
      client that sends a posting and never sees the 201 cannot tell a lost reply from a
      lost request, and the safe thing for it to do — send it again — left two captures of one
      posting to decline and two notifications about it. A capture may now carry an
      Idempotency-Key of the client's own invention: the first answer given under that key
      is the answer it keeps getting, for a day, with nothing fetched and nobody told twice.
      /captures/known stays what it always was, advice asked for beforehand, which does
      nothing about the answer that went missing afterwards.

      /api/v1/openapi.json answered without a token. The documentation page has always been
      off, but django-ninja guards the schema only when it is given something to guard it
      with, so the description of every call — and the plain fact that this address is a
      Postulo — was there for anyone who asked, against the threat model's promise that the
      API answers 401 to everything without a live token. It now wants a live token, of any
      scope, or a person signed in to the instance.

      And there was no way to ask what had changed. Lists could be narrowed by the date
      something was applied for and by nothing else, so anything holding a copy of a search —
      an agent, an extension — had to read all of it again and compare. Every list now takes
      updated_since and answers with what changed at or after that moment, oldest change
      first, and every row carries the updated_at to ask with the next time. A deletion is
      not in it, because a row that is gone cannot be listed; the outbound webhooks that would
      say so are their own feature (#240). (#230)

    • A backup now holds what a restore needs, and a restore in a container is something you
      can do safely.
      The archive was the database and the media, and that is not the whole of
      an instance. The plugins live on the data volume — the record of what is installed and the
      packages themselves — and were in no archive at all, so a restore onto a fresh instance
      brought back connections belonging to plugins that were not there, and said nothing about
      it. They go in now, whole; --no-plugins leaves them out for anyone who keeps that
      directory another way.

      The key the connection secrets are encrypted under cannot travel in the archive, and must
      not: it is what protects the part of the archive that is protected at all. So the manifest
      carries a one-way mark of it instead, and restore compares. An instance rebuilt with a
      new secret key and no POSTULO_FIELD_KEY is now told at the restore that the passwords and
      tokens in the connections it just put back cannot be read, and how many there are — rather
      than finding out weeks later, one failing connection at a time. The wiki's backup page said
      that losing the secret key "will not lose your data, but it will log everyone out", which
      was only the first half of it; the configuration page had been saying the rest for a while.

      And restoring overwrites the database that is there, through SQLite's backup API or
      pg_restore --clean, which is safe only when nothing else has it open. In the container
      the documented route was exec into the running web container, with gunicorn and the
      scheduler reading through it. The wiki now gives the route that works — stop the services,
      then docker compose run --rm -e POSTULO_SKIP_MIGRATE=1 — and restore refuses when it
      can see anything else connected, unless --force. It sees every connection on PostgreSQL,
      and on SQLite the files WAL leaves beside the database for as long as one is open, which
      catches the scheduler and misses a web server that has been idle a while; a check is a
      second pair of eyes and stopping the services is what makes it safe, which the page says.
      The instructions for restoring by hand were missing the step that matters most under WAL:
      delete the stale -wal and -shm beside the file you have just put back, or SQLite
      replays a log written against a different database.

      Archives taken by earlier versions still restore. The format is 2, and anything from 1
      upwards is read, because the archive somebody restores from is by definition older than
      the Postulo reading it. (#234)

    • The scheduler no longer sends things twice, no longer dies on one bad item, and now says
      whether it is still going round.
      It is one command in a loop, which is the right size for
      a single instance and left four things to chance.

      Nothing claimed the work before doing it. A reminder was announced and then stamped, so a
      restart in between — or the perfectly ordinary mistake of running cron and --loop at once,
      which the Compose file makes easy — announced it again. Store copies were worse: two passes,
      or a pass overlapping the Send now somebody had just pressed, could both send the same
      document to the same store, though the handbook said that never happened. Everything is now
      claimed first with a conditional update, so whoever changes the row has the work and the
      other finds nothing to change. For a reminder that means the stamp is written before the
      message goes: a message lost to a crash in that half-second is one nobody gets, while the
      alternative is somebody's telephone going off twice at three in the morning.

      One bad item ended the process. A reminder whose posting had lost its company, a notifier
      raising something nobody anticipated, a dropped connection — any of them killed the loop,
      and the container restarted through the entire entrypoint to try the same thing again.
      Failures are now caught per item and per pass, and the database connection is refreshed
      each time round, which is what Django does between requests and there are no requests here.

      Syncs ran inline with no limit, so one slow calendar server held up every reminder behind
      it. A pass now spends at most two minutes starting new ones — --sync-budget, 0 for no
      limit — and leaves the rest to the next pass. A sync already started is never cut off.

      And nothing anywhere recorded that a pass had happened, so an instance whose scheduler had
      stopped looked exactly like one where nothing was due. Each finished pass now writes its
      time to a file on the data volume, which the scheduler container's own healthcheck reads —
      it had been inheriting the image's healthcheck, which curls a web port it does not serve,
      so it was permanently unhealthy and told nobody anything. /metrics reports the same
      heartbeat as postulo_scheduler_last_pass_timestamp_seconds, alongside a new
      postulo_overdue{kind="reminders"} for reminders that fell due a quarter of an hour ago
      and have still not been announced, and postulo_failures{kind="syncs"}. The handbook has
      alerting rules for all three. postulo_pending{kind="reminders"} counted every reminder
      anybody had ever set and not finished, which grew because the instance was being used and
      so could not be alerted on; it now counts the ones that have fallen due.

      In Compose, the scheduler also waits for the web container to be healthy rather than
      merely started — it skips migrations on purpose, so starting against a half-migrated
      schema is the one thing it cannot recover from — and no longer restores the plugin record
      at the same moment the web container is doing it. (#221)

    • A store that says it is finished is now believed, instead of being dialled for ever.
      #216 gave a plugin a way to say that the other side has ended a connection, and notifiers
      honoured it: a browser that withdrew its subscription stopped being pushed to. Stores did
      not. A Paperless whose token had been revoked, or a share that no longer existed, answered
      the same way for every document there was, and each answer was filed as an ordinary failure
      to try again later — so the retries went on, once per document, until somebody noticed the
      row of failed badges and worked out what they had in common.

      A store saying it is finished now switches the connection off with the reason in the
      plugin's own words rather than a class name, exactly as a notifier does. Copies waiting for
      a connection that is switched off are left waiting rather than being sent to record that it
      is switched off: they would have spent every attempt they had on that sentence, and there
      would have been nothing left when it was fixed. Switching it back on is one press, and they
      go. A copy whose connection is not merely off but gone is still told so, because it has
      nothing to wait for. (#243)

    • An identifier is the same identifier whatever case it is typed in. Every named scheme
      folds its own values as you type them — Wikidata to Q95, LinkedIn to lowercase — so a
      search usually found what it should. Two things it did not. A row written before its scheme
      gained that folding kept the spelling it arrived with, and an early import that stored q95
      was then invisible to everything that looked for Q95: the company was not found, and the
      posting was filed under a second record of the same employer. And the rule that one
      identifier names one company was enforced on the exact characters, so the same company could
      be entered twice, once in each case, with nothing to say they were one.

      The comparison is now case-blind everywhere it happens — the lookup, the form, the
      importers, and the uniqueness the database itself keeps — while the stored value is left
      alone. That last part is deliberate: a scheme that folds is still the thing that decides
      what its values look like, and other, which is where a staff number lives, folds nothing,
      because AB-12 should read AB-12 and not ab-12. An accent is still a different
      character, not a different case.

      A migration puts existing values through the current rules, and where that leaves one
      account holding the same identifier twice it removes the later identifier and names both
      companies on the console — the companies themselves are left exactly as they are, because
      whether two records are one employer is not something a migration gets to decide. Saying no
      now also sounds like a person: where the database used to answer "Constraint
      “unique_other_identifier_per_company” is violated", the form says the identifier is already
      listed. (#211)

    • Applications you recorded after the fact counted nowhere, and nothing let you say when
      you applied.
      The date an application was sent was written only when it passed through the
      literal Applied. Anybody recording a reply they already had — straight to Interviewing,
      or to Rejected — got an application with no date, and every figure measured from that date
      then ignored it: the number sent, reply and interview times, sources, industries, the
      by-month figures, "sent recently", the API's since filter, and the evidence list in the
      report an employment office reads. The funnel counted it all the same, so a stage could show
      more than 100% of Applied.

      Reaching any status that means it went out now records the date, Withdrawn excepted: a
      draft abandoned before it was ever sent is not an application, while withdrawing something
      already sent keeps the date it has. A migration fills in the past from the timeline, which
      has always known — the first status change into a sent status is when it went out.

      And every door now takes the date: intake, Apply, capture review (a posting captured today
      was often applied to last week) and the API's applied_on. Left empty it is today, as
      before. The spreadsheet import stops guessing from the date alone — a row that says
      Rejected with no date column keeps its status, its channel, its tags and its deadline
      instead of becoming an untouched listing, and records that the date is unknown rather than
      inventing today; a row that says draft stays a listing even when it carries a date. (#222)

    • Deleting one thing no longer quietly deletes the record of what you sent, and a deleted
      file is now actually deleted.
      Three ways the promise that "the record of what you sent
      has to stay true" was not kept.

      A company cascaded into its postings, those into applications, and applications into the
      frozen PDFs an employer had received — so tidying up one employer could take a year of
      evidence with it, behind a confirmation that said only "anything belonging to it goes too".
      A sent document now survives the application it went with, and still names the role and
      employer it was sent to, because that is stored as text beside the link. The confirmation
      page counts what will go before it goes, and says what is kept.

      Editing an uploaded file replaced the bytes in place, while applications went on saying
      they had sent it and external stores kept the copy they had already filed. The file is
      fixed once it arrives; a different file is a new upload that supersedes the old one, which
      is what the Supersedes field was always for. Uploads are checksummed now as renders have
      been, so a store can tell one from another.

      And deleting a document left its file on disk for ever — only deleting a whole account
      removed anything — so "deleted" meant "hidden" for files holding a home address and a
      full career, which were then copied into every backup. Deleting a document now deletes its
      file once the change is committed and nothing else points at it, and manage.py prune_media
      lists what earlier deletions left behind, removing it only when asked. (#217)

    • Choosing PostgreSQL got you an instance that would not start, and no scheduler if it
      had.
      docker/compose.postgres.yml has offered the second engine since it was added, and
      Installing Postulo has recommended it to anybody already running one — while the
      published image had neither the driver nor the client tools to honour the offer. The
      container stopped on its first migrate, because psycopg lives in an extra the build
      never asked for; had it started, manage.py backup would have stopped on “pg_dump is not
      on the PATH”, because core/backup.py dumps a PostgreSQL through the tool rather than by
      copying a file that is being written to; and reminders, gone-quiet notices, store copies
      and syncs would never have run at all, because that compose file had no scheduler and
      nothing fails when a loop nobody started does not loop.

      All of which survived a release for one reason: nothing had ever pointed a test at a
      PostgreSQL. The suite runs on in-memory SQLite, and the PostgreSQL half of the backup code
      was covered by a stand-in for subprocess.run — so the engine was documented, offered,
      and entirely unexecuted. CI now runs the database-facing tests against a real
      postgres:17 and takes an actual backup of a seeded instance and puts it back, through
      pg_dump and pg_restore, so the next thing to break on this path breaks in a job rather
      than on somebody's server. Which found its first thing immediately: the suite's throwaway
      model had no migration, so it was built in migrate's syncdb phase with a foreign key to
      an accounts table that did not exist yet — recorded and checked later by SQLite, refused
      outright by PostgreSQL.

      The client tools come from PostgreSQL's own repository, pinned to the major the compose
      file starts: pg_dump refuses a server newer than itself and Debian's is two majors behind,
      which would have made this “backups work” until the first time anybody looked. (#219)

    • Adding a company answered a 500 when the form was sent twice, though the company was
      saved.
      A double click on Save posted the form twice in one second; the first saved
      and redirected, the second hit SQLite's database is locked and the browser showed the
      500 — which invites a retry, and a retry makes a duplicate. Three causes, three fixes.
      SQLite's default transaction takes no lock until it writes and refuses at once when it
      cannot upgrade: a file database now opens with immediate transactions, a write-ahead
      log and a twenty-second wait, so two writers take turns. Nothing stopped a form being
      sent twice: every form that posts is let through once, its button greyed until the
      page changes, and the back button gets a form that works again; with scripts blocked
      nothing changes. Backups already use SQLite's own backup API, so a copy under the
      write-ahead log is consistent. (#206)

    • The career-order checkbox under Settings → Appearance described itself with an id
      that was not on the page.
      Django names the help text in the checkbox's
      aria-describedby; the template drew the help inside the label without the id, so a
      screen reader was told about an element that did not exist and the sentence explaining
      the preference could not be reached from the box. The help carries the id now, outside
      the label so the name says what the box is and the description says why, and the fast
      suite checks every aria-describedby on the settings pages points at something. (#207)

    • A CV's page no longer says “What is on this cv”. The heading put the kind's label,
      lowercased, into a sentence — which flattened an acronym in English, French and
      Portuguese and misspelt a noun in German, and which could never agree in a language where
      the two kinds take different articles. It is two sentences now, chosen by kind: What is
      on this CV
      and What is on this portfolio. The same |lower was applied to a salary's
      period (Pro Jahr → pro jahr) and to a copy's status after a store's name; both now
      show the label as the catalogue wrote it. Filled in English, French and Portuguese; the
      other catalogues get the two new strings at the release sweep. (#168)

    • The suggestions page fits on a phone once there is a suggestion on it. The accept form
      for a suggestion not yet matched to an application carries a select as wide as the longest
      application title, in a group that could not shrink — 45 pixels past the edge of a
      320-pixel screen in English, 60 in Greek, 70 in German, on exactly the page a mail or
      calendar plugin's first suggestion lands on. The group wraps under the words now and the
      select gives way. The walk had never reached that state: its fixture had no suggestion, so
      the page was only ever checked empty. It has a pending, unmatched one now, filed the way a
      plugin files one, so axe and the reflow check read the page with something on it — the
      last of what #167 found and left undone. (#167)

    • Escape closes a cell editor however quickly it is pressed. The editor arrives by a
      swap, and htmx wires what it swapped in — the Cancel button Escape clicks — only when the
      swap settles, 20 ms later by default, while the caret is put in the input the moment it
      lands. For those 20 ms Escape reached a button nothing was listening to, and did nothing.
      No hand is that quick; the browser suite was, one run in three, and the flake it reported
      was this. The handler now has htmx process the editor before clicking, a no-op once the
      settle has done it, and a test widens the window to two seconds and presses inside it.
      (#161)

    • The image scan can now be told from its own failure. scripts/scan-image.sh has three
      outcomes instead of one red step: exit 0 for nothing fixable, 1 for fixable findings, and
      2 when the scan did not complete — a scanner that failed to run, a bill of materials that
      came back empty — which says nothing about the image and now says so, on stderr and in
      .scan/verdict.txt, which both image workflows put at the top of their run summary. Trivy
      runs once for the gate rather than twice, with an exit code of its own for findings; Grype
      exits 1 for everything, so its verdict is read from whether a report came out. A verdict
      left by an earlier run is removed before anything starts, and the dev image's summary no
      longer offers a docker pull for an image that was never pushed. The root cause — reports
      written through a bind mount the daemon resolved against the host — went with #190, and a
      test now keeps every report on a redirect. .scan/ is ignored, so a scan run by hand
      leaves no untracked files behind. (#192)

    • An uploaded file downloads under its own extension. Every download was called
      <title>.pdf, whatever had been uploaded, so a .docx or a .txt arrived as a file no
      PDF viewer would open — served as application/pdf too, since the type is guessed from
      the name. The name is the title with the upload's own extension now, on the page and over
      the API alike; a snapshot of what was sent is still <title>.pdf, because that is always
      what it is. (#193)

    • A file's edit form no longer shows where Postulo keeps it. Editing an uploaded file
      said Currently: documents/1/2026/09/reference.txt — the storage path, carrying the
      account id and the month of the upload, inside a link to /media/ that nothing serves. It
      says Currently: reference.txt now. The path was Postulo's filing system thinking aloud:
      not the person's to care about, a second answer beside the Title field above it, and on
      a shared screen an account id. Django's file widget template is overridden once, for every
      file field, so no form has to remember. (#191)

    • The accessibility walk claimed thirty-five pages it never opened, and six of the pages
      it had never opened were broken.
      tests/test_page_coverage.py insisted every URL
      pattern was either visited by the browser suite or excused in writing — but "visited"
      meant "named in a hand-written tuple beside the walk", and nothing held the tuple to the
      walk. It claimed 104 names and the walk reached 69. Every CV page and every letter page
      past the list was counted as checked and had never been looked at.

      The claim is derived from the walk now, by resolving the paths it actually visits, so
      it cannot get ahead of it again. That turned the coverage test's output into an honest
      list of thirty-four, and the walk was given what those pages need to exist: a CV with an
      entry, a letter, an upload, a tag, a contact, an industry, a posting, a capture waiting
      for review and a connection. Two routes answer POST only and are excused by name rather
      than pretended at; settings:index stopped being excused as "redirects to the appearance
      page"
      , because the walk visits /settings/ itself and the excuse had become false.

      What the fuller walk then found, none of which anything had ever measured:

      • A CV's own page ran off a 320-pixel screen in Greek and German. Its entries are the row
        #165 fixed on the career page, and this page had kept the old one: words beside a
        shrink-0 group of ↑, ↓, Tailor and Remove. The grid's column is sized by its
        content, so the overflow took the Add entries card with it.
      • The uploads list squeezed a title into 58 pixels in English and 13 in Greek, behind
        Download / Edit / Delete.
      • The connections list did the same, with flex-1 — which is flex: 1 1 0%, so the words
        claimed no width of their own before anything wrapped.
      • The upload edit form scrolled sideways in every language, by exactly the same amount,
        because Django renders a bound file field as Currently: documents/1/2026/09/reference.txt
        — a path, with no spaces in it, 252 unbreakable pixels against the 238 a phone leaves.
        The new form passed all along, which is why nothing had caught it.
      • A checkbox on the applications filter was 20 pixels tall, against the 24 WCAG 2.2
        SC 2.5.8 asks for.
      • The capture review page pointed aria-describedby at a help-text element that was not
        there — worse than no description, because a screen reader is told there is one.

      All six are fixed. The preview pages are the one exemption, in writing and narrowly: a
      preview returns "the CV as HTML, exactly as the PDF renderer will see it", so axe is
      reading a print document and asking it for <main>. Satisfying that would change every
      PDF Postulo produces to answer a question nobody asks of a printed page. They stay in the
      walk, so reflow and target size still read them; only axe looks away. (#167)

    • Dragging worked in Chromium and did nothing in Firefox, and the tests agreed with
      Chromium.
      Neither drag cancelled dragenter. The specification makes an element a drop
      target only once both dragenter and dragover are cancelled; Chromium forgives the
      omission and Firefox does not, so arranging the dashboard by dragging a widget (#125) and
      moving a card between board columns both sprang back with nothing posted and nothing
      logged, for anybody using Firefox.

      Both drags had it, because both were written the same way — the board's cards and the
      dashboard's rows — so both are fixed here rather than one being left with a known copy of
      the fault in the same file.

      The tests could not have caught it. They dispatch four synthetic DragEvents including
      the drop itself, so they exercise the handlers and pass whether or not a browser would
      ever have delivered that drop; and the browser suite runs --browser chromium, the one
      browser that forgives this. Both helpers now send dragenter as a browser would, and each
      file gains a test that asks the handlers the question the browser asks — was this event
      cancelled?
      — which fails without the fix in Chromium, so it needs no second browser to
      keep watch. (#174)

    • A chip with no remove button had almost no padding at its end. On Documents → CVs
      the kind tag sat hard against its own right edge, twelve pixels of space on one side and
      two on the other. .chip was shaped around a button it did not have: the two pixels are
      where .chip-remove goes, and the button supplies the visual space, so a chip holding only
      a word got the gap and nothing to fill it. Two of the three places that draw one have no
      button — a CV's kind and a company's industries — and the company row had already patched
      it by hand with pe-3, which is the sort of workaround that says the class is wrong rather
      than the caller.

      So the class changed rather than the third caller: a chip pads both ends, and the remove
      button pulls its own end back with -me-2.5. A chip with a button looks exactly as it did;
      one without is no longer short of an end; and the fourth caller will be right without
      knowing any of this. The company row's pe-3 is gone.

      The test that guarded this pinned ps-3 and pe-0.5, which is to say it pinned the
      asymmetry rather than the intent — its own docstring says the intent is that the × sits at
      the end edge and not the right. It now asserts that, and that a chip pads both ends. (#185)

    • The report page was missing two of its own classes, and the stylesheet was being fed by
      prose.
      The report arrived without a stylesheet rebuild, so its Show this period button
      sat out of line and its two tallies stacked on a wide screen, because mt-5 and
      lg:grid-cols-2 compiled to nothing. Only CI noticed, after the push; the ordinary suite
      now rebuilds the stylesheet and compares wherever the Tailwind CLI is installed, and the
      build is on the checklist.

      Chasing a third, phantom class turned up the real problem: what compiled depended on
      more than the interface.
      Tailwind's automatic detection scanned the whole repository, so a
      word in a test's docstring, the wiki or the changelog could put a class in the stylesheet
      every page loads — the template lint's own list of forbidden physical utilities was
      compiling them. The compiled file sat inside the scanned tree, so a build written anywhere
      but over it read the previous build as a source. And the templates that become PDFs, which
      never load this stylesheet, had the words in their inline CSS read as classes. The scan is
      now the interface and nothing else, and twenty-four utilities nothing used — among them
      every physical one — have gone. (#164)

    • The image builds again. Giving the Python build a stage of its own (#157) put the
      dependencies first, so that a change to the application would not re-resolve them, and
      left the one uv sync above the line that copies the source — and uv sync installs the
      project as well. Every image build since failed with Expected a Python module at
      src/postulo/__init__.py, and nobody knew, because nothing in CI builds an image (#81):
      the test instance's deploy was the first build since. The dependencies are synced before
      the source with --no-install-project, Postulo after it, and a test reads the Dockerfile's
      stages to hold that order. (#166)

    • Pages that ran off a phone's screen in a longer language, and words squeezed out of their
      own space.
      The arrange page's Take … off buttons carry a translated widget name, and
      sat in a group that was not allowed to give way: in Greek it pushed the page 64 pixels past
      the edge of a phone, in German 31 — and in English 8, on CI's fonts, which is how it was
      found. The interview list's outcome buttons did the same by 143 pixels in Greek, the account
      page's buttons in Dutch, French and German, and four list headings in Dutch. Those groups now
      wrap, and the words beside them claim twelve rem before anything may sit next to them, so on
      a phone the buttons go underneath.

      Where the buttons did fit, the same layout failed quietly, with nothing to scroll: the
      words were left whatever was over. The arrange page had a column one word wide, a career
      entry had fourteen pixels in Greek, and each built-in plugin's description had six in
      English, with the longest word written across whatever was beside it. The browser suite now
      walks every page in Greek and German as well as English, and fails on words that run out of
      their own box as well as on a page that scrolls.

      Walking in Greek found two more. The recovery page's table had a hidden Actions label that
      escaped its scroll box and dragged the page sideways, because the report and that page had
      been given a bare overflow-x-auto rather than the positioned scroll-x #113 made for
      exactly this; the template lint now refuses the bare one. And a heading may break a word too
      long for a phone — Wiederherstellungslink is wider than one — rather than run off its edge.

      The Suggestions widget, which draws its own heading, had no name anywhere else: the arrange
      page showed its key, its button read "Take off", and its four arrows told a screen reader
      "Move up a row". Every widget now has a name in words, and registering one without it is
      refused. (#165)

    • The CV page and the letter page open again. Making a rendered document point at
      whatever produced it took related_name="renders" with the two columns it replaced, and
      both detail pages ask for exactly that — so anyone opening one got a server error instead
      of their CV. Nothing caught it, because the suite tested what the new link stores and
      never opened the page that reads it back.

      The reverse is a query rather than a GenericRelation, and that is not a detail. A
      relation would give the name back and a cascade with it, and the cascade is the one thing
      that must not happen here: deleting a CV has to leave the PDF an employer received exactly
      where it is, which was the whole reason RenderedDocument exists. (#130)

    • A test could be handed a translated string because of which test ran before it.
      LocaleMiddleware activates a language per request and nothing deactivated it afterwards,
      so a test that signed in as somebody reading Postulo in Portuguese left Portuguese active
      for every test that followed. It showed up as a test asserting an English message and
      getting a Portuguese one — passing alone, failing in company, and failing differently
      depending on the order, which is the worst shape a failure can have. Every test now starts
      in the instance's own language. Found while adding the portfolio tests, which is to say by
      accident. (#133)

    • A team nobody had been recorded at did not survive an export. A department travelled
      only as a name beside a contact, so a team with no contact simply vanished from the
      archive — and a team you applied to before you knew anybody there is exactly the ordinary
      case the model was written for. Departments are now records of their own in the file. Found
      by the new attachment failing to restore, which is the argument for the round-trip test
      being a round trip. (#138)

    • A stored column width did nothing on a real deployment. It shipped as a style
      attribute on the header cell, and the policy Postulo serves is style-src 'self', which
      refuses one as firmly as it refuses an inline script — so the browser dropped it, the
      column sized itself, and the preference appeared not to save. Development never saw it
      because the strict policy is production's: the browser test that covers widths runs under
      the development settings, and the test that runs under the real policy visited no page
      with a stored width. Both were true and neither could catch it, which is the gap that has
      been closed alongside the width. The script that owns the handle applies it through the
      DOM now, which the policy does not govern, and which is the coherent place for it — a
      width is a pointer gesture, so it belongs to the script that provides the gesture. (#136)

    • Three plugin descriptions were never translatable, and nobody could have noticed.
      scripts/messages.py reads the source rather than importing it, so it knows a translation
      call by the name at the call site — and three plugins import gettext_lazy as _lazy, a
      name it had never been told about. Their descriptions were therefore never extracted, so
      the catalogues were complete and the strings were simply not in them: the one shape of
      translation bug that a completeness check cannot see. The extractor knows the alias now, a
      test fails on the next one somebody invents, and the strings are translated into all
      thirty-nine European languages. (#149)

    • The browser suite no longer fails whichever test happens to run after the fortieth
      sign-in.
      Every one of them signs in, all from one address, and the limit that stops a
      stranger guessing passwords cannot tell a test suite from an attacker — correctly. So the
      suite got a 429 somewhere in the middle once it grew past the allowance, and which test
      got it depended on how many had run before, which is the worst shape a failure can have.
      The limiter is now emptied before each browser test rather than switched off, so it is
      still the real one and the security tests still hold it to its numbers. (#119)

    • Rebuilding the stylesheet no longer produces a diff nothing can read. app.css is a
      build artefact that is committed on purpose — Postulo runs without Node — and Tailwind was
      writing it minified, as one line of 76 kB. So every rebuild produced a two-line diff a
      hundred kilobytes wide: a terminal wrapped it into thousands of rows, a review tool
      truncated it, a pager could stall on it, and git add -p was unusable. Reported from a
      terminal that stopped part way through one.

      It is written out now. That is not only about being able to scroll past it: a Tailwind
      upgrade quietly changing a base rule is exactly the sort of thing a diff should catch, and
      nobody could see one. The cost was measured rather than guessed — 819 bytes of the 11 kB
      WhiteNoise actually sends, because compression removes almost everything minification does
      — and a stylesheet a person can reason about is worth eight per cent of one response.

      The image builds it with the same command, so its copy and the committed one stay byte for
      byte identical, and CI still fails on a stale one. Git is told the file is generated, so a
      forge collapses it by default — but it stays diffable, because the diff is now worth
      reading. (#159)

    • Port 465 could not be configured at all, so half the mail providers there are could
      not be used.
      There are two ways of putting TLS on an SMTP session. STARTTLS connects in
      the clear and asks the server to upgrade the socket, which is ports 587 and 25; implicit
      TLS hands over a certificate before a byte of SMTP is spoken, which is port 465. Postulo
      did only the first — no SMTP_SSL, no use_ssl, nowhere — so an administrator entering
      their provider's documented settings got a socket waiting for a greeting the server would
      never send, and, ten seconds later, the word timed out. Ticking the STARTTLS box changed
      nothing, because the failure happened before STARTTLS would have been reached.

      Found configuring a real provider on a real instance, which is the only way this was ever
      going to be found: every test in the suite that touches mail either mocks the socket or
      sends to a local memory backend.

      One control with three states, not a second checkbox. Django's SMTP backend raises when
      use_tls and use_ssl are both set, and rightly — they are alternatives, not layers — so
      a checkbox each would have offered a pair that cannot be saved. Connection security is one
      field and cannot express the invalid combination. A migration carries the old boolean into
      it in the order add-carry-remove, because the schema change on its own would have dropped
      the column first and quietly reset every instance to "nothing chosen"; and
      POSTULO_EMAIL_USE_TLS goes on meaning what it means for every .env that sets it, with
      the new POSTULO_EMAIL_SECURITY winning where both are given.

      The timeout now says which mistake it is. Pointing one kind at the other's port is the
      ordinary error rather than an exotic one — the two ports are documented interchangeably by
      half the providers there are — and both directions fail identically. So a failure on 465
      without implicit TLS, or on 587 with it, names that before repeating the original message. A
      port that is neither is left alone: a relay on a port of its own is ordinary for a
      self-hosted instance, and the surest way to make a settings page hated is to argue with what
      was typed into it. For the same reason the default port is filled in only when the box was
      left empty. (#158)

    • The lock protecting people's accounts rested on a route that might deliver nothing.
      Mail may not be switched off while it is the last way anybody could get back into their
      account. What that rule actually checked was whether a mail transport was selected — a
      statement about configuration, not about delivery. An instance whose relay had been
      switched off, whose password had been changed, or whose host no longer resolved still
      counted email as the last way in, and refused every attempt to switch the transport off on
      the strength of a route that delivered nothing. The refusal told an administrator it was
      protecting accounts it was not, in fact, protecting.

      Now a route counts because it delivers. The Email page says when mail last went out, or
      that the last few messages failed and what the transport said about it — recorded from
      what sends actually did, never probed, because the lock is evaluated while rendering a page
      and an answer that opened a connection would make reading a page send traffic. The Send a
      test message
      button goes through the same code a real message does, so nothing extra had
      to be wired up for it to count as evidence.

      It takes three failures in a row, not one. A relay that refuses a single address has
      told us about that address rather than about itself, and an instance that has never sent
      anything counts as working. Both fail in the same direction on purpose: this makes the lock
      honest, never eager to open, because a lock that opens on a shrug is worse than one that
      stays shut on an optimistic guess.

      When it does open, the page says the thing that matters. Mail failing while it is the
      only route means nobody who forgets a password can get back in — and that is true whether
      the lock is shut or not, because those accounts are stranded by the relay, not by the
      setting. So the page names how many people that is, and the lock opens rather than standing
      between an administrator and the transport that would fix it.

      recovery_routes() now returns routes that know the difference between existing and
      delivering, because SMS, an administrator-issued link and a passkey will each have to
      answer "does this one actually work" in its own way. (#152)

    • The runtime image carried 430 MB it never runs, including a Node.js runtime. Trivy and
      Grype, run against the image on the test instance, found Playwright, pytest,
      pytest-playwright and pytest-base-url installed in the application's own virtual
      environment — 136 MB of browser test tooling, bundling its own Node binary, in a container
      that launches no browser — and 296 MB of uv's download cache left behind beside it. Out of
      906 MB.

      The Dockerfile read correctly, which is why nobody saw it. uv sync --locked --no-dev
      omits the dependency group called dev and nothing else, and pyproject.toml declares two
      — dev and e2e — with both named in default-groups so that uv sync never quietly
      removes the browser test's dependencies. That is a good reason, and it collided with the
      image build. Checked in the image rather than reasoned about: none of the dev group was
      present, all four of e2e were. --no-default-groups asks for the project and the named
      extra and nothing else, and stays right when a third group is added.

      The cache is the same shape of mistake in a different place. uv unpacks every wheel into
      ~/.cache/uv and keeps it; in a single-stage build that is shipped, and deleting it in a
      later layer would free nothing because the bytes are already below. --no-cache was
      already on the plugin install a few lines down, for exactly this reason.

      Three tests read the Dockerfile and the project file rather than the image, because nothing
      here builds an image (#81) and this is the second mistake in that file to reach a
      deployment. (#154)

    • The image could not be built, and had not been buildable since #111 landed. That
      issue taught the production settings to refuse a secret key shorter than fifty
      characters, which is right, and docker/Dockerfile passed collectstatic a literal
      twenty-eight characters long. Every docker build failed at that step. It was found by
      deploying, which is the expensive way to find it.

      The reason it landed green is the interesting half. CI runs the same collectstatic
      under the same production settings and passes, because the workflow's key is long enough
      and was written that way for security.W009 years before this rule existed. What CI does
      not do is build the image: that needs a runner advertising the docker label and none is
      registered (#81), so image.yml has never run once. The check that would have caught this
      is the check nobody has ever seen execute.

      The build now generates its key and throws it away with the shell that made it. A longer
      literal would have passed the rule while remaining a published constant somebody could
      paste into a .env; a random one passes it for the reason the rule exists. collectstatic
      needs a key at all only because the settings module insists on one before it will import —
      nothing it writes is signed, and no session, cookie or stored credential exists at build
      time. A test now reads the Dockerfile and every workflow and puts each literal key through
      refuse_a_weak_key, so the two cannot drift apart again while the real build stays
      unexercised. (#121)

    • Every form said "I am invalid, and these two elements explain why" — and neither element
      existed.
      Django renders a refused field with aria-invalid="true" and
      aria-describedby="<id>_helptext <id>_error", which is exactly right, and Postulo's own
      field partial then drew the help and the error without those ids. So a screen reader
      announced the invalid state and then had nothing to read, while the message sat on the page
      in red two lines below, reachable to eyes and to nothing else. On a company form with one
      empty name: four references, four of them dangling. It is SC 1.3.1 and SC 3.3.1, both level
      A, under the AA the README commits to — and the application already knew how, because
      the pages allauth renders were correct throughout. Half of it honoured the promise and half
      did not, which is worse than a consistent omission: anybody testing the sign-in flow with a
      screen reader would have concluded it was fine.

      The two paragraphs now carry the ids the input already claims, from one partial the whole
      application shares. The id goes on the error block rather than on each message, because a
      field with two errors would otherwise emit it twice and aria-describedby names it once —
      and a duplicate id resolves to whichever came first, which is the same bug wearing a
      disguise. role="alert" earns its place through htmx rather than page loads: most screen
      readers ignore an alert that was already in the document when it arrived, but these forms
      come back through a swap, and an error inserted into a live page is what the role is for.

      Groups got the same treatment, one layer up. A set of radios or checkboxes drawn as a
      <fieldset> — theme, navigation, industries, token scopes, interview contacts, language —
      had no association at all rather than a broken one: Django deliberately leaves
      aria-describedby off a widget it expects to be drawn as a fieldset, because the group is
      what the help and the errors are about, and nothing was putting it on the fieldset. They
      now carry field.aria_describedby, which is Django's own computation of the value, so it
      cannot drift from the ids the partial renders.

      Checked by resolving every aria-describedby on every page against the document, and by
      submitting five forms empty first — a page that has never been refused has no errors to
      point at, so the half of this that mattered was unreachable by walking pages. axe reports
      none of it: it cannot know that a <p> below an input was meant to describe it, and it does
      not report a dangling reference at all. Two more turned up that way, both checkboxes whose
      help was drawn by hand in a <span>. (#114)

    • A release run finishes again, and building the image is something you start rather than
      something that hangs.
      v0.2.0 was published — wheel, sdist, notes, all of it — with its
      workflow run sitting in waiting for ever, because the second job in that file asked for a
      runner advertising the docker label and no runner advertises it. Forgejo schedules a job
      before it evaluates the if that would skip it, so the job was neither skipped nor
      failed: it queued, and the run never finished. The comment at the top of ci.yml had
      warned about exactly this — a label no runner has does not fail the job: it queues it for
      ever, which looks exactly like CI passing until somebody checks
      — and it came true one file
      over. Giving runs-on an expression moved the symptom without removing it: the job then
      failed in zero seconds having run no steps, which is what an unschedulable job looks
      like when it is not left hanging. So the image build now lives in image.yml and is started
      by hand, with the tag to build as its input. A workflow nobody starts cannot queue, the
      release workflow has one job, and it always completes. The repository variable that gated
      the old automatic trigger went with it — a switch on something that only happens when you
      press the button is a second way of saying no. Nothing is lost that worked: that job had run
      three times and failed three times, and had never once built an image. (#81)

    • CI had tested nothing for a fortnight, and looked merely red rather than empty. A test
      imported config/settings/prod.py at module scope to read the redirect exemption list from
      what actually ships rather than a retyped copy — a good instinct. But that module refuses to
      import without POSTULO_SECRET_KEY, and the only thing supplying one was the .env in
      the developer's own working copy
      , which is gitignored and which CI does not have. So the
      import raised there, and because it happened during collection it aborted the whole run:
      not one failing test, no tests at all, on three Python versions and in the browser job, on
      every push for fourteen commits. The two jobs that kept passing were the two that never run
      pytest. Nothing distinguished "the suite failed" from "the suite never started", which is
      how it hid behind a red mark people had stopped reading. The file beside it had already
      solved this properly and said why — the repository's .env belongs to whoever is
      developing here
      — by reading production in a subprocess with the environment stripped;
      there is now one such reader in tests/security/conftest.py and both files use it.
      Reproduced by moving .env aside, which is how this should have been checked in the first
      place.

      With the suite running again it immediately caught three things a green local run never
      would. Two were layout on a machine with different fonts: the action bar beside a page
      heading did not wrap, so on Linux the buttons were wide enough to push Applications
      sideways at 320 pixels — fixed on all thirteen pages that share the pattern rather than the
      one that happened to overflow, because which one does is a question about typefaces. And
      the warning above Server settings → Plugins rendered its three paragraphs as three
      94-pixel columns: .alert is a flex row so an icon can sit beside the words, every other
      alert passes it a single element, and that one passed three. The third was a race in a
      test
      : setting an image's src is synchronous and fetching it is not, so checking that
      the flag had loaded the instant its attribute changed was a race won on the machine it was
      written on and lost on a slower one.

      The last of them took four rounds to find because the test kept answering confidently and
      wrongly. Server settings → Plugins scrolled 8 pixels, and every element over the edge was
      inside the settings sidebar's scroll box — which is on every settings page, while only that
      one scrolled. The cause was a filesystem path in a sentence: paths have no word
      boundaries, so a browser will not break one, and on Linux the font made it 8 pixels wider
      than the card. A walk over rectangles could never have found it, because a margin, a
      transform and an unbreakable string all add scrollable overflow that
      getBoundingClientRect does not show. The test now finds the culprit by hiding elements
      until the page stops scrolling, which looks at no boxes at all and cannot be fooled. (#117)

    • Every page in Postulo scrolled sideways on a phone, and one row of links was most of
      the reason.
      At 320 CSS pixels — the width a normal window has at 400% zoom, which is how
      somebody with low vision reads — all thirteen pages measured overflowed by an identical 331
      pixels. Identical is the tell: six navigation links come to 635 pixels, a flex row does not
      care how wide the window is, and that one element was doing it on every page at once.
      Reflow is level AA, and the README promises AA without qualification. Below 768 pixels
      the row is now a disclosure, the same <details> the account menu beside it has always
      been: it opens with no script, closes with Escape, and the links are written once and
      rendered twice so only one copy is ever in the layout. Wrapping the row instead would have
      been one class and three lines of navigation above every page on a phone.
      Five more went with it, none of which anybody had seen, because the navigation was
      hiding all of them: a fixed 288-pixel column in the plugin tables; an action bar of three
      buttons that would not wrap; a grid whose items refused to shrink; the API page's
      openapi.json address, which has no word boundary in it for a browser to break; and the
      server overview, 223 pixels over, where truncate — which sets white-space: nowrap —
      made a database path's smallest possible width its whole width and pushed the card, the
      grid track and then the page. Those two paths now wrap and can be read, which the third
      path in the same card always could. And one that is worth knowing about: a
      screen-reader-only "Actions" label, one pixel wide and invisible to everyone, made every
      listing page scroll 144 pixels. It is position: absolute, and an absolutely positioned
      box is confined by its containing block rather than by an ancestor's overflow — so it
      stepped straight out of the table's scroll box and took the document with it. Every box in
      Postulo that is allowed to scroll sideways now establishes a containing block, which is
      what the new .scroll-x is for. Checked by a third browser test that asks each page to
      scroll and fails if it moves. (#113)

    • Sixty-two buttons were two pixels too small to hit, and the suite said the pages were
      fine.
      The column chooser's move up and move down buttons were 22 by 22 — a 14-pixel
      chevron with 4 pixels of padding — against the 24 that WCAG 2.2 asks for at AA, repeated
      across every table in the application. They went unnoticed because axe-core does not
      enforce Target Size (Minimum)
      : it reports the rule as needing review rather than as a
      violation, so the accessibility suite returned a clean result on every page carrying them.
      Reading the markup would not have found them either; p-1 around a size-3.5 icon is a
      sum nobody does while writing a template. So the fix comes with the measurement, as a
      test
      : every clickable thing on every page the browser suite already visits is asked for
      its box in a real browser and held to 24 by 24, allowing the criterion's own exceptions —
      clear space around a small target, a checkbox measured by the label that switches it, a
      link inside a sentence whose height belongs to the prose around it. Three more failures
      fell out of running it: the dashboard's shortcut links, 20 pixels high in a column with 8
      between them, which is too small and too close; the column chooser's own labels at 20;
      and every sortable table header, 12-pixel type on a 16-pixel line with a filter control
      directly beneath. All now carry a tap-target class that sets a minimum box without moving
      anything — the criterion measures the box, and padding is only one way to reach it. One
      correction to the report: the checkboxes on Settings → Plugins were listed as a probable
      false positive, and they were passing, but on the spacing exception rather than on their
      size — those rows are tall and nothing sits near them. That is a thin thing to rest on, so
      they now pass on size too. (#115)

    • A plugin's own description, licence, author and source link were read and then thrown
      away.
      All four came out of every wheel, the confirmation screen showed them once, and the
      record kept none — so an administrator could see who wrote a plugin on the day they
      installed it and never again. They are kept now, and shown on Server settings → Plugins.
      Two of the four were being read from headers modern packaging does not write: Home-page
      is setuptools' old url=, and anything using [project.urls] emits Project-URL instead.
      That was not theoretical — Postulo's own reference plugin is built with hatchling and
      declares its homepage that way, so the project showed no source link for the plugin it
      publishes as the example to copy. Author was tried before Author-email, which meant
      preferring a bare name over the First Last <address> the other field carries. An instance
      that already has plugins fills in the blanks from the .dist-info still on its volume
      rather than showing them empty for ever. A plugin may now also declare a label and a
      description of its own — optional, and read through helpers rather than added to the
      protocols
      , because runtime_checkable checks data members and requiring them would have
      silently unloaded every plugin written before they existed. Twenty tests, one of them
      standing guard over exactly that. (#97)

    • The container's health check could not fail. With POSTULO_SSL_REDIRECT on — the
      production default — SecurityMiddleware answered /healthz with a 301 to
      https://127.0.0.1:8000/healthz, before any view ran and before anything touched the
      database. The probe is curl -fsS, and curl -f fails only on 4xx and 5xx, so it took
      that redirect as success and exited 0. Every deployment of the shipped image had a
      liveness probe that reported healthy whatever was wrong
      — database gone, migrations
      unapplied, every view raising. The 503 the health view returns was unreachable in
      production, and so was the restart that a failing check plus restart: unless-stopped
      would have produced. It survived a release for the obvious reason: a check that always
      passes looks exactly like a healthy service. /healthz and /metrics are exempt from
      the redirect now, both anchored at each end — SecurityMiddleware matches with
      re.search, so a loose pattern would have exempted every path containing the word, which
      is a worse bug than the one being fixed. /logs is deliberately not exempt: its
      entries name connections, companies and applications, and a scrape that visibly breaks
      beats personal data crossing a network in clear. Six tests come with it, and each of them
      fails without the fix. (#82)

    • Server settings → People scrolled sideways at every width, including on a desktop.
      The table needed 967 pixels and the card it sits in gives about 730 whatever the window
      does, so a wider monitor never helped — measured at 1440, 1280, 1024 and 768, and it
      overflowed by roughly 200 pixels at every one. A third of the table was four buttons:
      Change username, Make administrator, Deactivate and Delete account, spelled out end
      to end, making that column 335 pixels — wider than the email column, and holding no
      information at all. They are a menu now, the same disclosure the account menu in the header
      uses, which takes the table to 676 and leaves room to spare. Every action is still there
      and still a word. On a phone the rows stop being rows: a new table-cards component
      gives each person a card with its values stacked and labelled. That technique works by
      turning table elements into blocks, which takes the table semantics with it — so every
      element now states its ARIA role, or a screen reader on a narrow screen would hear
      "Administrator" as a loose word rather than as the Role of a row. Eight other tables are
      wrapped the same way and may well overflow too; none of them was measured or touched here.
      (#91)

    • Flags were emoji, and Windows draws those as two letters. A flag emoji is not a
      character: it is two regional indicator code points, and a font is invited — never
      required — to draw the pair as a flag. Segoe UI Emoji never has and Microsoft has said it
      will not, so every Windows machine showed PT where everyone else saw a flag. Both places
      this was used carried a comment predicting exactly that and calling it "a legible fallback
      and not a broken image". It is not a fallback; it looks broken, and it looked broken to
      the maintainer on his own desktop. Flags are now SVG images from
      flag-icons (MIT), copied into the repository like
      the icons already were — nothing is fetched from anybody else's server, and the policy
      still says img-src 'self'. The telephone field changed shape: an <option> can hold
      text and nothing else in any browser, so no image could ever have gone in that list. The
      flag moved out beside the closed chooser, where it is arguably more use — visible without
      opening anything — and the list now reads +351 Portugal. The chooser is still a native
      <select>, because replacing it with something that could hold pictures would trade a
      control that works on every phone and with every screen reader for one that has to be
      re-proved against all of them. With JavaScript off the flag still shows the country the
      page loaded with. On the way, languages.FLAGS held the emoji and phones.FROM_LANGUAGE
      held the ISO code for the same 24 languages — one fact written down twice, in the codebase
      whose own telephone field refuses to store a country column for precisely that reason. It
      is one map now, and a test fails if the two ever disagree. (#88)

    • The authenticator QR code could not be seen, let alone scanned, in the dark theme.
      qrcode draws the modules as one path filled #000000 and gives the image no background
      at all — not even a white quiet zone; black is the only colour in the file. On a light
      page that reads perfectly, which is why it shipped. On a dark one it is black on
      near-black: not low contrast, invisible. It is inverted in the dark theme now, which
      flips the modules to white and leaves the transparency alone, so the quiet zone becomes
      the page's own dark — an unbroken margin of one colour, which is what a scanner wants.
      The class hangs off the qr tag allauth already puts on that image, so no other image is
      touched. Setting up two-factor authentication is a page nobody visits twice, which is
      exactly how a screen goes years without being looked at in both themes. (#87)

    • Five icons were drawing without their geometry. The icon tag strips Lucide's fixed
      24-pixel size so that one file can serve a 16-pixel glyph and a 48-pixel illustration —
      but it did so across the whole file rather than the root element, and on a <rect> the
      width and height are not a size, they are the shape. The envelope on Email lost its box
      and became a lone flap: a "V". Language and time lost the month and kept two rings,
      Dashboard lost every panel and drew nothing at all, Overview lost the screen and kept
      the stand, and the briefcase in the navigation lost the case and kept the handle. Three of
      the five sit side by side in the settings sidebar, which is how they were noticed
      together. The root is stripped now and nothing else. Nothing could have caught it: the
      icon tests all asked about the root element, so an icon that rendered as a valid,
      well-labelled, correctly sized, empty box passed every one of them — and the icons are
      aria-hidden by design, so axe had nothing to look at either. Three tests come with the
      fix, one of them the reported symptom stated as itself. (#85)

    Downloads
  • v0.2.1 0db8ad07d6

    Postulo 0.2.1
    Some checks failed
    CI / test (3.12) (push) Successful in 1m29s
    CI / test (3.13) (push) Successful in 1m34s
    CI / security (push) Successful in 34s
    CI / styles (push) Successful in 10s
    CI / test (3.14) (push) Successful in 1m30s
    CI / browser (push) Successful in 2m41s
    Release / image (push) Failing after 0s
    Release / release (push) Successful in 10s
    Stable

    tiagoagueda released this 2026-09-07 13:29:19 +00:00 | 295 commits to main since this release

    🐛 Fixed

    • The release run never finished. release.yml's image job says runs-on: docker and
      is guarded by if: vars.BUILD_IMAGE == 'true' — but Forgejo queues a job before it
      evaluates the condition, so with no runner advertising that label the job waited for ever
      and the run never completed. v0.2.0 was published, attached and correct, and its run still
      looked unfinished hours later. The comment at the top of ci.yml had warned about this
      exact behaviour, about a different file. The destination follows the switch now: with
      image building off the job lands on a runner that exists and is skipped at once, and the
      run completes. (#81)

    • Server settings → Overview returned 500 as soon as a backup existed. The view worked
      out the newest backup's age as a span and the template handed that span to timesince,
      which wants a moment and reads .year off it straight away. The line below it asks
      age.days >= 7, which is the right question about a span — both readings sat in the same
      six lines of template and only one matched what the view returned. It returns made_at
      and age now, and each is used for the thing it answers. Nothing caught it because no
      test had ever rendered that page with a backup on disk
      : with an empty directory the
      template takes the "none yet" branch and never touches the filter, so the unit tests, the
      page-coverage check and the accessibility suite were all looking at the empty state. It
      had been broken for as long as the feature existed, and reachable by anybody who had run
      manage.py backup once. Three tests come with the fix — a backup present, one older than
      a week, and an empty directory — so the branch that used to be the only one tested stays
      tested. (#83)

    Downloads