• v0.5.0 f0461985a1

    Postulo 0.5.0
    All checks were successful
    CI / Unit tests and coverage (Python 3.14) (push) Successful in 13m50s
    CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 1m52s
    CI / Browser tests (Chromium) (push) Successful in 34m5s
    CI / Database tests on PostgreSQL (push) Successful in 4m49s
    CI / Security audit (push) Successful in 2m39s
    Every Python / Unit tests (Python 3.12) (push) Successful in 15m40s
    Every Python / Unit tests (Python 3.13) (push) Successful in 15m0s
    Release / Publish the release (push) Successful in 1m38s
    Dev image / Dev image: build, scan, push (push) Successful in 1h30m58s
    Stable

    tiagoagueda released this 2026-11-01 11:36:19 +00:00 | 110 commits to main since this release

    🔒 Security

    • A photograph uploaded as a document has its location, time and camera cut out by default, without re-encoding the picture: a box on the upload form, ticked unless you untick it. (#964)
    • A translation that adds markup the English lacks (a tag, attribute, address or stray <) is left out of the compiled catalogue if a draft and fails the check if reviewed, and the map's "you are here" tooltip is text. (#983)
    • The board's fold links encode their hx-vals with as_json instead of pasting a string into JSON, and a template lint refuses any hx-vals or hx-headers built another way. (#985)
    • The company edit page no longer prints a translated sentence as markup: the kept-back notice on the company, contact and profile pages is composed in Python with the setting's name in italics, and a lint allows |safe in two templates only. (#982)
    • seed_demo takes the new account's password from POSTULO_SEED_PASSWORD or a prompt (--password), never from the command line. (#980)
    • Postulo's own JSON log scrubs the same one-time secrets as the access log: password-reset and email-confirmation keys too, from one list. (#979)
    • /healthz tells a request a trusted proxy passed on by any forwarding header (not only X-Forwarded-For) from the container's own probe, so it is held to the host list and is not told the version. (#977)
    • A request id sent by a trusted proxy is kept when the proxy also sent X-Forwarded-For, judged by the proxy that delivered the request and not by the client address it named. (#976)
    • The threat model says the referee rules bind the shipped themes and the sections they are handed, and that a plugin theme is handed full records and trusted. (#965)
    • Calendar files (the feeds, the downloads, an interview's file, and what a sync plugin writes) no longer carry your private notes or name the people you meet as attendees, so a calendar program cannot offer to send them updates; the notes' help text says so. (#963)
    • The links in a capture notification (mail, webhook, push) are built on POSTULO_PUBLIC_URL when it is set, not on the Host the capture request carried. (#962)
    • A logo address fetched on a shared or group-owned company spends the allowance of the person who pressed the button, once, never the owner's or a shared bucket. (#960)
    • A GeoJSON file or a page's JSON-LD nested thousands deep is refused, or skipped, instead of ending in a server error. (#959)
    • The account archive carries only the exporter's own appointments at a shared or group-owned company, and its count matches the file (#966)
    • A company page, a company table and the group count name a parent or child company only to people who can see it, and a company cannot be put under one whose audience could not see it (#957)
    • A delete confirmation counts only the reader's own applications, meetings, history and documents, and a reader who cannot delete is neither offered the dialog nor sent its counts (#958)
    • The company merge preview names only the viewer's own appointments (#973)
    • The threat model says that the contacts a person adds at a shared company stay theirs (#955)
    • The share form's lookup of a person by username or address is limited per account (POSTULO_SHARE_LOOKUP_RATE, 20/h), and a hit names the address as typed, never the username it belongs to. (#943)
    • Deactivating an account and changing someone's username under People ask for a recent sign-in, like the other People actions. (#944)
    • Changing a group's members asks for a recent sign-in and is logged with who was added and who was removed, and Your groups lists every member by username. (#945)
    • Giving a listing whose company has a parent to a group shares the parent too, and a refusal is a message instead of a server error. (#946)
    • A record cannot be given to a deactivated account, a deactivated account cannot be added to a group or given a plugin access rule, and the groups pages mark the deactivated members who stay. (#947)
    • Two shares of one record to one person, or two groups of one name, made at once end as one instead of an error, and a group that is not a number is a 404. (#948)
    • A group's row is locked while members are removed, so two removals of its last two members cannot leave records it owns with nobody in it. (#989)
    • Free text written through the API (a timeline body, a status note, notes, a description, a letter) is refused over 40,000 characters, as a listing's history is, and a longer timeline entry from a page is cut there with a marker (#953)
    • Every answer of the API that says nothing of caching, the iCalendar feeds and all JSON included, is given Cache-Control private, max-age=0, no-store (#952)
    • A wrong token sent to openapi.json, to an address the API does not have or with a method it does not take is counted against the address and bars it after sixty, as on every other call (#951)
    • Sending a rendering with a token that only holds captures answers with the narrowed capture, as the list does, without the address, company, location or source (#950)
    • Renaming a company shared with you counts, against POSTULO_COMPANY_NAME_RATE (20/h), each time it is refused for a name its owner already holds, and a spent allowance refuses every name alike, so a rename is no longer a way to read the owner's other company names (#949)
    • Deleting a shared contact with "delete the letters" ticked deletes only your own letters, never another member's, and the count shown is yours. (#956)
    • With the data-protection module on, deleting a shared contact leaves the other members the same copy as without it, and the summary counts your rows only. (#961)
    • A restore refuses an archive with no signature unless --unverified, since one whose signature was removed looks like one from before signing, and the Restore page shows the verdict and prints the flag (#974)
    • An unrecognised POSTULO_BACKUP_DOWNLOAD is refused at start-up and grants nobody, where it used to grant every administrator (#978)
    • The monthly dependency workflow resolves, installs and tests the new lock in a job with no secret and hands two files to a second job, in a fresh container, that runs nothing from the new packages, checks what it commits, runs git with hooks off and alone holds the bot's token (#975)
    • The PostgreSQL apt signing key is a committed file whose fingerprint a test checks, in the image and in CI, instead of a download trusted on TLS alone (#981)
    • The browser CSP test runs under the policy production sends instead of a stale hand copy of it (#984)
    • A plugin's settings page now refuses an administrator whose access rule says they may not change its settings, an administrator who is not the operator cannot write or lift a rule about themselves or their groups, and the pages of the mail settings are held to it too (#967)
    • An installed plugin can no longer register under the name of one that ships inside Postulo: the install is refused and the built-in keeps the name (#968)
    • A wheel's metadata members are read within a size limit, an encrypted member and a wheel of thousands of files are refused (#969)
    • The webhook notifier accepts only an https:// receiver, or a plain http:// one on a private network the operator allowed (#970)
    • A plugin's templates are searched after every template Postulo and its apps own, so a theme cannot redraw a form widget or a page, as the module always promised (#971)
    • The highest catalogue serial seen is kept in the database and not only in the cache, so an old signed index is still refused after the cache is cleared, and the official index tool signs an expiry by default (#972)
    • A sync connection's lease is released only if it still holds this run's token (#988)
    • A web link saved from a card or an import is refused unless it is a complete http(s) address, whoever hands it over (#991)
    • Django 6.1.2, with four security fixes, among them a quadratic header parser reachable without signing in (#986)
    • django-allauth 65.20.0 and oauthlib 4: MFA and sign-in flow fixes, a recent sign-in no longer stands in for reauthentication, and the audit no longer ignores any advisory (#987)
    • SECURITY.md no longer promises that the audit notices every disclosure: it lags the advisories, so the process now has a weekly manual check of the dependencies' release notes (#990)
    • Companies, contacts and listings may be a group's or shared read-only or read/write, under an amended promise: nobody sees another's data unless granted, and a superuser sees any of those three but never anyone's notes, applications or documents (#896)
    • On a pull request the CI caches are restored by their exact key and never by a prefix, so a run of somebody else's code cannot read what a run on main restores or leave behind a poisoned fallback (#827)
    • The isolation sweep now asks the owner's own record to get past the lookup (and fixes the five entries that never did), checks every download it reaches for no-store, and a ratchet test fails on a new query of an owned model with no for_user or owner (#825)
    • The container's entrypoint gives gunicorn the list in POSTULO_TRUSTED_PROXIES, two more forwarding headers are stripped from strangers, and docker/compose.hardened.yml is an opt-in override with a read-only root, no capabilities, no new privileges and memory and process limits (#824)
    • htmx is configured with allowEval and allowScriptTags off, and a template lint fails on hx-on, js: prefixes, trigger filters and javascript: addresses (#823)
    • base-uri is now none, and a browser's policy-violation reports are written to the log as one line without the address, so a violation in production is seen (#822)
    • A new instance derives the browser notifications' signing key from its secret and a random salt kept in the database, so the public key every member is handed cannot be tested against a guess of the secret; an instance that already has subscriptions keeps its key so they go on working (#821)
    • Importing a newly installed plugin to see whether it loads happens in a child with an allow-listed environment, and the threat model states plainly that an installed plugin can read every account's rows and stored secrets (#820)
    • A wheel downloaded from a catalogue keeps the address's file name only when it is a well-formed wheel name, otherwise a name made here, and a file that cannot be written is a catalogue refusal and not a crash (#819)
    • A captured page address with a user name or password is refused, and a board recipe and a posting's source are chosen by the host that is fetched rather than by the whole authority (#818)
    • The server's outbound requests no longer honour HTTP_PROXY, HTTPS_PROXY or NO_PROXY unless POSTULO_OUTBOUND_USE_ENV_PROXY is true, since a pinned request through a proxy was verified against the address and not the name (#817)
    • The destination guard refuses multicast, reserved and unspecified addresses, which it used to pass as public (#816)
    • The threat model and the Hardening page say that OpenID Connect discovery, token and userinfo calls are made by allauth's session and not the guarded client, and why that is acceptable (#815)
    • The process that draws a captured page as a PDF is started with an allow-listed environment and in an empty directory of its own, so it does not inherit the secret key or the database credentials (#814)
    • A file is handed to nginx or Apache only if it is a PDF, a Word or OpenDocument file, a picture that is not markup or an archive; HTML, XML, scripts, SVG and unknown types are streamed by Django with its own policy headers, and the wiki has the Apache block beside the nginx one (#813)
    • A request that declares a body longer than anything its page accepts (25 MB, the backup limit on the backup upload) is answered 413 before the body is read; POSTULO_UPLOAD_CEILING_MB sets it (#812)
    • The backup manifest holds a scrypt mark of the field key with its own salt instead of an unsalted SHA-256, so a copy of a backup is no longer a cheap offline test of a guessed key; archives from before still restore (#811)
    • POSTULO_BACKUP_DOWNLOAD says whether every administrator, only the operator's own account or nobody may download a backup through the browser, and the threat model names the exception (#810)
    • Every name of a hard-linked media file is written into the backup as a plain file with its own bytes, where the second name used to be dropped without a word (#809)
    • Importing a kind file creates each application as a draft and moves it to the file's status through change_status, and leaves out the status-change and interview entries a file supplies, so the log says what the fields do (#808)
    • An editable table cell drops every field of the page form but the one it edits, so a post carrying an industry to add or a location correction beside the cell changes nothing else (#807)
    • A translated sentence that carries a link or a piece of the person's own text is built by htmlstrings.sentence, which escapes the translation and fills only its plain slots, instead of handing the catalogue to format_html as markup (#806)
    • The avatar, logo and plugin tiles escape the picture address, the classes and the initials they are given, instead of trusting the caller (#805)
    • The messages partial treats a message as HTML only when one of its tags is exactly safe, not when those letters appear inside another tag (#804)
    • captures/known answers the similar-by-title matches only to a token that may read, so a captures-only token cannot probe for a listing it did not make (#803)
    • Listing remote type, employment type and salary period must be one of the model's choices and a contact's email must be an address, each a 422 otherwise; a refused capture correction no longer repeats the value that was sent (#802)
    • With POSTULO_METRICS_ENABLED on and no POSTULO_METRICS_TOKEN, /metrics answers 503 and logs why instead of serving whoever asks (#801)
    • /healthz answers the status and the database to anybody, and the exact version only to the container's own probe and to a signed-in administrator (#800)
    • Wrong API tokens count against the address that presents them (sixty an hour, then 429), a token without the scope now spends its allowance, and one account's tokens share POSTULO_API_ACCOUNT_RATE (#799)
    • X-Request-ID is accepted from a trusted proxy only; from anybody else Postulo mints its own, so a stranger cannot write a chosen id into the log (#798)
    • The log says how many orphan files the sweep removed and not which, and a failed capture or logo names its source or company number and not the page's address (#797)
    • An account can make the server ask Gravatar five times an hour; the next ask answers that it is a lot of tries and requests nothing (#796)
    • The threat model states that administrators can fetch any account's profile picture, as the People page draws them, and members cannot (#795)
    • OAuth token requests are no longer redirected: a token endpoint that answers 3xx is a failure, so the client secret and the grant cannot be re-sent to another address (#794)
    • A password change, reset or set, an address change and a second-factor change now send a notification to the primary address (ACCOUNT_EMAIL_NOTIFICATIONS) (#793)
    • Only the operator's own account can remove another account's superuser flag from People; an ordinary administrator is refused, and both outcomes are logged (#792)
    • Gravatar is fetched through the public-only client, like a logo, and the Hardening page lists what POSTULO_CONNECTIONS_ALLOW_PRIVATE widens and what it never does (#537)
    • The CSRF cookie is HttpOnly like the session cookie, as the threat model says of every cookie; the script takes the token from the form it sends (#538)
    • Restoring an archive or reading a kind file blanks any web address that is not http or https, and the update check keeps a release address only if it is, instead of storing a script address to be drawn as a link (#770)
    • A bare number or object where a file expects text, an impossible exported-at or save stamp, and a mail subject with a line break are refused or ignored instead of ending in a 500 (#769)
    • A number past what a row can be called, or a foreign-script digit, in a bulk selection, a capture discard, a model choice or a spreadsheet date is dropped or refused as an invalid choice instead of crashing with a 500 (#767)
    • The API refuses an id, an offset, an amount or a moment the database cannot hold with a 422 that names the parameter, where it used to answer 500 (offsets and ids to 2^31-1, moments between 1970 and 2100, amounts to twelve digits) (#766)
    • A next address that is a bare word, a number or a dotted name falls back instead of crashing the page with a 500: only a path from the root of this site, or an absolute address on this host, is kept (#768)
    • The write scope's label and the API's description now say plainly that a write call answers with the whole record, so a token holding write can read every record it can touch (#765)
    • Making an API token asks for a recent sign-in, starts on a one-year expiry instead of never, is limited to ten an hour, and the page says how many are live (POSTULO_API_TOKEN_RATE) (#764)
    • Replaying an external_id no longer reads a listing's history entry back to a token that may only bind: another writer's text is left out of the answer (#763)
    • GET /me no longer tells a token that only holds captures whose account it belongs to; a token that may read still sees the address (#762)
    • A token that holds only captures sees the title, dates and review link of what is waiting, not its address, company, location or source, unless it also holds read (#761)
    • The API answers a wrong method, and its root, with 401 and no Allow header when there is no live token, so the route table is not probed without one (#760)
    • The company form answers a made-up identifier id and another account's the same way, and a test holds it so (#759)
    • When a forwarder that is not in POSTULO_TRUSTED_PROXIES has been barred for wrong tokens, the log now says so once an hour and names the setting, since everyone behind it shares one allowance (#757)
    • Postulo's own rate limits are counted in a file store beside the data, outside the request's transaction, so a charge survives a view that raises after spending it (POSTULO_RATELIMIT_DIR) (#756)
    • The database cache that holds the rate limits and the sign-in lockout keeps 200,000 entries instead of 300, so an outsider cannot push a counter out by making the cache grow (POSTULO_CACHE_MAX_ENTRIES) (#755)
    • The shipped gunicorn access log no longer writes the secret in a recovery, invitation, password-reset or confirmation link, or the code in a consent callback (#754)
    • Signing out drops the browser's push subscription and unregisters its worker, so reminders stop arriving on a shared browser, and sign-out goes on at once where the browser has no worker (#753)
    • A webhook delivery's body is cleared once the receiver has it, finished rows are deleted after a week, and deleting a connection deletes its deliveries (#752)
    • Expired sessions, and the imports they were holding for review, are deleted by the scheduler's pass and by the hourly tidy-up that pages ask for (#751)
    • Deleting an account also deletes the invitation it accepted, which carried its address and an administrator's note, and the sessions it was signed in on, with the imports they held (#750)
    • The Django admin's profile inline shows only the preferences that make an account usable, not birth data, nationality, gender or anything else that identifies a person (#749)
    • The OAuth consent state carries a nonce held in the session and spent on use, so a captured state cannot be replayed with another person's code; the connection is looked up among the signed-in person's own (#748)
    • A wildcard in POSTULO_ALLOWED_HOSTS is refused at start-up in production, and the links and the name in a message come from POSTULO_PUBLIC_URL when it is set, not from the Host header (#747)
    • A next address on an https instance must be https, whichever page takes it: sign-in, sign-out, sign-up and single sign-on follow the same rule as the rest of the interface (#746)
    • An account can try only so many usernames in an hour (POSTULO_USERNAME_RATE, 10/h), since naming a taken one says it is taken (#745)
    • A sign-in lock no longer shows which names belong to one account: the account's count refuses in the ordinary words, and only the typed name's own count says too many attempts (#744)
    • Authenticator secrets and recovery-code seeds are encrypted in the database under the instance's field key, and the ones already stored are encrypted by a migration (#743)
    • An API token no longer survives the password it was made under: changing, resetting or recovering the password, or a single-sign-on link wiping it, ends the account's active tokens (#742)
    • An account that signs in only through the identity provider must have done so within the last few minutes before deleting an account, taking a backup or making a recovery link, since allauth lets it through unasked (#741)
    • Making a recovery link, appointing or demoting an administrator and deleting an account from Server settings ask for a recent sign-in, as the backups do (#740)
    • A recovery link is revoked when the person it was made for signs in or changes their password themselves, instead of staying usable for its whole hour (#739)
    • Two sign-ups that overlap on PostgreSQL can no longer both become administrator: the check is taken under a lock on the settings row, and the event is logged (#738)
    • Single sign-on is started by a POST with the CSRF token behind a page that asks, not by a link on any page, and the content security policy's form-action names the provider's origin (#737)
    • Single sign-on no longer spends an invitation addressed to somebody else: the provider's verified address has to be the one it names (#736)
    • The archive import charges its phone-number lookups to the allowance, and once it is spent skips free and held numbers alike in one line (#890)
    • Backup archives are signed with a MAC under the instance's key, with a digest of every media and plugin file, and a restore refuses one that does not check out, or restores it without its plugins directory, unless told otherwise (#772)
    • Sync now is limited per account (POSTULO_CONNECTION_SYNC_RATE, 20 an hour), as Test is (#786)
    • The built-in local store can no longer be connected by hand through its create address, and a connection to it never gets copies (#785)
    • A plugin's .pth files are never run at start-up and a wheel that ships one is refused, so switching a plugin off stops its code (#784)
    • A signed catalogue index may carry a rising serial and an expiry, and an index past its expiry or older than one already seen is refused, so an old signed index cannot be replayed (#783)
    • The GeoNames download and the update check are read through a bounded GET: at most 64 MiB, a table of at most 512 MiB unpacked, and a deadline (#782)
    • A webhook, a push service, a link check and a name lookup are held to a total time, never read an answer they do not use and never inflate one, so a hostile receiver cannot stall a worker or the scheduler (#781)
    • What Postulo fetches from strangers (a posting, a link check, a logo) dials the web's ports only, 80, 443, 8080 and 8443 by default (#780)
    • The capture parser limits the tree to 512 levels, so a page of thousands of unclosed tags no longer costs minutes of CPU (#778)
    • A company or plugin logo is decoded as PNG, JPEG, GIF, WebP or ICO only, not as any of the forty formats Pillow reads, and Pillow's pixel guard is put back after use (#777)
    • Rendered-document and export downloads check the owner's folder as uploads do, and the check compares paths and not strings (#773)
    • A downloaded file's media type comes from its extension alone, so a title that reads like a data: URL cannot steer the Content-Type (#771)
    • A CV entry cannot be saved pointing at another person's entry or CV, whoever writes it (#774)
    • Pages drawn for a signed-in person carry Cache-Control: private, no-store, so Back after sign-out no longer restores them from the browser's history cache (#787)
    • Every workflow checks the uv installer's sha256 before running it, and the Dockerfile copies uv from an image named by digest, so a moved tag or a changed script can no longer alter the tool that builds a release. (#790)
    • An unverified address claim is dated and expires after the confirmation link's three days, gives way to its real holder's sign-up, and cannot be added without the password or where the several-addresses feature is off. (#891)
    • The PostgreSQL compose profile still hands its database container the whole .env, secret keys included; this is now stated in the threat model, the compose file, .env.example and the wiki, with how to close it. (#791)
    • On a bare-metal install the database directory, uploaded documents and the log are created readable by their owner alone (0700/0600) instead of by every account on the machine; the image is unchanged. (#789)
    • Anything sent as a download (the calendar file, the report as CSV and PDF, a contact's data-subject export) now carries Cache-Control: private, max-age=0, no-store, so no cache keeps it. (#788)
    • An invitation bound to an email address no longer marks that address verified at sign-up, since Postulo never mails the invitation: the address is confirmed by the usual link, and the invitation page says so. (#732)
    • Single sign-on matches the provider's address only to an account that has verified it, so a member who listed somebody else's address can no longer have them signed in as that member. (#730)
    • A password reset key or a sign-in code is mailed only to an address its account has verified, so a member can no longer claim a stranger's unused address and receive its holder inside their own account. (#731)
    • The JSON log, the Logs page and the log collector no longer receive the query string of a request that ended in a 4xx, and the token in an invitation or recovery link is replaced by ; lines already written keep the old form until they rotate away. (#733)
    • A candidate file's review no longer says which telephone numbers other accounts hold once the number allowance is spent: every number is then refused in the same words, as the form does. (#735)
    • Rate limits longer than five minutes (number collisions, capture, fetch, the API, texts, the wrong-token bar) now last their whole window on the stock database cache instead of expiring five minutes after their last use, so the allowances are no longer about twelve times what is set. (#734)
    • Every API list now refuses a limit above 200 with a 422 and declares the bound in the OpenAPI schema, so one call can no longer make a whole account the page while holding the database's write lock. (#434)
    • Importing an archive reads every file, and the manifest, through a size cap, so an entry that unpacks to gigabytes is left out and reported instead of exhausting memory, and an imported avatar or company logo goes through the same checks as an uploaded one (an SVG is sanitised, a picture re-encoded). (#466)
    • Check, Check all, a new logo address and Find logo now spend a fetch allowance, POSTULO_FETCH_RATE (300/h by default, once per address fetched): Check all stops when it runs out and says how many links it did not check. (#407)
    • A release tag is now refused unless the PostgreSQL job and the security audit passed on its commit, as well as the test legs, browser and checks; maintainers tagging with either red are blocked. (#419)
    • Isolation tests now cover administrator-only routes, ids sent in API bodies, form choices and Your details rows, and the API download of a sent document, so a dropped ownership or staff check fails the suite (#422)

    🔧 Changed

    • CI runs the unit tests on three workers: a test worker now lets go of the catalogues, URL tables and CLDR locales of every language it met after each file that met more than four, and one worker takes the translation tests, so a worker stays far below the 1 GB it reached. (#1033)
    • CI keeps the compiled translations between runs, installs Chromium's libraries in one apt run, clones history without old files for Checks, names the heavy tests' file, and the test settings fetch no map tiles. (#1033)
    • A push to an issue branch no longer runs CI by itself: its pull request runs it, on every push, so open one early, as a draft if the work is unfinished. Only main and release branches run CI on a push, and a newer push to the same pull request cancels the run it supersedes, never on main. (#1033)
    • The importer that reads Europass files is called Europass import, on the plugins page and in Your career's Import menu, so it is no longer mistaken for the Europass CV theme from Postulo Templates; its internal name stays europass, so saved switches keep working. (#911)
    • Every change reaches main through a pull request: a branch per issue, Closes #N, squash-merged once CI is green (Weblate's with a merge commit); CONTRIBUTING.md, AGENTS.md and the release steps say so. (#940)
    • messages.py check counts, per language, the drafts it leaves out of the compiled catalogues and fails when one has more than src/postulo/locale/excluded-drafts.txt records (ur 51, sw 19, id 1 today), Weblate's pull requests included; a fall asks for the number to be lowered. (#910)
    • On a phone the navigation bar at the foot of the window holds every item, in your order, and no More: it keeps one row and scrolls sideways when they do not fit, fades out the end that has more past it, and keeps the page you are on and the link you tab to in view. (#912)
    • A language menu shows a language's variants under its own name (Português: Portugal, Brasil; 中文 by script), its search box finds a language by its English name too, and a browser asking for plain pt, es, fr, en or zh gets pt-BR, es-MX, fr-FR, en-GB or zh-Hans. (#1053)
    • Lines without spaces break where a reader expects: Chinese and Japanese take the strict kinsoku set, Korean keeps its words whole (word-break: keep-all), and the image names libthai0, which Pango breaks Thai between words with. (#1052)
    • Urdu is set in Nastaliq: the interface and the CV, letter and portfolio themes ask for Noto Nastaliq Urdu first with a taller line, Arabic keeps Naskh, and check_fonts and the server overview say whether Arabic (Nastaliq) is drawn. (#1054)
    • On a phone the Applications board stacks its columns down the page, full width, with folded ones as one-line headers that open in place; the page scrolls instead of a box, and column names stick under the header. (#844)
    • The setting "Underline the page you are on" is now "Underline links in navigation": turned off, it also stops the board's column names underlining under the pointer. Card titles keep their underline. (#843)
    • A company's names in other languages are read from a popover opened by a languages icon beside its name, drawn only when there are some; adding, fetching and removing them moved to its Edit page. (#917)
    • On a phone the companies' map shows the Table link as its icon alone, with a "Table view" tooltip below it; the word is kept for screen readers and shown from the small breakpoint up. (#916)
    • On the companies' map, the Zoom in, Zoom out and Near me tooltips appear beside their button, towards the map, instead of over the neighbouring button; tooltips can now ask for a side (data-tooltip-place). (#915)
    • Your details has one help pattern: a question mark after the heading and at the top right of every section, with a tooltip and the full text in a drawer, and no helper text under any heading. (#918)
    • Form of address, pronouns and gender are stored as language-neutral concepts (he, doctor_f) and drawn in the language of the page or document; only "Other" stays free text. The profile API returns {key, label} for each. (#921)
    • Telephone numbers, postal addresses and social profiles work the same for a company, a contact and Your details: the star, the bin that removes a row at once and the help mark on all three, a browser fills a phone number only on your own, and a contact's card on its company's page lists its addresses. (#934)
    • Form of address, pronouns and gender are stored as language-neutral concepts (he, doctor_f) and drawn in the language of the page or document; only "Other" stays free text. The profile API returns {key, label} for each. (#921)
    • The instructions for AI assistants moved from CLAUDE.md to AGENTS.md, which Codex, Copilot, Cursor and others read; CLAUDE.md only imports it (#939)
    • The section menu of Your details has an icon on every entry, and Your CV photo is in it. (#920)
    • A social profile is one to a service: a service another row uses is greyed out in the menu and a second one is refused, except Other and services on many servers such as Mastodon; rows that already share one are kept and flagged. (#925)
    • An identifier can be marked primary with a star at the start of its row; a CV prints it first. (#922)
    • An address is shown on the map by a pin beside its star and bin on Your details, which asks before it is turned on. (#923)
    • Clicking the fixed front of a username's address puts the cursor in the name. (#924)
    • Every service in the social-profile and code-repository selects has a one-colour brand mark, black on the light page and white on the dark one, from Simple Icons and, for LinkedIn, from Font Awesome Free (CC BY 4.0) (#926)
    • On Your details the date of birth's calendar button stays beside the day, month and year boxes on one line down to 320px, and its picker is Postulo's own calendar (month and year menus, a keyboard-driven grid of days, named by your browser's language) instead of the browser's date input. (#928)
    • The companies map puts its zoom and Near me buttons, with tooltips, above a compact footer line in the bottom-right corner, its heading without a card or count, the Table button top right and the list of places under the heading (#909)
    • The companies map draws its zoom and Near me buttons at the bottom of the start side, above the footer's line, and on the right on a right-to-left page (#907)
    • Companies are classified by sector, the NACE section, above their industries: the company form narrows by it, and the table, industries page, report and insights group by it; ESCO's NACE links order the suggestions (#897)
    • The companies map credits its tiles and Leaflet at the end of the footer's line over the map, in the same look, instead of in a second box of Leaflet's own (#908)
    • The calendar's kinds of entry, its key and filter, come after the month, week, day or agenda rather than above it (#903)
    • The companies' map draws a marker for every address, gathered into clusters that count companies; places are grouped by where they resolve to, so two spellings of a town are one, and the list names each address's kind and says when a company is counted at several places (#703)
    • A company's Location is the town and country of its primary postal address: each location becomes a Work address on upgrade, keeping its point, the form's Location and correction boxes go, and the API, CSV and kind files still take a location line (archive format 68) (#702)
    • Only a person's own telephone number is unique across the instance: a number recorded on a contact or a company is unique within it, asks nothing and spends no allowance, and an address is unique per holder rather than per account (#329)
    • The companies' map fills the window, with the heading, a Table button, the list of places and the footer's line drawn over it, darker tiles in the dark theme, and the table's Map button carries the map icon (#849)
    • The companies' map is drawn by Leaflet over OpenStreetMap's tiles by default; Server settings → Plugins → Maps (or POSTULO_MAP_TILES) chooses a tile server of your own or no tiles, and only the map page's security policy opens to the tile server (#898)
    • Who may use a plugin or module, switch it for themselves and change its settings is a list of rules for groups and accounts, kept on a page of its own with a Groups page under People; the checkbox Each person may change it is replaced by Everybody may switch it on that page (#886)
    • Browser, Email and Webhooks are the sub-modules of a new module, Built-in notifications, and are named Browser notifications, Email notifications and Webhooks, each with its icon (#876)
    • The six features that let a field hold more than one value are the sub-modules of a new module, Multiple fields, and are named Multiple email addresses, telephone numbers, code repositories, social profiles, websites and messaging contacts, each with its icon (#875)
    • The wiki, CONTRIBUTING, PLAN and the CI header describe the project as it is now, and checks keep the settings pages, the container name and the CI header right (#406)
    • The plugin author's page and the logo docstrings describe the code: all seven notifier events, a logo as a file name, the real size limits and every shipped plugin, with checks that keep them so (#405)
    • The wiki's Configuration page now says an unreadable rate stops start-up, that implicit TLS is offered and names every rate setting, and two checks keep it so (#404)
    • The masthead search narrows the Documents lists and the Calendar first, with Search everything beside it; on the Calendar it narrows the period on screen (#848)
    • The section menu of Your details and Your career has the look of vertical tabs, still anchors to sections of one page (#870)
    • The calendar's view switch sits beside the period, aligned to the end, and the heading's row keeps Import, Export and the new-entry buttons (#861)
    • The calendar's week view is one card of seven days divided by borders, with no gaps between them (#860)
    • The calendar's Earlier, Today and Later are a button group of Previous, This month/week/day and Next, and the period opens a date picker (#858)
    • A company's logo is changed from a dialog on its edit page, with its own form for an address or an upload; the company form now draws its parent (#853)
    • Industries are drawn as coloured badges with an icon and a tone taken from their NACE section, on the company page and in the companies table (#856)
    • Listings: Columns and Views move into the filter row, at its end, and are drawn only while a table is (#840)
    • The Listings state strip is drawn like Basecoat's tabs (a tab-nav component of links, not an ARIA tablist) (#842)
    • Documents, Calendar and Your career have a question mark beside the heading: the sentence on hover and the help in a drawer (#847)
    • "Companies on a map" is now "Maps", credits GeoNames, the Unicode CLDR and Natural Earth in its description and on the map page, and has the map icon (#882)
    • The module "Employers with a structure" is now "Companies with a structure", with the pyramid icon added for its logo (#881)
    • POSTULO_CONNECTIONS_ALLOW_PRIVATE is documented as one switch for the whole instance that lets every account probe the private network through the Test buttons (#779)
    • Nationalities are named as nationalities (Portuguese, portugaise, portuguesa), not as countries, on a CV and in the menus of Your details, in English, French and Portuguese (#865)
    • The gender list offers Female, Male and Non-binary (Féminin, Masculin; Feminino, Masculino), and a profile, an archive or a candidate file that holds an old word is carried over to the new one (#864)
    • Your details is a sidebar and two columns of cards from 2xl, filled row by row, each no wider than before (#871)
    • Your career takes the whole screen like Your details, its sections keeping the measure of prose (#872)
    • The sidebar's label on Your details and Your career names the page, larger, once its heading has scrolled behind the masthead, in place of the section's title (#869)
    • Social-profile and repository rows draw the service's own brand mark where Postulo ships one, in the row and in the list of services (#867)
    • A contact row reads star, type, boxes, bin on one line, and a postal address carries its star and bin on the line of its type (#866)
    • A board card's status is changed from a pencil menu at its corner (a row per status, the current one ticked) instead of a select at the foot of the card; dragging and the keyboard keep working. (#845)
    • A company's identifiers are removed with a bin that asks first and takes the row off at once, as on Your details, instead of a Remove tick box applied on Save. (#857)
    • Delete on a company, a listing or an application asks in a dialog over its own page, with the same counted consequences, instead of on a page of its own; the page still answers at its address. (#854)
    • On phones the table searches (Listings, Applications, Companies) open in the masthead row like the page search, with the + menu hidden meanwhile and 16px text; the bar under the masthead stays for tablets and without scripts. (#833)
    • The account menu is a quarter wider, and a long address is cut short with an ellipsis (whole in the page and in a tooltip) instead of wrapping. (#834)
    • The footer's line is the administrator's motto (a new field under Server settings → Defaults, starting as the tagline it showed), defaulting to “your job search, your data”, and is left out on phones so the footer starts with “Powered by Postulo”. (#835)
    • The footer's “Powered by Postulo” no longer shows the version and links to the releases page for everybody, signed in or not. (#836)
    • Single sign-on no longer signs somebody into the account holding the same address unless POSTULO_OIDC_LINK_BY_EMAIL=true is set; an instance with a provider and the variable unset is warned at start-up (postulo.W002), and those people connect the provider once from their own account. (#892)
    • The skills section's example heading is now “Programming languages”, so “Languages” means the spoken ones wherever Postulo writes it; the demo data follows, and nothing in anybody's record changes. (#690)
    • CI runs the four-times-size Markdown worst cases in a step of their own, one process, after the unit suite: beside it they pushed the job over its memory limit. (#830)
    • Translations are made in Weblate only: docs/TRANSLATING.md, the contributor guide and the assistant's instructions say how and what a commit may do with a string, and no test pins a translated word any more. (#706)
    • The catalogues are written in Weblate's layout, so what it saves passes CI unchanged: a draft is flagged fuzzy and still compiled, references name a file, the tool sets no header date, and compile writes locale/status.json instead of it being committed. (#349)
    • A test now fails, naming the section and the list, when a career section is missing from the archive, candidate file, CV headings, importer or translation lists, so a new section cannot be half added (#692)
    • CI no longer carries a permissions: block that Forgejo 16 ignores and warns about on every job; the workflow audit is told why for ci.yml alone, and the write grants of the release and image workflows are still to be given to the instance as Authorized Integrations (#727)
    • Deleting a listing now deletes the captures that became it, with their kept pages; a capture nobody saved can be deleted at once, and manage.py prune_captures finds saved captures already stranded without a listing (#664)
    • A profile picture, its Gravatar copy and a company logo are now rows in the database that go with their owner however it is deleted (page, merge, admin, account deletion), no longer files that could be left behind (#662)
    • The test suite now runs views inside the per-request transaction production uses, so a view that forgets non_atomic_requests fails in tests too. (#572)
    • The applications and companies tables no longer de-duplicate every row on each view, so counts and pages load faster, most on large accounts. (#552)
    • The company page's duplicate check is much faster for people with hundreds of companies: legal forms are looked up by their edge word and company names are cached. (#553)
    • Social profile rows now ask for the username with the service's address shown fixed in front of it, and a name that breaks the service's rule is refused beside the box (#678)
    • Find logo and Refresh logo open a dialog on the company's page instead of a page of their own (#674)
    • Companies in Employment activities and public employment services are now offered first as the agency an application went through, with a "Show every company" link for the rest; the employment-service company type is now labelled "Public employment service" (#671)
    • Removed a set of unused code, including the orphaned posting form at /jobs/postings/new/, so Listings is the only place to add a listing (#401)
    • CI starts the browser tests first, checks lint, migrations, catalogues and the production settings once instead of on every Python, bounds each test step's time and lists each suite's slowest tests; a release also needs those checks to pass. (#712)
    • CI keeps the ESCO classification between runs and downloads it live only on its weekly run. (#714)
    • CI's browser tests no longer trace every test: a failed test is run again alone with a trace, and the job stays failed either way. (#715)
    • CI runs the unit tests on Python 3.14 for every push; 3.12 and 3.13 run in a new Every Python workflow weekly, on release branches and by hand, and a release needs all three. (#713)
    • The dev image is built only from a commit CI passed: its workflow waits for the commit's CI before building. (#716)
    • A signed-in person whose profile language is blank is now read in the instance's default language, never the browser's, and the menu's blank choice says Use the instance default. (#398)
    • Language and time and Server settings → Defaults say which language and which time zone the blank choice stands for (Deutsch de — Default, Europe/Paris — Default), with the flag and the name in its own language where the control can draw rows, and "In force now" names the language instead of printing a code. (#698)
    • The browser tests draw every page in their own copy of DejaVu Sans on every machine, so a layout that passes locally passes on CI. (#717)
    • Four browser tests wait for what they read instead of a fixed time or an idle network. (#718)
    • Three unit tests no longer depend on the machine being idle: robots.txt, the two-factor codes and a slow-filter guard. (#719)
    • Browser tests sign in through one helper that hands the browser a session, instead of filling the sign-in form in every test. (#720)
    • CI runs the browser tests on two processes, the walks of every page first. (#721)
    • scripts/messages.py stats finds the catalogue sets once instead of once per language: 4 seconds instead of 45. (#724)
    • The unit suite no longer repeats the catalogue and collectstatic checks CI's Checks job makes; pytest -m step runs them. (#725)
    • Browser tests wait for the page's state instead of a fixed time where the time is not the point, and every fixed wait left says what it measures. (#723)
    • Server settings Overview no longer walks the whole media directory on every visit; the file count and size are cached for five minutes, so the page opens fast on older instances. (#488)
    • GET /api/v1/documents now cuts its page in the database and never reads a sent document's text, so a page costs the page and not every file ever sent. (#551)
    • CONTRIBUTING.md says what belongs in the browser suite and how to keep it fast. (#726)
    • The browser suite's walks of every page check the accessibility, target size, text spacing and described-by rules on one load per page, in three walks instead of six. (#722)
    • Pages no longer re-read the package metadata for the footer's version, and the dashboard draws its widgets without re-running every context processor for each one, so it renders faster (#560)
    • Plugin policy no longer has a "switched off for the instance" step that compared a plugin's name with package names and never matched; the registry's filter on disabled packages is the instance-wide switch, as before. (#596)
    • Address rules no longer claim to print an address in its country's order, which nothing did; the order only lays out the form's lines, and the unused print and expects functions are gone. (#643)

    ✨ Added

    • A European Digital Credential (the JSON-LD file the Europass wallet gives you, sealed or not) can be uploaded as a certificate, diploma or diploma supplement and is kept byte for byte, labelled "Seal not checked by Postulo" on the Files page and in the API. (#670)

    • A European Digital Credential's edit page shows what it states: its title, who awarded it, when, and its EQF level where it gives one. Read from the file by Europass import each time, nothing fetched and nothing personal read or kept. (#670)

    • A plugin can make an interview with schedule_interview, refused exactly as the form and the API are (the kind, the end, the people now checked in the service), and read a person's deadlines, closing dates and other dated things in their own time zone with calendar_items. (#330)

    • Export a table whole: Applications, Companies and Listings each have Export, which writes every row in your columns, filters and sort as a PDF set by a theme (a new list kind, offered and remembered like the report's; landscape by default) or as a CSV of the same rows, downloaded and never filed (#341)

    • A connection's form can offer what is on the other service: a plugin's discover lists what it finds when you press Look on the server, and you choose several, each in a mode the plugin offers (read, write), from a table that keeps one that vanished in sight until you let it go (#332)

    • Print the calendar: Print, beside the views, turns the month, week, day or agenda on screen into a PDF set by a theme, every entry and none of the buttons, on the paper chosen (a month on its side by default); a new calendar kind for themes; a download, never filed (#342)

    • A Pairing page for each sync connection whose plugin lists its items: pair an item with one of your records, have the plugin make or fill one, unpair or pair again; each paired record says so, and asking the server has its own allowance, POSTULO_CONNECTION_PAIRING_RATE (#327)

    • Choose the paper: A4 or US Letter, portrait or landscape, set for the instance under Server settings → Defaults, for yourself under Settings → Export, and per export; a theme declares the papers it prints on (none is A4 portrait only), and Chromium, WeasyPrint, Word and OpenDocument honour it (#344)

    • Every capture on the API carries a posting_key, the same for two captures of one posting and different for two, so a client counts the postings waiting for review without copying same_url(); a keyed hash told to a captures-only token too, and answered per address by /captures/known (#711)

    • On Your details, a card holding a change not yet saved is ringed in soft amber, says "Not saved" beside its title, and has a dot on its entry in the section menu; undoing the change takes the mark off, and a refused save marks again the cards it brought back changed. (#927)

    • A theme can set the job-search report: a plugin may offer one (ThemeKind.REPORT), the report's page offers the theme and the language it is drawn in and remembers the theme, plain draws it as before, and a filed document records which theme drew it. (#340)

    • For sync plugins, a SyncLink names its twin's collection and a slot, so one record can have several twins; one remote item pairs with one record (bind raises AlreadyPaired), bind clears remote_gone, unbind keeps the row marked unpaired, and for_remote finds a link by collection and UID. (#326)

    • Reminders are on the plugin surface with one service each to make, change, move, tick off and untick one, each naming who did it; a reminder changed by a token or a plugin says so beside it, and a done one can be marked Not done on the calendar and through POST /reminders/{id}/reopen. (#331)

    • A map pin whose holder has a picture is drawn as it: the company's logo, or your own picture on your own addresses, ringed in the brand's colour; a contact keeps the dot, and a picture that fails to load gives its pin the dot back. (#914)

    • A plugin theme can carry its own fonts, SVG drawings and images: {% theme_asset %} writes them into the document before drawing, so nothing is fetched; the preview alone allows a data: font, and check_fonts and Overview say which scripts a theme's font draws. (#343)

    • A backup destination plugin kind (postulo.backups): chosen under Server settings → Backups, it receives each verified archive encrypted to an age recipient, retried by the scheduler, with remote retention and its own metrics; a waiting archive is never rotated here. (#325)

    • An address with a street can be pinned at its street by Nominatim, Photon or addok, off by default and switched on with the operator's consent; the scheduler looks one address up a second, existing ones included, and never moves a pin placed by hand. (#913)

    • With Postulo installed, a Postulo link followed from mail, chat or an extension opens in the app where the browser captures links, and reuses an open app window, taken to the link's address; the browser suite asks the full Chromium whether it would install Postulo, signed in and signed out. (#899)

    • A publication can be filled in from its DOI (Look up, via doi.org) and suggested from your stored ORCID record, only when you press: blanks only, nothing kept until you save, 20 presses an hour (POSTULO_LOOKUP_RATE), and a publication-lookups feature to switch it off. (#688)

    • Your career gains Citations: how often your work has been cited, typed with its source, a required date (proposed as today) and optionally one of your publications; a CV prints "Cited 1,240 times (Scopus, September 2026)", after the paper's title, and the archive and candidate file carry them. (#697)

    • Register lookup, a plugin off until an administrator switches it on: Look up beside a company's register number (France, Norway, Finland, Czechia, Poland) or LEI (GLEIF) offers the register's name, address, state and NACE activity, keeping only what you tick, and checks an LEI against the number. (#1021)

    • Settings → Export sets what every exported PDF, Word and OpenDocument file says about itself: each of title, author, subject, keywords and language from the document, fixed or left out, and Strip all metadata, which keeps the language and takes WeasyPrint's name out of a PDF (#878)

    • The languages each release must carry complete are listed once, in messages_tool.SELECTED: 0.5 adds fr-CA, en-CA, es-MX, be, cnr, fo, nn, rm, az, kk, zh-Hans, zh-Hant, Arabic, Hindi, Urdu, Bengali and Indonesian to 0.4's 39, and every release gate reads it. (#347)

    • The plugin surface holds companies, departments and the person's own card (read only, with the sign-in address), a holder's numbers, addresses and links as plain values, and services to update a company, make a department and a contact, and write several numbers, addresses and links; each takes an actor. (#328)

    • A company register number is checked against its country's check digit (France, Czechia, Norway, Finland, Estonia, Poland) and links to the register's own page (seven countries) or the EU business-register search, all offline. (#935)

    • Date pickers of Postulo's own beside every date box, and on the calendar one per view: months, a week, a day, and a range for the agenda, which now takes an end day (until=) (#933)

    • A company has a status: its legal state (active, in administration, acquired, dissolved) and dated hiring signals, shown by its name, in the table and on the map, and as a notice on its listings. (#941)

    • A company's vCard carries its mailbox, every number and address, its website and its social profiles, and importing one reads them back; the API takes and returns phone_numbers, postal_addresses and web_links on a contact as on a company. (#934)

    • A code of conduct, the Contributor Covenant 2.1, for issues, pull requests and Weblate, with reports to the maintainer's address (#1007)

    • An accessibility statement, ACCESSIBILITY.md: WCAG 2.2 AA as the aim, how it is checked, the known limits, and how to report a barrier without an account (#1008)

    • read_body, deadline_in, until and BodyRefused are on postulo.plugins.api, so a plugin can bound what a remote server sends it (#967)

    • Groups own companies, contacts and listings, records are shared with people or groups, each person's notes and listing decisions stay their own, and leaving keeps a copy of what is yours; a conflicts page settles duplicates across owners (#896)

    • Seven more European languages are scaffolded with empty catalogues for 0.5.0: Azerbaijani, Belarusian, Kazakh, Montenegrin, Norwegian Nynorsk, Romansh and Faroese; none is offered until a string is translated (#269)

    • A company holds social profiles, its LinkedIn page and its accounts, as a contact does: on its form, on its page with each service's mark, in the API, in the archive and through a merge (#906)

    • The calendar's Month, Week, Day and Agenda buttons each carry an icon, drawn alone on a small screen (#904)

    • The companies' places export as GeoJSON, and a GeoJSON file's points, matched to companies by name or Wikidata identifier and ticked, place their addresses by hand; a GeoJSON sub-module of file formats that needs Maps (#880)

    • Fetch from Wikidata also reads where the company is, and keeping it places its primary address there, unless a person placed that address by hand; taking it off places it from its words again (#701)

    • A company keeps its names in other languages, fetched from its Wikidata item on a press or typed where Wikidata has none; a letter in that language prints it, and search, the API, a merge and the archive (format 69) carry it (#673)

    • Postal addresses are placed on the map offline, at their postcode where the GeoNames postal codes were downloaded (fetch_geonames --postcodes) or at their town, or by hand on a page of their own; a contact's and your own are drawn only when you tick Show on the map (#702)

    • A company keeps its general email address, its telephone numbers and its postal addresses, on its form and page, in the API, an export and a merge, and a contact's form edits its addresses (#329)

    • Near me on the companies' map moves it to where you are, asking the browser only when pressed and sending the position nowhere; every other page forbids asking (#850)

    • The hello-world plugin is at 0.3.1 in the official catalogue, with its notifier under the name helloworld-notifier so Postulo no longer leaves it out (#593)

    • Postulo installs as an app on Chromium browsers and Safari: a completed manifest with a maskable icon and shortcuts, title-bar colours for both themes, Install in the account menu, and an offline page, the one thing the worker keeps (#895)

    • The official catalogue lists Postulo Templates 0.3.0, the curated document themes, beside the hello-world plugin (#729)

    • Server settings -> Plugins draws an installed package that registers several plugins as a parent row whose switch is its sub-plugins' master, and catalogue listings carry categories shown as badges and filterable (#883)

    • Server settings -> Plugins draws a Settings button under the switch of every plugin or module that names a settings page in its manifest, hidden when it has none and shown disabled, with the reason as its tooltip, to someone who may not change them (#885)

    • A module, Extend file formats, holds PDF (always on), DOCX, ODT, iCalendar, vCard and Europass: one that is off is not offered and its download, import and feed answer 404; the text of Word and OpenDocument files you upload is kept for search (#877)

    • A plugin or catalogue release can name the plugins and modules it needs; one whose need is missing or off is off with the reason beside it, an install that lacks one is refused naming it and offering it from the same catalogue, and a plugin others need cannot be removed (#884)

    • A module can name a Lucide icon in its manifest, drawn where a logo goes; Companies with a structure wears the pyramid and Maps the map (#874)

    • A module may have sub-modules, each with exactly one parent, and categories; switching a parent off turns its sub-modules off and takes their switches away, and switching it on brings them all back (#873)

    • The breadcrumb can be a thin line that opens while it is pointed at or focused and closes on Escape; a touch screen draws it whole (#832)

    • An optional breadcrumb under the navigation bar, sticky with it, switched on under Accessibility; the company, listing and application pages fill it (#831)

    • The date of birth has a calendar beside its day, month and year boxes, which stay as the form and take a year alone (#863)

    • The calendar has three settings: words or only chevrons on its period buttons and abbreviated or full weekday names (Appearance), and the first day of the week (Language and time), which the week view, the month grid and the reports all follow (#859)

    • Import and Export are a button group beside the heading of the Calendar, Your details and Your career, from a registry of formats a plugin can add to (#862)

    • Appearance has a setting for buttons that carry an icon and words: both, icons only (the words become a tooltip) or labels only (#852)

    • A company's page groups Edit, Merge, Export and Delete with icons, and Export offers the company as a vCard or as JSON, with or without your notes (#851)

    • A plugin can place a dated calendar entry that belongs to no application (add_reminder, idempotent by uid, never unticks) and end it (retire_reminder); removing the plugin retires its entries (#887)

    • A connected plugin may offer up to six https links on its connection's card (an optional links method), drawn as external links (#889)

    • A company's identifiers are badges with the scheme's mark or icon, and a setting chooses icon and label, icon only or label only (#855)

    • Appointments with an adviser at a public employment service, typed by hand: on the calendar, in the diary feed, in the report, in the archive and on the plugin surface (#888)

    • A question mark beside the Listings heading shows the page's summary on hover and opens its help in a drawer, replacing the helper paragraph; page titles can take a help topic. (#839)

    • “Capture a posting” and “Add a listing” on the Listings page are one button group with icons; the capture buttons elsewhere take the icon too. (#841)

    • The footer's “Help” link has a book icon beside it, and only the icon on phones (the word stays for screen readers and as a tooltip). (#838)

    • The footer's “Source code” link carries the Forgejo mark where the address is on a Forgejo host, and only the mark on phones (the words stay for screen readers and as a tooltip). (#837)

    • scripts/messages.py guard refuses, in CI and before a commit, any translation written outside Weblate, gate holds Weblate's pull requests to complete French and Portuguese, and scripts/weblate.py locks, syncs and checks the Weblate project. (#706)

    • The official plugin catalogue is published, signed, with postulo-helloworld in it: Server settings → Plugins → Where plugins may come from has an official row that is off until an administrator switches it on, and a plugin whose file it signed is labelled Official. (#729)

    • Nine languages are scaffolded with empty catalogues for 0.5.0: Chinese (simplified and traditional), Hindi, Urdu, Bengali, Indonesian, Canadian English, Canadian French and Mexican Spanish; none is offered until a string is translated, and the image now carries a CJK font (#728)

    • Diploma and Diploma supplement are kinds of file, and a certificate, diploma or supplement can say which education or certification entry it proves; the Files list, the career page, the API and the archive carry the link (#669)

    • Uploads are now checked for what they are, kept with a size and checksum the archive carries (format 54), scrubbed on a schedule without ever deleting a damaged file, and orphans are swept after a grace period (#663)

    • A CV can print a photograph of its own, kept apart from your picture, uncropped and off until a CV's "Print your CV photo" is ticked; it is left out of the plain text and Word file, and the archive format is now 55 (#668)

    • Your career gains a Driving licences section: tick the categories you hold (AM to DE) and a CV prints "Driving licence: B, A2 (Portugal)"; the licence number and everything else on the card is never kept (#691)

    • A certification's issuer is linked to one of your companies (added if new, with no industry), offered with awarding bodies first, with a notice when it has no NACE industry; archive 52, candidate file 14 (#686)

    • An education entry now links its institution to a company of yours, offering places of learning first and adding the Education industry to a company the form creates; the archive goes to format 51 and the record file to 13 (#685)

    • GitHub, Forgejo and SourceHut brand marks appear beside contact links, drawn in black or white where the owner's guidelines allow it, and an identifier scheme can name a mark (#654)

    • Your details can hold a gender, chosen from a short list or in your own words, and a CV prints it only when it is set to (#681)

    • Your details can hold your nationalities, one or several, or only whether you are a citizen of the EU, the EEA or Switzerland, and a CV prints them only when it is set to (#680)

    • Your details has a Personal details card with an optional date and place of birth, which a CV prints as one “Born …” line only when it is set to (#679)

    • Added: links to Mastodon, Bluesky, Facebook, Instagram, YouTube and Codeberg now show the service's own mark beside them where a contact's links are listed, with the generic icon kept for every other service (#654)

    • Added: CONTRIBUTING.md now says to look for an open-source library before writing one and which format an export or import picks first, and pull requests get a template that asks the same questions (#653)

    • The company map is a feature an administrator can switch off under Server settings → Plugins: off, the map page, its button and the guessing of a location's place go, and every stored coordinate stays for when it is back on. (#700)

    • Key caps now sit beside Save and next, Discard and next, Skip to the next and the search box, and Settings → Accessibility has a switch for them apart from the one for the keys; the controls name their key to screen readers either way. (#658)

    • Settings → Appearance lets you choose which identifier schemes are shown and in what order, on a company's page, in Your details, in the companies table's column list and in a CV's choice of rows; hidden ones are counted, never deleted. (#672)

    • Settings → Accessibility can save a field on Appearance and Accessibility as it changes, with the result said beside the heading; off by default, and Save stays. (#656)

    • A listing's description can be written in Markdown (lists, links, emphasis, quotes, headings), chosen per listing, drawn and sanitised on the server; the API and archive carry description_format. (#665)

    • Messaging handles, several per person and per contact, each checked against its service (Matrix, XMPP, Signal, Telegram, Threema or Other): a Messaging block on Your details and on a contact, kept in the archive, the API and a merge, never printed on a CV unless chosen; plugins can add services. (#682)

    • On a phone, tapping the search icon opens the search field in the header in place of the logo and name, with a close button, Escape and the navigation menu's Search all working; with scripts off the icon still opens the search page. (#350)

    • The language picker in Settings can be searched by a language's own name or tag, and says plainly that a partly translated language shows English where its translation stops; TRANSLATING.md describes adding a language without a developer (#72)

    • On a phone, controls grow to 44px where the pointer is coarse, the Applications board shows one column at a time with swipe snapping, and the browser suite checks every page at 390px in both themes (#73)

    • The wiki and the README now show the interface: one command, scripts/screenshots.py, retakes every picture from a seeded demo instance (and --check names stale ones), and the release steps run it. (#353)

    • New issue on Forgejo now offers a Bug report form (version, install, database, browser, steps) and a Feature request form, with a link above them telling reporters to send security problems privately (#351)

    • A CV and a letter can be downloaded as OpenDocument (.odt); each file's title, author, subject, keywords and language are shown before export and can be edited or left out, and what was sent records the choice (#480)

    • Server settings gains a Backups page for administrators: list, back up now, download and delete (with re-authentication), a daily or weekly schedule with retention, backup metrics, and a verified restore guide that prints the commands. (#242)

    • Companies, contacts, listings and applications can each be exported as a JSON file of their own from Settings > Your data and read back through a review page that adds only what is new and never changes what you have. (#659)

    • Leaving a page with unsaved work through a link or a button now asks "Leave without saving?" in Postulo's own translated dialog instead of the browser's prompt, which stays for closing the tab, reload and Back. (#657)

    • Contacts export and import as vCard 4.0 (.vcf): one contact, a company's people, every contact, a company card and your own card out; a 3.0 or 4.0 file in through a review with nothing ticked; the one mapping is on the plugin surface for the DAV plugin. (#660)

    • A company's page now warns, never blocking a save, when its register number and OpenCorporates id name different companies, or a stored identifier would be refused by its own kind today (#675)

    • Events as iCalendar: the calendar downloads as .ics, reminders become VTODO tasks in their own feed, and an .ics file is imported through a review with nothing made until you choose and no time guessed; the writer now runs on the icalendar library (#661)

    • A reference letter now records who wrote it, when, until when it is good to send and who sends it, shows its referee when you record what you sent and on the contact, and is off by default when copying to a new store (#666)

    • An experience's organisation is now linked to one of your companies, added and marked "from your career" if none matches, and kept out of the posting, contact and agency pickers, the map and the companies table until you use it for something (#683)

    • An education entry can state its EQF level, 1 to 8, kept by the Europass import beside the grade, and a CV can print it as "EQF level 7" under a new switch, off by default (#684)

    • Your career record has a Publications section: papers, books, chapters, theses, datasets and software typed like BibTeX entries, printed as one line on a CV, and carried in the archive and your own record file (#687)

    • A spoken language on your career record is chosen from a list instead of typed, and a CV prints its name in the CV's own language (Inglês on a Portuguese CV, Anglais on a French one); archive format 50, candidate file format 12 (#689)

    • The wiki's pre-install pages (Home, Installing Postulo, Getting started) can be built into a static site in every language that has them, with an English fallback and a staleness check, and a Weblate setup for translators (#352)

    • Your career gains Honours and awards and Memberships: a CV prints "title, awarded by" with the year and "role, organisation" with "since 2015" or the years, both link to your companies, and a Europass file's memberships are read (#693)

    • The career gains Courses: a title, a provider linked to your companies, dates, the hours, a summary and a link, printed on every CV as "title, provider · 40 hours · year" (#695)

    • Your career gains References, the people who will vouch for you, kept as contacts and printed on a CV only once they have agreed, with a CV switch for "References are available on request" and Europass referees read in as not yet asked (#696)

    • The career gains one section for presentations, conferences and seminars, with a role and a type that print nothing when not stated; a CV prints one line per event and Europass conferences and speaking history are read in (#694)

    🐛 Fixed

    • The pairing pages head their record column with the noun, not the verb on the status card's button: the two now have separate entries to translate, so no language shows "Enregistrer" or "Erfassen" there (#1178)
    • A Europass PDF whose attached CV is not marked as an embedded file, such as one Google Docs wrote, is read through its file specification instead of being called empty, wherever the PDF lists it and whether or not it is compressed. (#318)
    • A reminder nested under an application or an appointment comes back from an account archive with the plugin that placed it, its note and the date it was retired, as a top-level one does, not as one somebody made and still open (#1081)
    • The browser tests keep their database in a file, so each request the live server answers has a connection of its own: two requests at once no longer share one in-memory SQLite connection and its transactions, which failed a run at random with no such savepoint and then every test after it. (#1024)
    • A capture of a page nothing could be read from is accepted when a title was typed for it, and its review says nothing read it; with no title it is still refused, naming the field. (#710)
    • Adding a contact through the API needs write access to the company, not only read access, so a company shared read-only can no longer be given contacts. (#328)
    • A stored date no longer shows as an empty box in British English and other languages that write the day first (#933)
    • CI's translation guard no longer crashes on a force-pushed branch: a previous commit it cannot find makes it read the branch since main (#1011)
    • A company's LinkedIn row builds a /company/ address and a person's an /in/ address; Xing likewise. (#932)
    • On a phone the star and the bin of a telephone row stay level beside the choice of the number that gets you back in. (#931)
    • The seven cards of Your details that drew their heading on the border draw it inside the card. (#919)
    • Your details on a phone: the masthead keeps the instance's name and logo and the section menu is drawn, now held by browser tests. (#930)
    • The section menu of Your details is held on a phone by a browser test. (#929)
    • The calendar's Search everything link carries the words searched for, and a template lint refuses a filter inside translated text (#893)
    • The social-profile and repository service lists draw every mark in one colour: Bluesky, Mastodon and YouTube in black or white as their owners allow, and Codeberg, Facebook and Instagram, whose owners allow no black version, by their icon (#905)
    • A module's icon is centred in its tile on both plugin pages, and the connection pages draw each plugin with the same tile (#901)
    • The calendar's period name is centred on the page in every view, between the step buttons and the views, and its picker opens under it (#900)
    • The calendar's Next button reads Next › with its chevron after the word, and both chevrons, like the breadcrumb's, turn round on a right-to-left page (#902)
    • The kept-page quota of an account holds when uploads arrive together: the total is counted again once a rendering is written, and one that tipped it over is given back (#776)
    • The unattended media sweep refuses a pass that would remove most of the files kept, so a wrong or empty database cannot delete every person's documents (#775)
    • A plugin repository's address on Server settings → Plugins wraps instead of pushing the page sideways at 320 pixels. (#894)
    • Every page under Documents now has the heading “Documents” and its list's name as a subheading, as Listings, Applications, Companies and the Calendar do. (#846)
    • A focused address field in Your details (a service's address with its fixed front) is ringed once, by its group, instead of twice. (#868)
    • Resizing a column no longer renders the whole list page in the background or leaves a stray "Columns saved." banner, and holding an arrow key on a handle saves once instead of on every repeat. (#521)
    • Exporting an account no longer runs a query per contact, application or sent document, so a large export holds the database write lock for far less time. (#557)
    • The Listings Applied and Everything tabs no longer make a query per applied row, so they cost the same however many listings you have applied to. (#558)
    • A company's page no longer scrolls sideways on a phone, and a contact's link is no longer cut off under wider text spacing (#654)
    • Find logo now says why a site gave nothing, and always tries the favicon (#526)
    • Fixed: THIRD-PARTY.md now lists the NACE table and the GeoNames and ESCO data downloaded when an instance is provisioned, and a test finds any licence file the register does not name (#579)
    • Removed the duplicate Search row from the phone bottom bar's More menu, and More is no longer drawn when nothing is under it (#339)
    • The dashboard widget code no longer declares one field three times, and the linter now catches that mistake (#501)
    • The Applications board is a box about the height of the window that scrolls inside the page, so every column's name, open or folded, stays on screen however far down a long column you are; the box is a named region you can scroll from the keyboard, and the page scrolls on past it. (#652)
    • The doc comment on ResumeItem.order is written once instead of twice, so later edits cannot leave a stale copy (#614)
    • Who an interview is with now reads as one translatable phrase with its own list separator, on the interview list and the application page, instead of a bare "with" and a hard-coded comma (#627)
    • The API tokens page now translates the name placeholder and lists each token's scopes as translated sentences, one per item, instead of raw keys like "captures, read". (#432)
    • The Tags page counts each tag's applications in one query instead of one per tag, so it no longer gets slower as a person keeps more tags. (#554)
    • A status change posted with htmx at a table row now redirects as a plain post does, instead of answering an empty row that no page asked for (#402)
    • Putting a reminder off until tomorrow or next week on an application's page now keeps keyboard focus on that reminder's actions button instead of dropping it to the start of the page (#520)
    • The scheduler's closing-date and quiet-application announcers now handle each person once per pass instead of once per listing or application, cutting the repeated work on large accounts. (#400)
    • The monthly dependency upgrade pull request now gets the full CI run through a bot token, says what the workflow ran, and keeps the pre-commit ruff version in step with the lock (#585)
    • Importing a spreadsheet now runs as a watched errand in chunks of 200 rows, so a large sheet no longer times out, rolls back or locks the database for everyone else; imported history is not announced to notifiers. (#555)
    • A CV's page, preview, downloads and API read its entries a query per kind instead of one per entry and three per skill group, as does the career overview, so large CVs open and export as fast as small ones. (#559)
    • The GeoNames city table is indexed once at provisioning into a lookup database, so saving a company with a location no longer parses 32 MB and holds about 190 MB per worker (#399)
    • A captured page with thousands of label-like siblings or deeply nested link blocks no longer holds a worker for minutes: remembered places and the fallback's link-farm measure now read the page in one pass (#588)
    • Capturing a page without JSON-LD, or from a board with a recipe, now parses its HTML once instead of seven or eight times, so large pages and the extension's batch capture stop tying up a worker. (#591)
    • Setting a profile picture or company logo from a phone photo no longer holds a web worker for seconds: the picture is bounded to 2048 pixels and encoded at most twice (#482)
    • Binding a capture to a listing you already have now empties the review data kept on the capture, as saving and discarding already did (#523)

    ⚠️ Deprecated

    • API: a contact's single phone, phone_country and linkedin_url are deprecated in favour of its phone_numbers and web_links lists; they are still taken and returned, and will be removed in a later release. (#934)
    Downloads
  • v0.4.0 d1095b2382

    Postulo 0.4.0
    All checks were successful
    CI / Unit tests (Python 3.13) (push) Successful in 14m46s
    CI / Unit tests (Python 3.12) (push) Successful in 16m10s
    Dev image / Dev image: build, scan, push (push) Successful in 17m51s
    CI / Database tests on PostgreSQL (push) Successful in 3m57s
    CI / Security audit (push) Successful in 1m23s
    CI / Stylesheet, icons and scripts current (push) Successful in 24s
    CI / Unit tests and coverage (Python 3.14) (push) Successful in 10m57s
    CI / Browser tests (Chromium) (push) Successful in 32m36s
    Release / Publish the release (push) Successful in 23s
    Stable

    tiagoagueda released this 2026-10-04 16:58:36 +00:00 | 693 commits to main since this release

    🔒 Security

    • Deleting an account or a company removes its export archives and logos from the disk, and prune_media no longer lists a live export as an orphan: the files are found by walking every model's file fields, and an archive's bytes go with its row however the row is deleted. (#355)
    • Server settings → Data protection no longer names other accounts' contacts or shows their connections' labels and configuration: staff see counts, per plugin and kind for the record of processing. (#369)
    • A number filter given a value like 1e1000000 is ignored instead of holding a server worker for half a minute. (#499)
    • A plugin switched off for a person, by an administrator or by themselves, no longer runs for them: notifiers, queued webhooks, stores, syncs, sources and importers ask the policy where they run, the create address of an unavailable one is a 404, and what was waiting resumes when the decision is reversed. (#362)
    • Your own email now starts on STARTTLS and refuses to sign in with a password or token when no encryption is chosen. (#360)
    • The operator's POSTULO_EMAIL_HOST exemption from the private-address rule no longer covers a person's own outbox: it dials public addresses only unless the operator allows private ones. (#358)
    • A career file's document type declaration is now found wherever it sits (after a long comment, or in UTF-16) and the Europass reader parses with defusedxml, so a declaration can no longer slip past the refusal and have its entities expanded (#378)
    • Appointing an administrator no longer makes them a superuser, and Django's admin lists no person's records. Administrators appointed before keep the flag; clear it with manage.py shell (see the wiki's Accounts and invitations). (#368)
    • The image carries /app/constraints.txt, so a plugin installed on top of it cannot move anything Postulo pins; the wiki's examples use it. (#542)
    • Wrong tokens presented to /logs and /metrics are counted per address and shut it out after ten an hour; a token shorter than 24 characters is warned about. (#472)
    • Behind nginx or Apache an SVG is still sent by Postulo, so its sandbox policy always reaches the browser; the wiki shows the headers the proxy must add for the rest. (#415)
    • The capture API's "known" answer no longer carries a held listing's company, address or state, and the scope's label says it can ask. (#536)
    • Restoring a backup refuses member names that a Windows path would read as leaving the media or plugins directory. (#478)
    • An imported interview with an unsafe calendar identifier or an unknown outcome can no longer add a line to a feed or break its file. (#450)
    • Server settings → People → Plugins answers the same to a stranger whether or not the account exists. (#373)
    • The weekly dependency audit also reads gunicorn and psycopg, which only the image installs. (#583)
    • @parcel/watcher 2.6.0, for GHSA-vfj7-8cjw-p6xm (a deeply nested pattern could exhaust the stack in braces), pinned by an npm override so that Tailwind stays at 4.3.3 and the stylesheet is unchanged; build-time only, nothing of it runs in the application. (#708)
    • An outbound request no longer writes its address to the log. The HTTP client recorded every URL at INFO, so staff and a log collector could read other people's capture, connection and webhook addresses. Logs already kept hold those lines until they rotate out; delete postulo.log* to be rid of them now. (#548)
    • An invitation opens one account: on PostgreSQL, sign-ups sent at the same moment through one invitation could each get one. (#544)
    • A new API token's secret is shown by the response that made it and stored nowhere: it used to wait in the session table, where a backup kept it if the next page was never drawn. Upgrading removes any left there. (#441)
    • Failed sign-ins are counted per account: its username and each of its addresses used to have five guesses apiece, and so did each host name the instance answers to. (#489)
    • Django's admin has no login of its own and sends you through Postulo's sign-in: its form opened the whole instance on a password alone, whatever second factor the account had. If you set POSTULO_ADMIN_URL, nothing to do: you are asked for your code on the way in. (#367)
    • urllib3 2.8.0, for CVE-2026-97689 (a hostile server could make a chunked response grow without bound in memory) and CVE-2026-97687 (an HTTPS proxy could take the target's TLS settings); and virtualenv 21.14.2, a development tool, for four advisories of its own. (#324)
    • PyJWT 2.15.1 and django-allauth 65.19.5, for CVE-2026-102274. oauthlib's CVE-2026-49265 is a timing leak on an OAuth2 provider's token endpoint, which Postulo never runs; allauth still requires oauthlib 3, so the audit ignores that one advisory by name until it can be taken, and a test fails the day it can. (#323)
    • The source a capture keeps is never served as a page: it is stored as text, shown escaped and downloaded as text/plain. Kept files are capped each and per account and refused before they are read, and an API request too large to read answers 413 as a problem document instead of an HTML 400. (#256)
    • The image takes Debian's security updates on every build the project makes; the layer that upgrades had been served from the builder's cache. Building your own: add --pull --build-arg POSTULO_APT_REFRESH="$(date -u +%FT%TZ)", or a cached build keeps its old packages. (#300)
    • Invitation tokens are stored as fingerprints and the link is shown once, on the page that
      made it; links already sent keep working. (#232)
    • Forwarding headers are believed only from this host unless you name your proxy.
      Upgrading behind a container or another machine: set POSTULO_TRUSTED_PROXIES to its
      network
      , which Server settings → Overview now shows. (#232)
    • The email notifier sends only to your own verified addresses, and every Test button
      is bounded by POSTULO_CONNECTION_TEST_RATE. (#232)
    • Every address that names a record is swept for another account's, on pages and the
      API alike, and the feeds, pictures, logos and export have boundary tests. (#232)
    • The content security policy applies in every settings module and the browser suite fails
      on a violation; CV and letter previews, which it had been refusing to style, carry a
      nonce. (#232)
    • A capture address sent to the API holding a line break or another control character is refused with a 422, and addresses are logged quoted, so one can no longer write a forged line into the console log. (#321)
    • The public-only address check judges the IPv4 address inside an IPv6 one (NAT64, 6to4, Teredo, mapped) and refuses site-local fec0::/10 and the rest of the reserved ::/8. On a host with NAT64, 64:ff9b::a9fe:a9fe reached the cloud metadata service. (#321)
    • A page, its robots.txt and a logo are read as they stream in and stop at their size limit, and the whole download, headers included, is over within 30, 10 and 20 seconds; only gzip and deflate are unpacked. A hostile site could make a worker hold as much as it liked, for as long as it liked. (#321)
    • import_data takes an archive's username only when it passes the username rules and is not reserved; otherwise the account keeps its own and the report says so. A username ending in a line break no longer passes the pattern. (#321)
    • Find logo reads an icon's sizes in time that follows its length: a company's website with an over-long one could hold a worker for hours. (#321)
    • Importing an archive builds every row from the fields an export writes, so a crafted file can no longer write into another account's records, profile or files. (#354)

    🔧 Changed

    • The Applications board is centred on the screen, its columns open and fold in motion (not where less motion is asked for), and several can be open at once: a strip opens beside the open columns and an open column's heading folds it alone. ?status= takes several, comma-separated, in the table too. (#709)
    • The interface says Type, not Kind, for what sort of thing something is; the design page's typography section is Typography, and the report's period selector is Period. (#705)
    • Copyright notices name the Postulo contributors as the holder, and CONTRIBUTING.md says on what terms a contribution comes in. (#346)
    • Your details keeps its help where the question is asked: each card has a question mark that shows one sentence and opens the card's help in a drawer, and what a field is for is a tooltip on hover and on focus, wherever there is room for one. With scripts off the help is on the page. (#302)
    • On the Applications board a column's heading folds the board to that column, the others becoming strips that keep their name and count; All columns undoes it. This replaces the board's status filter, and ?status= still says which. The board also scrolls sideways while a card is dragged near its edge. (#315)
    • A telephone number is checked against its country's numbering plan: an impossible one is refused, saying why, an unplaceable one kept with a warning, and all are grouped their country's way. API: a contact's national number needs + or the new phone_country, else 422, and comes back in international form. (#304)
    • A postal address answers to its country before it is kept: a postcode in a form it never uses, or a required part left empty, is refused beside it; postcode spacing is put right; a town and a country alone are kept. Rows read kind first, in country order. Addresses already kept stay, marked, until changed. (#306)
    • The filters above the Applications table are in its column headers: status, outcome and Gone quiet under Status, the tag under Tags or Role; with that column hidden they are under Narrow. Addresses and saved views mean what they did, and the board keeps its form. (#314)
    • A social profile or a code repository is on a service (LinkedIn, Mastodon, GitHub, a Forgejo instance) or Other: chosen first in its row, with an icon, and the address is checked against it. Name is asked only for Other. The archive and the API carry the service, and a plugin can add more. (#305)
    • Language codes are BCP 47 tags in canonical form wherever Postulo writes one: pt-BR and en-GB, not pt-br, and Serbian is sr-Cyrl. If you read them from the API, a webhook, a store plugin or an archive, compare without regard to case or expect the new spelling. Export format 28. (#337)
    • On Your details, Primary is a star and Remove a bin that asks in a dialog and takes the number, link, address or identifier off at once, without Save and without losing what is typed elsewhere. The number that gets you back in cannot be removed, and a copy of the page open in another tab still saves. (#303)
    • On Companies, Applications and Listings the masthead's search box narrows the table, with a button beside it to search everything instead; the page's own box is gone, and Listings can now be searched. On a phone the magnifier opens the same box. (#313)
    • Python 3.12.4 is the oldest Python Postulo runs on: the 3.12 releases before it call some reserved address ranges public. Installed without a container: check python --version before upgrading. (#321)
    • Reminders are kept on the calendar: done, later, edit and delete in every view, a new one from any day, and the agenda narrowed to reminders, overdue ones first, replaces the Reminders page and its navigation entry; its old address leads there. (#316)
    • Postulo's modules import in one direction now, and a test keeps them so. (#248)
    • A table's sort icon sits at the end of its header, so a row of them lines up: on the right in English, on the left in Arabic, with a numeric column's name beside it. A column's resize handle no longer covers part of it. (#317)
    • The forms on Your details and every Settings page stop at the company form's width beside their sidebar, instead of running across a wide monitor; from 1536 pixels a contact's details sit beside its numbers and links, and the capture form beside its help. (#320)
    • A plugin's switch under Server settings is labelled Enabled, and the pills that said Off say Disabled — for a plugin, a repository and a connection; a shipped plugin reads enabled or disabled on your own Plugins page. The amber pills on the server's page are drawn as every other pill is. (#311)
    • The forms under Server settings keep that width too: Sign-in, Email, Capture, Defaults, Data protection, the record of processing and the pages about one person stop at 672 pixels beside the sidebar, while its tables and lists keep the screen. (#320)
    • A page with no structured data is read further: its OpenGraph and Twitter card properties, a title line split into job, employer and place where the page vouches for each, and a salary and closing date in the page's own language. What nothing vouches for stays empty. (#267)
    • The company form uses the whole page: from 1536 pixels wide its details and its identifiers sit side by side, each as wide as the form was, and below that one under the other. (#210)
    • Every CI job is named for what it checks (Unit tests (Python 3.12), Browser tests (Chromium), Release image: build, scan, push), so a failed one says what failed; the release gate follows the new names. (#319)
    • The main navigation follows your own order, set with arrows under Settings → Appearance; each item has an icon, and what the row has no room for is under More. On a phone it is a bar at the foot of the screen, and search is reachable there and from the masthead. (#299)
    • Download draft PDF on a CV or a letter hands back the PDF and files nothing; Export
      PDF
      on an unchanged CV hands back the version already filed. A store is now sent only
      what went with an application, and reports: what it already holds stays, and copies
      still waiting for an export are withdrawn.
      (#236)
    • A figure is drawn one way on the dashboard, the report and the widgets, and the ones that mean something carry a colour: an offer green, a rejection rose, a conversation the brand. (#292)
    • A pill, an alert, a card, a table, a tile and an empty state are each drawn by one component on Basecoat's structure, and a section's heading by one of Postulo's own; the tag, chip and alert-* classes are gone. (#291)
    • Form rows are Basecoat's field family: a label, a box, help and errors in one structure, checkboxes and radios drawn by the stylesheet, and the flag choosers as input groups; the four field-* classes are gone. (#290)
    • CI runs the suite on every core with the browser group left out, measures coverage on one leg against a floor, pins uv, zizmor and pip-audit, and refuses a release or an image unless CI passed on the tagged commit; a monthly workflow proposes lock-file upgrades. (#233)
    • The pages that grew with the size of a search no longer do: search, the board, the companies table, the listings tabs, the report, the figures and bulk tagging each do a fixed amount of work whatever you have recorded. (#231)
    • A table's filters live in the column headers: click a column's name to open its filter, and only the ones in use are on screen. Sorting moved to an icon beside the name, since the name is now the filter. (#253)
    • On an application's page the status, a new timeline entry and a ticked-off reminder change in place instead of reloading the page and returning you to the top. (#257)
    • Installing a plugin keeps the directory as it was, checks that the new one imports before recording it, and puts everything back if it does not; manage.py plugins rollback undoes the last install. Removing one takes the dependencies nothing else uses. (#246)
    • API refusals are RFC 9457 problem documents (application/problem+json, with type, title and status beside detail). A validation failure's field errors moved from detail to errors; a client that iterated detail reads errors now. (#296)
    • The underline on the current navigation link is a choice under Settings →
      Accessibility
      ; the weight and the shade still mark the page when it is off, and a
      high-contrast theme underlines it whatever you chose. (#289)
    • Settings has an Accessibility section holding the career order number and the
      keyboard-shortcuts switch, which were under Appearance. (#281)
    • docs/PLAN.md no longer restates where the project stands and is revised at each
      release. (#255)
    • A changelog entry is one line ending in its issue, where the reasoning lives; the test
      suite holds Unreleased to that. (#254)
    • Each plugin on the administrator's pages has a switch and a "may change it" box instead
      of a four-way dropdown; transports and identifier registries appear, fixed on. (#286)
    • Settings → Plugins no longer names who decided a plugin for your account, and shipped
      rows carry no sentence. (#287)
    • The component layer draws on Basecoat, one structural file at a time under Postulo's own
      paint, starting with the button. (#262)
    • Shared markup is a django-cotton component rather than an include with a with chain.
      (#263)
    • The ESCO classification is no longer committed: manage.py fetch_esco downloads the
      revision it names into src/postulo/jobs/data/, and without the file Postulo runs on,
      matching no titles to no codes. (#266)
    • The foot of every page names this instance and links its source, the help and, set by POSTULO_LEGAL_NOTICE_URL, your legal notice; POSTULO_SOURCE_URL points the source at your own code. The version is shown only to people signed in. (#212)
    • On Your details and a company's form, a kind of identifier another row already holds cannot be chosen again (Other excepted); a duplicate is still refused, naming the kind on its row, and a kind freed by Remove can be taken in the same save. (#307)
    • In European Portuguese an API token is “Token da API”, the wording its maintainer chose in Weblate. (#349)
    • Every list of choices is a button with a list of its own: a flag beside each language and country, an icon beside each kind and each status on the board's cards, time zones grouped by continent, and a box that narrows a long list. Without scripts it is the browser's own list, as before. (#301)

    ✨ Added

    • Record what you sent can email the CV, the letter and the files you chose from your own address when you have a Your own email connection: it sends first, and freezes and records only if the email went. (#361)
    • On Your details and Your career the sidebar's label names the section you are reading once its title has scrolled away. (#677)
    • Your career is in the account menu after Your details, and the menu marks the row of the page you are on. (#676)
    • The Server settings and Settings sidebars stay in view while a long page scrolls. (#699)
    • An administrator adds an instance's own kinds of identifier, in JSON, from Server settings → Plugins: a key, a name per language, a pattern that is not a regular expression, a link. Offered beside Postulo's. Removing one deletes no identifier and changing its pattern refuses none: it is kept as it was, marked. (#311)
    • A CV chooses which of your details it prints: which number, email address, link of each kind and identifiers, the location or not, and your form of address and pronouns if you say so. Nothing changes until you choose; a chosen detail you delete prints none. The API gains PATCH /cvs/{id}; export format 27. (#308)
    • Your name takes a form of address and pronouns, from your career record's language or typed as Other, printed only if a CV says so (#308). A blank location prints the town and country of your primary address on CVs and letters; type one to print that instead. The API gains /profile; export format 26. (#309)
    • A correction on the review screen is remembered where the page showed it, for that site and for you alone: your next capture from it is read there first, each such field says so, a place wrong twice in a row is dropped, and Settings → Capture lists and forgets them. (#267)
    • A skill keeps the ESCO skill its name is: the skill box offers the classification's names as you type, a CV in another language prints its name there where you wrote none, and the export carries it (format 24). Skills already recorded are matched when next saved. (#266)
    • A listing has a history: a message, an email, a call, a note, a file from your documents, or the same advert captured again elsewhere, added on its page or from the capture's review. An application shows it first on its timeline, and it is in your export (format 23). (#270)
    • A token can hold listings:bind, which adds to a listing's history and lists listings to choose one, and nothing else: the scope for a mail client that files messages. Plugins get record_listing_event. (#270)
    • The Europass import reads the PDF europass.europa.eu gives you, through the CV attached inside it (the Candidate XML the platform has written since 2020), and a PDF it cannot read says why and what to upload instead. (#244)
    • A capture can keep the page it was read from: the source as it was parsed, and a rendering sent by the browser extension or drawn from the source. Off until an administrator allows it under Server settings → Capture and you switch it on under Settings → Capture; what is kept is in your export. (#256)
    • An application moved to Rejected, Withdrawn or Ghosted can say why — pay, location, not a match, filled internally, your own choice, or other with a note — then or afterwards; the stage it had reached is read from its timeline, and a widget, Where and why applications end, counts both. (#239)
    • An application can name who referred you and the agency it went through; the posting's company stays the employer, and Where they came from gains By referrer and By agency. Both are in the API, the report's spreadsheet and the archive (format 20), which now carries the people recorded at no company. (#239)
    • A company's or a contact's page says which of your other records look like the same one — the name without its legal form, the website, an identifier, an email address — and Merge shows what would move first, keeps what differs in the kept record's notes and writes on every timeline it touches. (#239)
    • Two versions of a CV or a letter can be compared line by line, from the document's page
      and from an application's documents; a sent CV keeps its words beside its markup, in the
      export too (format 19). A CV frozen before this kept none, and the page says so.
      (#236)
    • A CV can be copied as plain text from a page that needs no script, and downloaded as
      .txt or as a Word .docx, written with no new dependency. Formats are a registry, so
      another is added the same way; none of them is filed as sent. (#236)
    • Your details and your career can be taken out as one readable JSON file and put back, here or on another Postulo: the page shows what it found and what adding it would do, adds what is new and leaves what you already have exactly as it was. The picture is left out. (#181)
    • manage.py check_fonts and Server settings → Overview say which of the scripts the offered languages need this instance can actually draw, and the document themes name their per-script fallbacks; the image's CJK is a build argument, not a default. (#74)
    • Start-up refuses a setting outside its vocabulary, a malformed rate or a bare POSTULO_PUBLIC_URL, with a message naming it. Every log line names its request, scheduler pass or errand, X-Request-ID is honoured and echoed, and POSTULO_LOG_FORMAT=json is there for a parsed console. (#233)
    • A built-in Webhook notifier posts every event as signed JSON to an address you give, delivered by the scheduler with backoff; three new events — a status change, an interview scheduled or moved, an offer recorded — are on for it and off for the notifiers that reach a person. (#240)
    • An offer can be recorded on an application — pay, variable pay, equity, benefits, where, holidays, start and answer-by dates — and every application at Offer is compared side by side, amounts brought to a year within a currency; the answer-by date is on the calendar with a reminder. (#237)
    • A filtered, sorted table can be kept under a name and chosen again from Views, columns included; one view per table can be what the table opens as. A view saved against a table that has since changed still opens and says what it left out. (#259)
    • A CV's preview is on its own page, beside the entries, redrawn after every change; a letter's likewise, and its application picker redraws it in place. (#293)
    • The calendar draws application deadlines and listing closing dates as whole days, the key along the top is also the filter, and the diary file carries both as all-day entries. A new notifier event says when a listing you are considering is close to its deadline — three days' warning unless you change it. (#238)
    • A reminder can be edited, deleted, and put off until tomorrow, next week or a time you choose, from its row and from the API (PATCH and DELETE). Putting one off moves it instead of making a second one. (#238)
    • A tag can take one of seven colours and one of twelve icons, drawn wherever the tag is — the board, the table, an application and the tags page — and the colour box that did nothing is a palette. Colours you typed become the nearest one, or grey. (#285)
    • Your picture is kept at the size you gave it rather than 256 square, so the profile page stops upscaling it, and a Gravatar copy is fetched large enough to show. Pictures already stored stay 256 until re-uploaded or re-fetched. (#265)
    • A company logo or a plugin's mark may be an SVG, sanitised against an allowlist; a raster one is kept at its own size rather than a 256-pixel square. Logos already stored stay square — Refresh re-fetches one with an address, and an upload is replaced by uploading again. (#264)
    • Form fields explain themselves: what goes in the box, in what form, and what Postulo does with it afterwards. (#205)
    • Postulo asks before a page is left with something typed into it and not saved, so a click in the sidebar no longer discards an unsent letter. (#258)
    • Company, Location and Found via suggest what you have already recorded, on the intake form, the listing form and the capture review. (#261)
    • The plugin surface carries the notifier contract, the records a sync works on and the calls that write to them, and postulo.plugins.testing publishes the check that a plugin imports nothing else. (#229)
    • Settings → Appearance offers a compact interface for anybody who wants more rows on a
      screen. Comfortable stays the default, and nothing you can click gets smaller. (#292)
    • Server settings → Design shows every piece the interface is built from, on one page,
      in whichever theme you are reading it in. (#292)
    • The slow things — fetching a posting's page, finding a logo, rendering a PDF, building an
      export archive, telling your notifiers — can be done by a worker instead of while you
      wait, with a page that says what is happening. Off by default. (#247)
    • Listings are a table like the other two: sorted by any column, narrowed per column, with
      the columns you choose, and a bar that shortlists or discards everything you ticked in
      one go. The state tabs and their counts stay above it. (#160)
    • An upload records which language it is in and a sent PDF records the one it declared when
      it was frozen, so a store no longer files a German certificate under the language its
      owner reads Postulo in, and editing a CV no longer rewrites what was sent. (#283)
    • Each CV and cover letter says which language it is written in, as a flag beside its kind
      on the lists and the document's own page, or the language's own name where no flag
      stands for it. (#280)
    • A postal address's country chooser shows the chosen flag, as the telephone field does.
      (#214)
    • POSTULO_UPDATE_CHECK=true lets the scheduler ask the project's release address once a
      day whether a newer Postulo exists, shown on Server settings → Overview; off by
      default. (#272)
    • Settings → Plugins shows each plugin's logo. (#288)
    • Server settings → Defaults shows each language's flag, as the language picker does.
      (#208)
    • Anybody may write an importer: postulo.importers is an open group, Record is on the
      plugin surface, and the contract is on the wiki. (#105)
    • The browser suite reads every page under WCAG's text-spacing override and at 200% zoom.
      (#278)
    • The title box offers the ESCO unit groups in the language read, on the intake form, the
      listing form and the capture review; the posting keeps the ISCO-08 code its title
      matches beside it, on its page, the API and the export (format 18). Postings already
      recorded have no code until their title is next saved.
      (#266)
    • Server settings → Defaults draws a bar under each language, green for strings a speaker has reviewed, amber for drafts and grey for the untranslated, with the three counts in words beside it. (#312)

    ⚠️ Deprecated

    • Plugins: phone_number_is_taken(number) without asked_by= is deprecated and will be removed in a later release. Pass the account the plugin works for: the answer is then charged to that account's allowance of answers about numbers recorded here, and once it is spent every number is answered as taken. (#333)

    🐛 Fixed

    • A company's location written with the country in another language ("Valencia, España", "Köln, Deutschland") now places it: country names come from the CLDR tables of the new babel dependency, in every language Postulo speaks. (#534)
    • Pages that wait on another server (link checks, a logo address, Gravatar, the capture API, connection tests, sending to a store, the mail test) no longer hold the database lock meanwhile; Check them all stops after a minute and says how many links it left. (#357)
    • Restoring a backup on PostgreSQL is all or nothing: the dump is applied by psql in one transaction that stops at the first error, so a dump that fails half way leaves the database as it was. (#477)
    • Deleting an account removes its files only once the deletion commits, so a request that fails afterwards no longer leaves rows whose files are gone; an export whose file has gone from the disk says it has expired. (#355)
    • The phone's bottom navigation bar no longer opens taller than it ends up: the safe-area padding now applies only to an installed app, not to a browser tab whose toolbar already stands under the bar. (#338)
    • Capture now follows a redirected robots.txt (up to five hops) and reads the robots.txt of any other site a posting redirects to, instead of treating a redirect as "no rules". (#364)
    • The GeoNames and ESCO downloads now use the guarded client, the entrypoint follows POSTULO_GEOLOCATIONS_DIR and POSTULO_SKIP_GEONAMES=1 turns the start-up download off; SECURITY, README and PLAN no longer call capture the only outbound request. (#366)
    • Search highlighting and excerpts no longer shift onto the wrong letters after a Turkish dotted capital İ, and long texts no longer return an empty excerpt. (#377)
    • The Applications by month widget now lists every month from your first application to the current one, with nought for a quiet month, and names each month in your language ("février 2026"). (#397)
    • A listing's Apply page now shows the new-tags box its help text promised, and tags typed there are saved with the application. (#408)
    • A portfolio can now be saved in a plugin theme that sets only portfolios; the theme is checked against the document's own kind, not always as a CV. (#412)
    • The test settings no longer read a developer's .env and pin the cache, background work and file log handler, so the suite behaves the same on every machine and an empty POSTULO_LOG_DIR no longer breaks it. (#414)
    • the two tallies on the job-search report no longer share one id in Greek, Cyrillic and other non-Latin languages, so each region is named by its own heading. (#629)
    • postulo-messages now writes the project's own licence, name and issues URL into a plugin's catalogue headers instead of Postulo's AGPL line and tracker; the core's catalogues are unchanged. (#417)
    • A pre-release tag no longer moves :latest or the stable minor image tag; the image workflow takes its tags from release_tools.py, and a +build tag is refused. (#418)
    • The 500 page now speaks the reader's language with a matching lang and dir and is styled under the CSP; a refused CSRF token and a bad request get Postulo's own translated pages instead of Django's. (#421)
    • The demo data is valid and plainly fictional: contact addresses are ASCII, the French CV entry says Paris, and Initech's LEI and the Wikidata ids no longer point at real companies or places. (#423)
    • "Fetch my Gravatar again" no longer shows an unreachable or empty Gravatar as a success: one helper now reports found, none and error as success, info and warning from both places. (#425)
    • Saving Settings → Language and time no longer erases a stored language the instance has withdrawn; the closed row shows the instance default instead of a blank. (#426)
    • The Account page's Two-factor card no longer tells a passkey-only account it has an authenticator app, and now says a password sign-in skips the second factor in a browser marked trusted, for the configured days. (#428)
    • The Account page no longer offers "Add one" for a passkey at an IP address (IPv4 or IPv6, HTTP or HTTPS), where browsers refuse it, and says to use a domain name; IPv6 hosts are no longer garbled. (#429)
    • Armenian and Georgian are now declared in the script table and probed, so check_fonts and the Server settings font status ask whether they can be drawn, and a test derived from the language registry catches the next one. (#455)
    • A telephone or address row now starts on the flag's country for every offered language that has one (Ukrainian, Turkish, Catalan, Welsh and 27 more), not only for EU languages. (#456)
    • A bulk status move on the Applications table no longer records the person's own username as the timeline actor ("via alex"); the event actor stays blank, as on every other browser path. (#410)
    • Deleting an offer now ticks off its outstanding answer-by reminder instead of leaving it on the calendar and announced to the person's notifiers. (#444)
    • A suggestion's action is now only accept or decline; any other word answers 404 instead of silently accepting the suggestion into the timeline and possibly moving the application's status. (#447)
    • Insights: the Interviews column of the sources tables and the Interviews widget now count the same applications: those with an interview entry or that reached Interviewing or Assessment; Screening alone no longer counts. (#449)
    • A calendar narrowed with ?kinds= keeps the narrowing on the day number, the "and N more" link and the week view's day headings, so they open the same set of events. (#452)
    • The calendar no longer reads out an interview nobody came to as "Cancelled interview:" to screen readers; a no-show now has its own spoken prefix, and a cancellation is unchanged. (#453)
    • Removing a store connection now deletes its unsent document copies, so no failed copy is left that can never be sent; sent copies keep their reference, and orphans already left behind are cleaned up. (#511)
    • A waiting capture whose kept page expires after the retention period no longer loses what its review would teach about the site; expiry now saves the places first, as deleting the page by hand does. (#524)
    • The capture form's help now says when the instance is set not to consult a site's robots.txt, instead of always claiming it is honoured. (#630)
    • The email sign-in security tests can now fail: the guess limit, the no-enumeration answer and the second-factor redirect are each checked by behaviour, not by assertions any outcome satisfied. (#631)
    • Connections: a sync report is stored as numbers and worded in the reader's language with plural forms; a private-address refusal says so once, only when it is one; Web Push errors are translatable in the browser plugin's catalogue. (#384)
    • Notifications are worded in the recipient's time zone and language: closing notices count days on their calendar, interview messages show the local formatted time, status titles use their language's label. (#385)
    • A document's sent/created day for stores and the retention report's "recorded" and cutoff dates are now taken in the person's time zone rather than UTC, and the report shows them in the locale's date format. (#386)
    • A cover letter's date at the top is now the same locale-formatted date its {{ date }} placeholder prints, instead of an English "j F Y" pattern; the template lint now also rejects a literal format in {% now %}. (#387)
    • Bulk actions now say "N applications/companies/listings changed." as a real plural form per table, so every language's plural categories and the verb's agreement with the noun are right. (#390)
    • Sentences with a count now use plural forms everywhere ("Added 1 entry.", "1 copy sent.", "1 day", "1 web link"), so languages with several plural forms can translate them; two new checks keep it so. (#391)
    • Translated labels are no longer lower-cased inside sentences: no-show entries, offer terms, sent-document search hits and table filter/sort names now keep the catalogue's capitalisation in every language, and CV stays CV. (#392)
    • The spreadsheet import preview names each status instead of its key, skip reasons and the row-count sentence are translatable with plural forms, the summary shows labelled counts, and the tab title follows the language. (#395)
    • The Browser and Email notifier labels, the "This is before the start date." career error and the unreadable-secrets message now go through translation, so they appear in the reader's language. (#566)
    • Every dated career entry (Experience, Education, Project, Certification) now refuses an end date before its start, and the error message is translatable instead of English in every language. (#621)
    • A company's logo file is now kept until the change to its row commits, so a failed save no longer leaves a missing or orphaned logo; Find logo on a company deleted meanwhile is refused cleanly. (#525)
    • Adding a contact to a department now reuses the company's existing team when the typed name differs only in capitals, instead of creating a duplicate department. (#528)
    • Changing a company's parent now counts the companies already under it, so an ownership chain built from the bottom up can no longer grow past the ten-deep cap. (#532)
    • manage.py fetch_esco now deletes the ESCO files of the revision it replaces once the new ones are written, and manage.py check reports two revisions of either file, so an upgrade no longer breaks saving listings. (#535)
    • A posting field that runs past 500 characters, a NaN, infinite or oversized salary, or a currency that is no ISO code no longer makes the schema.org or board source fail and lose the rest of the posting; only that field is dropped. (#592)
    • A document store is now told the kind's name, and any error sentence it raises, in the owner's language whichever path sends the copy, so Paperless and other stores no longer file one kind under two names. (#605)
    • An imported CV's website and ORCID are now checked as the profile forms check them; a value that fails is named in the import report instead of being stored and printed on the CV. (#610)
    • A CV importer plugin that raises in can_handle is now logged and skipped, and one that raises in read shows a message naming it, instead of a 500 that blocked every upload on the import page. (#611)
    • The career preview now shows your Links (title, type and host) after Projects, so "Everything you have written" really lists every career section. (#612)
    • The listing form's Cancel now returns to the listing when editing, and to the company it was opened for (else the Listings page) when adding, instead of always going to Companies. (#628)
    • Tags and industries: with scripts on, the chip control no longer hides the help text and validation errors of the tag and industry fields (and their new-name fields), and the chip input is described by them. (#522)
    • Fixed: the uploads on an application's Documents page now name their file to screen readers on Download and "Send to stores now", as the other documents pages do. (#516)
    • A portfolio now prints "2020 – present" for a current role or course, instead of the start year alone, in the page, the text and the Word file. (#515)
    • Recording what you sent now offers only reusable-template letters and files that no newer version replaces; a one-off letter or an older version already chosen stays selectable. (#510)
    • Bulk Shortlist, Discard and Restore on listings now count only the rows they changed and no longer overwrite the decision date of listings already in the target state. (#508)
    • Dashboard picker: the blurbs of How long they take, Outcomes and Where they came from now describe what those widgets actually show. (#502)
    • The errand page's screen-reader announcement now works: the live region stays in place and only its contents are swapped on each poll, so "done" and "failed" are announced reliably. (#500)
    • Translation catalogues no longer lose entries whose text holds a backslash before n or t, or a carriage return: the .po reader decodes escapes in one pass and the writer escapes carriage returns. (#498)
    • The translation check now formats every translated form the way the runtime does, so a stray "%" or a dropped positional placeholder no longer gets through and breaks pages in that language. (#497)
    • A refused seed_demo run no longer marks the account's address verified and primary, and vouching for an address (also in createsuperuser) now leaves the account exactly one primary address. (#496)
    • Documents now has a menu on its four lists (CVs, letters, files, sent documents), stays lit on every page of the section, and the Files list can be narrowed by kind like the letters. (#667)
    • The open list of every select is drawn in the colours of its box, in the light and the dark theme, including the column filters, the board's status and the country in a group. (#655)
    • The dashboard's kept figures follow reminders, interviews, the clock and the time zone, so Quiet, In the diary, the interview kinds and the months no longer disagree with the widgets beside them. (#396)
    • A page is never drawn in a language the instance does not offer or nobody has begun translating: a person with no language of their own, or whose language was withdrawn, reads the instance default, and a visitor the browser's language only where it is offered. (#398)
    • The language picker and the bars in Server settings no longer show languages as complete that are not: the progress file is refreshed, and CI now fails when it is stale. (#495)
    • The calendar and the report start the week on Monday in Bulgarian, Bosnian, Greek, Estonian, Irish, Armenian, Icelandic, Luxembourgish, Slovene and Albanian, which Django had starting on Sunday. (#569)
    • The company map no longer reads a capital city as a country: Melbourne, Victoria is placed in Australia instead of being dropped as the Seychelles, and a country is known by its ISO codes and English name. A country written in another language still narrows nothing. (#534)
    • A job title typed in one gender, or with a double space or a decomposed accent, finds its ISCO-08 code: ESCO's développeur de logiciels/développeuse de logiciels is indexed as both forms, and a name that sits in two unit groups gives no code instead of an arbitrary one. (#533)
    • On a page that embeds several postings, the schema.org source picks the one whose url is the page before it looks at identifiers, reads an identifier's value (never the employer's name in it) and matches it as a whole path segment or query value, so 1000 no longer picks the posting of /jobs/10001. (#590)
    • The schema.org source reads a microdata or RDFa posting inside a page-level item (a WebPage on the body, or the posting as its mainEntity): an item that is nobody's property is read wherever it sits, and a posting held inside another is found as in JSON-LD. (#589)
    • Reading a page: an omitted end tag (li, p, dt/dd, td/tr, option) closes the element before it as in a browser, so a list written without them no longer fails every source with RecursionError, and label places like <dt>Local<dd>Lisboa are found. (#587)
    • The duplicate check finds an advert however many listings the account holds; it read only the 200 newest at one path, so older listings on boards that put the job id in the address (Indeed's /viewjob?jk=) were never compared. Addresses are now stored indexed, by a migration. (#556)
    • Put it back: the discarded captures link in the message that follows a discard now opens a list of them, each with a button that restores it; the same page lists every capture waiting, and Listings says how many wait (it said twenty over a longer backlog) and links to it. (#380)
    • A capture that was saved or discarded is decided once: saving it again, from a second click or an old link, no longer makes a second listing, a saved one can no longer be discarded, and its review page shows what it became instead of the form. (#336)
    • A tag or an industry is its name, not an ASCII slug: C# and C++ are two tags, non-Latin names are kept, a renamed tag answers to its new name, and none of it is a 500. Upgrading re-derives the slugs and merges duplicate industries. (#356)
    • A contact's data-protection document, erasure report and retention dry run each account for every record that names them: the applications they are the contact or referrer for, the interviews they were at and the listing-history entries from them. (#370)
    • Saving an application's or an interview's edit page opened before a move no longer undoes it and logs a move nobody made: a status or time that comes back as the page drew it is left as it is now. (#545)
    • Saving an offer without changing its terms (a note, or nothing) no longer writes "Offer revised", sends a webhook, reopens a ticked-off answer-by reminder or undoes a postponement; the reminder follows only a moved date. (#442)
    • Interviews attended (Insights and the employment-office report) no longer counts a no-show, nor a held interview that was moved or settled again; a no-show is its own kind of timeline entry. (#448)
    • Editing an interview no longer drops a contact who has since moved company: they stay on the form, with their company beside the name, until you untick them. (#443)
    • API: a refused POST/PATCH of a company or PATCH of an interview (bad identifier, contact from another company) no longer keeps the changes it made before the refusal. (#435)
    • API: a time sent without an offset (starts_at, due_at, occurred_at, updated_since) is a 422 naming the field instead of a 500 or a reminder moved into the server's zone; /me now says the owner's time_zone. (#567)
    • A scheduled sync now runs in its owner's language and time zone, like Sync now, so the reminders, timeline entries, notes and report it writes name the owner's hour and read in their language. (#335)
    • With a background worker, slow work (reports, frozen letters, exports, captures) is done in its owner's language and time zone instead of the server's, so a report covers the period the page showed and a letter carries the owner's date. (#383)
    • A webhook now receives a reminder that was moved and fell due again, a listing whose closing date moved, and an application that went quiet again, instead of dropping them as repeats; a long quiet or closing list no longer overflows the key column on PostgreSQL. (#413)
    • Status, interview and offer notifications are sent after the change is committed, so a notifier's network call no longer holds the database write lock, and a change that is rolled back is no longer announced. (#578)
    • A webhook whose stored secret can no longer be decrypted (a rotated key, a restored backup) is given up on with the reason on its connection, instead of ending every scheduler pass that reaches it. (#573)
    • A document copy the scheduler is already sending is no longer sent a second time when Send now is pressed: the claim is kept apart from the retry time. (#509)
    • Stopping the background worker (a restart or upgrade) now ends it after the task it is running instead of taking more work until it is killed, and an errand still unfinished after a day is marked failed, so its page stops saying the work carries on. (#474)
    • A slow sort or page answer no longer lands over a newer filter's on Companies, Listings and Applications, and tick boxes are drawn only when there is a bulk bar for them. (#648)
    • Keeping a view, the Columns menu and the Table / Board switch now use the filtered table on screen, not the address the page loaded with. (#623)
    • A career hit in search now opens the career page at its section instead of at the top. (#707)
    • Search's "All N in …" links keep the whole query, open the same N rows they count (timeline text included), and are left out for groups whose list cannot take a query. (#506)
    • Renaming a company through the API refuses an empty name (422) and another company's name in any case (422) instead of saving it or answering 500; accented names in another case are one company. (#546)
    • Company and department names are unique per account in any case on every database, so Émile/ÉMILE and Οτε/ΟΤΕ are one. Upgrading stops, listing them, if two already clash: run manage.py company_name_clashes first and merge each pair. (#546)
    • Search and the text filters now ignore the case of accented and non-Latin letters on SQLite, so école finds École. (#505)
    • The Plugins pages no longer fetch every catalogue's index and signature when they are drawn: an upload is labelled from the checksums the last Check for updates, install or plugins command verified. (#602)
    • Every shipped plugin's description on the Plugins pages is shown in the reader's language: it was turned into English text when the plugin loaded, so its translations were never used. (#633)
    • Install on Server settings → Plugins installs the release of the catalogue whose row was pressed, not the first catalogue to list the name, and plugins install refuses a name several catalogues list unless --catalogue says which. (#601)
    • Server settings → People → Plugins draws a plugin the instance switched off or forced on as it applies to that person and says it follows the instance default; saving it for them stores an exception only where it differs. (#490)
    • A plugin install that runs past the web worker's timeout, or is killed, now puts the plugins directory back as it was instead of leaving an unrecorded plugin loaded; the install and import-check time limits were cut to fit under the timeout. (#604)
    • Upgrading or rolling back a plugin now says that a restart is needed, instead of claiming the new code is already in use everywhere. (#599)
    • Upgrading a plugin that was installed with copies of Postulo's own packages no longer lets it move them: they stay pinned to Postulo's versions, and refusals say "Postulo has". (#598)
    • Removing a plugin no longer deletes a package another plugin still needs, and an upgrade or reinstall keeps the plugin's dependency list, because each plugin now records its whole dependency tree. (#597)
    • plugins rollback no longer switches back on a plugin that was switched off after the install it undoes, and says when a plugin's disabled state changes. (#600)
    • The plugins record is written to a scratch file and moved into place under a lock, and a record that cannot be parsed keeps the plugins that were switched off switched off instead of loading them all again. (#382)
    • Importing a Europass file now spends your allowance of answers about numbers already recorded here, as the forms do: a number held by somebody else is reported in the import's skipped list, and once the allowance is spent a free number is not written either and the report says so. (#615)
    • Once the hourly allowance of answers about recorded numbers is spent, Your details, the contact form and the API refuse every new number, held or not (the API with 429), and contact pages charge the account asking, not a shared allowance. (#374)
    • The Europass import reads the XML Cedefop's editor wrote, with its *List wrappers and hyphenated months, which left the career out; a section it cannot read is named on the review page. (#632)
    • A Europass import places what it adds as an entry typed by hand lands: experience and education by their dates, languages, skills and projects last, with the section's numbers dense. Every imported row used to take order 0 and land under the first entry. (#618)
    • A Europass import no longer fails with a server error when the file's website is a link the account already keeps as a social profile or repository, or its address is one already listed for a contact: that part is left out, said in words, and the rest of the file is added. (#616)
    • A Europass file from Cedefop's editor, XML or JSON, is read as the newer one already was: the markup in an experience's activities, a course's, an achievement's and the skill blocks becomes lines of text, and a listed skill block becomes one skill per item. (#563)
    • Spreadsheet import: a listing keeps its deadline and its tags (in its description), an unreadable deadline is kept in the note, and month names are read in every offered language ("15. Oktober 2026", "5-Jan-26"). (#394)
    • Spreadsheet import: a salary written "45,000 - 55k" is 45,000 to 55,000 rather than 55,000 to 45 million, and an amount written with "R$" is read as Brazilian reais rather than US dollars. (#471)
    • Spreadsheet import: a sheet with no Status column, or a blank Status cell, no longer turns every undated row into an applied application; the date decides, so a list of jobs to look at imports as listings. (#468)
    • Spreadsheet import reads a status or channel by whole words, not substrings, and an outcome wins: "Rejected after interview" and "Offer declined" import as Rejected, "Nothing yet" is kept in a note, "Referral via LinkedIn" is a referral. (#467)
    • Your own email can be set up again: its connection form answered 500 because a secret field's help text was lazy. (#359)
    • A redirect from a connection's server to itself (a relative one, or an absolute one to the same host) is now followed by the server's name rather than the address it was pinned to, so credentials are kept and the next hop's certificate is checked against the name (#363)
    • The diary feed a calendar application can subscribe to (/api/v1/interviews/calendar.ics, with a read token) now carries the deadlines, closing dates and answer-by days, as the downloaded file already did (#451)
    • Reading a pay period from a page no longer takes a "daily", "monthly" or "hourly" earlier in the paragraph for the salary's own period, and a long run of non-ASCII spaces no longer makes the search take minutes (#420)
    • An instance holding the same identifier in two cases (Wikidata Q95 beside q95, or two Other values differing only in case) can now migrate past the identifier-case migration instead of stopping at an IntegrityError (#570)
    • Both compose files now name the :0.3 image, and release_tools.py check refuses a tag whose compose files name another minor, so an install no longer runs the previous release (#403)
    • Source builds have their own override (docker/compose.build.yml, and a PostgreSQL one) that tags the image postulo:local, so docker compose pull can no longer swap a source build for the registry's release; SECURITY.md and the README give one upgrade path per kind of install (#403)
    • The PostgreSQL compose file gains the worker service the SQLite one has, and .env.example names the right file for each, so starting the worker no longer swaps an instance onto an empty SQLite database (#581)
    • The PostgreSQL compose file no longer needs POSTGRES_PASSWORD in docker/.env: it is read from the root .env, and a password with /, ? or % in it reaches PostgreSQL exactly as typed (#582)
    • Saving a table view under a long name cut after a space no longer ends in a server error, and saving the same view twice in a row says "View saved." instead of claiming it needs a name. (#507)
    • Sending a CV or letter for an application with a very long job title and company name no longer fails on PostgreSQL: the "sent to" text and the document title are cut to their columns. (#513)
    • A document whose title holds a double quote or a line break now downloads correctly instead of sending a malformed header or answering 500. (#376)
    • The calendar and the report no longer answer 500 for a hand-edited address at the edge of the calendar (year 9999, 0001 or 20266-Q3); they fall back to today, and only years 1900 to 2200 are read. (#389)
    • Django admin: adding a person now asks for a username and full name, and the change form, list and search show the username, so a second person no longer ends in a 500; signup's username rules apply there too. (#427)
    • New application, new listing and capture review, and the listing API, now refuse a salary currency that is not three letters and store it in capitals, so the listing's edit form no longer rejects it later. (#446)
    • An identifier pasted with an unbalanced "[" in its address is now refused with the format message instead of crashing the page, API call or import with a server error. (#460)
    • A spreadsheet import with a cell over 128 KiB, usually from an unclosed quotation mark, now says the file could not be read instead of giving a server error. (#462)
    • A long spreadsheet file name no longer overflows the timeline's actor and summary columns, which made the import fail on PostgreSQL. (#463)
    • Spreadsheet import no longer mistakes "Applied through" or "Deadline date" for the applied date, so the channel, deadline and real "Date applied" columns are guessed correctly in any column order. (#465)
    • Editing a reminder that was put off with Later or Snooze no longer announces it a second time when only its wording changes; those times are now stored to the minute, as the edit form shows them. (#445)
    • Moving an application's status now decides applied_at and closed_at from the locked row, so two concurrent moves no longer leave an open application closed, re-stamp the applied date, or leave a withdrawn one without a close date. (#543)
    • Outbound connections (HTTP guards, mail test and sending) now try each approved address in turn, so a name with one dead address, such as a broken IPv6, no longer fails whenever that address comes first. (#547)
    • A webhook delivery whose receiver's hostname briefly fails to resolve is now retried with back-off instead of being given up on and blamed on the private-address setting. (#549)
    • Webhooks no longer follow a redirect, which re-sent the POST as a bodyless GET and could record the event as sent; a 3xx now fails the delivery and the Test button, naming the address to use instead. (#550)
    • Putting a reminder off until tomorrow or next week now keeps the hour it was set for, in your own time zone, instead of the minute you pressed the button, and no longer shifts an hour across a clock change. (#565)
    • A browser notice older than a week is no longer shown to the next tab you open; expired and already-shown notices are also cleared on the scheduler pass, not only when a new notice arrives. (#577)
    • A deactivated account no longer gets due reminders, webhook posts, syncs or document copies sent on its behalf; its queued rows wait and resume if the account is reactivated. (#575)
    • A scheduler pass that outlives its lease no longer deletes the next pass's lease, and a sync connection is claimed before it runs, so overlapping passes no longer sync it twice. (#576)
    • Two runs of one sync connection no longer overlap: Sync now during a scheduler pass, or from two tabs, reports that a sync is already running instead of duplicating contacts. (#586)
    • Rolling back documents 0005 now carries copies back instead of failing, and accounts 0021 now refuses to be undone up front, since invitation tokens cannot be recovered from their fingerprints. (#571)
    • The archive now keeps single-key shortcuts, nav underline, density, plugins switched off, capture-keeping switches and closing notice, and a restore brings back a switch set off or a cleared dashboard instead of the defaults. (#464)
    • Importing an archive into an account whose profile already has a primary number, address, link or identifier now keeps the profile's rows and reports what it skipped, instead of failing with a traceback. (#375)
    • An account import now reports every telephone number it leaves out because another record on the instance already holds it, naming the profile or contact and the last two digits, so the report no longer reads clean. (#562)
    • An account archive from a newer Postulo, or one whose format is not a known number, is now refused up front with a message naming both formats, instead of a traceback or a silent partial import. (#561)
    • The container restore advice now stops every service whatever its Compose profile, the background worker included, and starts the same profiles again afterwards, in restore --help and in the "something else is using the database" refusal. (#541)
    • Restore now refuses an archive written by a newer Postulo (naming the version to install) or a database with unknown migrations, unless the new --allow-newer flag is given. (#479)
    • The retention dry run on Server settings → Data protection now reports on the number typed in the retention field, falling back to the saved limit when it is invalid or empty, and still saves nothing. (#483)
    • Saving the Sign-in or Defaults page no longer overwrites a stored setting that an environment variable pins; all four policy forms now share one rule for dropping pinned fields. (#492)
    • Choosing "Not set here" on a Server settings policy select (registration, single sign-on as second factor, capture options) now stores "unset" instead of silently saving "No". (#493)
    • Server settings → Plugins: a posted action the page does not have, such as a stale or hand-built form, now gets the polite refusal instead of an Internal Server Error. (#484)
    • Uploading, cancelling or confirming a plugin package now removes only that administrator's own waiting package, so another administrator's package under review is no longer thrown away; abandoned ones are swept after a day. (#485)
    • Server settings: the four per-account pages (username, recovery link, delete account, plugins) now mark People as the current sidebar section (aria-current), as the other sections' sub-pages already did. (#486)
    • A mistyped mail provider or grant, or Google with the application grant, in the environment is now refused at start-up by a new check (postulo.E008) when POSTULO_EMAIL_AUTH is xoauth2; a blank provider is left to the Email page. (#487)
    • A plugin whose name another kind already holds is now left out and logged, so one policy switch, form id and logo can no longer govern two plugins (a source and a notifier sharing a name). (#593)
    • Fixed a broken logo image on Settings → Plugins for plugins a person (or an administrator) switched off: the logo is now served to anyone the plugin is offered to, on or off. (#594)
    • manage.py plugins disable and remove now refuse the package that carries the selected mail transport, as the plugins page always did; the lock moved into the install code both use. (#595)
    • Settings → Plugins now shows the Uploaded or Custom tag on an installed plugin whose package name differs from its own, as every official plugin's does; before, only built-ins were tagged. (#603)
    • The plugin surface check now records what a plugin imports by name: from postulo.plugins import api passes, and an allowance for postulo.core.site no longer lets postulo.core.models through. (#606)
    • A plugin switched off or removed now takes its document themes, template and translation directories with it at once, instead of staying in the pickers and breaking Export until a restart; an upgraded plugin shows its new logo. (#609)
    • Importing a Europass file with an address without a country, an over-long number, an impossible year or a malformed website now reads what it can or is refused with a sentence, instead of ending in a server error. (#634)
    • Importing an old Europass XML or JSON file now reads the country codes UK and EL as GB and GR, and no longer cuts a three-letter code such as PRT down to another country (#639)
    • Europass import no longer silently cuts content: an untitled entry keeps its whole description, a heading's skills are no longer capped at forty, and a JSON Skills block of the wrong shape is reported as unreadable (#640)
    • Europass import: skill group headings (Digital, Job-related, ...) are now written in the language the file states for the CV, not the interface language of whoever imports it (#641)
    • The address notes are now whole translated sentences, one per label ("Post to this country usually needs a postcode."), so "a address" and "A Eircode" are gone and no label or English country name is pasted into a sentence. (#642)
    • A company register number typed without a country code (HRB 12345, KVK 12345678) is no longer split into invented letters and saved; it is refused with the example, as is a prefix that is not a country code. (#636)
    • An ISNI is now checked against its ISO 7064 check character, so a mistyped one is refused like an ORCID, and its link is built without spaces; an already-stored ISNI with a wrong check character is refused when its form is next saved (#637)
    • Identifiers: accented/percent-encoded LinkedIn names, 2-3 letter ResearcherIDs and Web of Science links are accepted; a pasted Scopus link keeps only its author ID; a Wikidata link's fragment is ignored; non-ASCII digits are refused. (#638)
    • A malformed identifier now reads "ORCID: that is not the usual form (for example …)" instead of the ungrammatical "a ORCID identifier"; the person and company identifier checks now live once in core. (#645)
    • Paginated API lists (applications, companies, CVs, letters, interviews, reminders) break ties by id, so paging can no longer repeat one row and skip another. (#440)
    • GET /api/v1/captures takes after_id, so a client can catch up past a run of captures sharing one updated_at. (#439)
    • The API refuses blank names for companies, listings, applications, contacts, reminders and letters, and renaming a company onto another's name is a 422, not a 500 or a duplicate. (#438)
    • Each letter's language is pinned in the API by a test, and the schemas no longer default it to an empty string. (#437)
    • PATCH /api/v1/interviews/{id} no longer answers 500 when starts_at, location or notes is null; null means unchanged, as on the other patch calls. (#436)
    • A capture's Idempotency-Key longer than 200 characters is refused with a 422 instead of a 500 on PostgreSQL. (#433)
    • The OpenAPI description lists the refusals a call raises itself: 413 on every call with a body, 409 on POST /captures, 404 on POST /interviews and POST /reminders. (#431)
    • An unknown API address, a non-numeric id and a method an address does not take are answered as problem documents (401 without a token, 404 with one, 405 with Allow), not HTML. (#430)
    • A non-numeric ?application= or ?company= on the new reminder, contact and posting pages is ignored, and accepting a suggestion with a non-numeric application answers 404, instead of a server error. (#409)
    • /logs and /metrics answer 401, not 500, when the bearer token holds a non-ASCII character. (#372)
    • Choosing a NACE division with a comma in its name from the Other industries suggestions adds that division, with its code, instead of splitting it into made-up industries. (#531)
    • Your career: a skill saved without a group is no longer lost from every page. The form now requires a group, Add a skill under a group opens it with that group chosen, and skills already without one are listed in their own block. (#617)
    • Deleting a job or any other entry from Your career now takes its place on every CV with it, instead of leaving a row that made the CV, its entry pages and the API answer 500; rows already left behind are removed on upgrade. (#381)
    • A CV with include contact details off no longer carries the holder's name in its PDF title, its file name, the draft's name or the DOCX title: it is titled by its kind alone. (#512)
    • Resizing a column, applying Columns or pressing Reset no longer deletes your saved views, and resizing under an applied view no longer overwrites your own columns. (#503)
    • Answering Stay to the unsaved-work question no longer leaves the button you pressed dead: Discard this capture or Sign out works again on your next key or click. (#518)
    • A discarded listing stays under Discarded after its closing date passes, instead of moving to Closed still labelled Discarded; the tab counts, the API filter and the insights figure agree with the State column. (#529)
    • A company form with a rejected identifier no longer says Company added. or Company updated., and no longer fetches, replaces or removes the logo of a company it did not save (a logo address on a new company was a 500). (#530)
    • Moving a contact who is in a department to another company, or to none, saves instead of answering 500; the contact leaves the old team, and a team typed in the box joins the new company. (#527)
    • In the Docker image the ESCO classification lives on the data volume (POSTULO_ESCO_DIR, /app/data/esco): fetch_esco used to fail with a permission error writing into the image, and a file forced in with -u root was lost at the next upgrade and never seen by the worker. (#411)
    • Removing a tag or industry chip with the keyboard keeps focus in the control instead of dropping it to the top of the page. (#519)
    • Server settings → Plugins lists the own-mail outbox, so an administrator can switch off people sending mail through servers they type, as the code and the refusal always said. (#608)
    • Check it answers on a link whose host refuses HEAD no longer downloads the whole file behind it into memory to read the status: the second request is streamed and closed unread. (#619)
    • In a right-to-left language the plugin switches draw their knob inside the track, at the end that says on or off, instead of hanging outside it. (#517)
    • The report filed with Download declares its language and reads from the right edge in Arabic and Hebrew: the filed PDF used to say lang="" and dir="ltr", unlike the draft. It is now drawn in the language of the person who pressed the button. (#568)
    • A group of tick boxes or radio buttons drawn through the form field component, such as the two lists on Freeze and attach, now has a name and its help text for a screen reader: it is a fieldset with a legend, not a label attached to nothing. (#624)
    • The counts on the company and application delete pages (3 postings, 2 interviews, ...) are now translated: they were passed to ngettext through variables, so no catalogue held them. A test now fails on any translation call whose text is not a literal. (#388)
    • A saved view can be named in any script (Greek, Cyrillic, Arabic, Ge'ez and the rest): it was refused as nameless, and two names sharing a number replaced each other. A name with no letter or digit now says so. (#504)
    • The Tags list on an application's forms has a name with scripts off: its heading is now a real label for the list. (#625)
    • A mistake in the plugin repository form on Server settings → Plugins now shows the form with its error, instead of a blank 405 Method Not Allowed page. (#491)
    • Importing an archive keeps the links on a CV: they were dropped as "no such record" and the CV came back without its portfolio and profile lines. (#470)
    • A contact's telephone rows no longer offer Gets me back in when a text gateway is installed: the choice belongs to your own numbers on Your details. (#650)
    • Saving Your details or a contact untouched no longer stores an empty telephone row where the country chooser starts on a country. An empty row saved before this is on the page, and can be removed there. (#649)
    • The telephone box tells a screen reader that its number was refused and why, and is described by its help: it carries the aria-invalid and aria-describedby every other field has. (#416)
    • On Your details, the box for the state of a postal address reads Bundesstaat in German and Πολιτεία in Greek, not the word for a status, and so in 28 more languages: the address rules' own translation of State is the one drawn. (#651)
    • On Your details, changing one postal address into another that is removed or changed in the same save no longer answers with a server error, and an address one of your contacts already holds is refused on its row, with the reason, instead of by the database. (#458)
    • With Address rules by country switched off for you, or for everybody, the address rows on Your details keep their neutral names and say nothing about a country; the switch had no effect on that page. (#635)
    • An API refusal's detail is a translated sentence every time, in the language of the account that owns the token and not the client's, and carries request_id, the id in X-Request-ID and in the log. What a call writes, such as an interview's reminder, is worded and timed for its owner too. (#393)
    • Erasing a contact no longer ends in an error page when a plugin's eraser fails. It is refused, with nothing removed, while a plugin holds rows about the person that it cannot remove; and a plugin that fails during an export, a merge or an erasure is logged. (#371)
    • /logs?since= answers with the oldest records after that time, so a collector that asks again carries on from where it stopped; it used to hand over the newest limit and skip the rest for good. since is compared as a time whatever its offset, and one that is not a time is a 400. (#475)
    • The kept log keeps what POSTULO_LOG_BACKUPS promises. Every process rotated postulo.log on its own, so one rotation became several and most of the history was lost; they now rotate one at a time, and rotations are read in order past the ninth. (#379)
    • A table whose filters match nothing keeps its header, so the sort and the filters are still there to change and are not forgotten by the next search; and narrowing Companies while it is kept to one group (?group=) no longer drops the group. (#647)
    • A table opened with a filter already in its address (a saved view, a bookmark, Back) can have that filter changed or cleared from its column header again; it stayed stuck until Clear. On a phone, Narrow now has its own button and applies when pressed, not as you type. (#622)
    • Emptying a column's filter in a table header, or choosing Any there, no longer folds the filter shut around the box you are in: it stays open with the focus in it, so the next word typed narrows the table again. (#626)
    • A telephone number or a link removed from one row and added to another is saved in one go: the new row was refused, the number in the words meant for somebody else's and at the cost of a rationed answer. Two rows exchanging their values are each told it is already listed: two links answered with a server error. (#461)
    • The same address listed under two kinds of link, a GitHub profile as a code repository and as a website, is saved as two links on Your details and on a contact: the page answered with a server error and lost everything typed on it. An address is still listed once under each kind. (#457)
    • The Chromium PDF renderer counts as usable only when its browser is installed, not the playwright package alone, and one that cannot start says how to install it instead of answering a draft with an error page. (#514)
    • A webhook connection says how its last delivery went: a receiver answering 404 no longer reads as working once another event is queued, and an address refused at delivery is said on the connection. (#574)
    • The container's own health check is answered whatever POSTULO_ALLOWED_HOSTS holds. With only the public host named it was refused with a 400, so a working container was reported unhealthy and the scheduler and worker never started. (#580)
    • Settings → Account → Passkeys → Manage opens: it answered with a server error for everybody who had a passkey, so one could not be renamed or removed. (#424)
    • A Norwegian reader is given the occupation and skill names ESCO publishes in Norwegian, where the English ones were shown; and a language menu keeps a language it does not list, where saving a CV or a letter wrote blank over it. (#337)
    • Your career → Other languages saves a translation under a language and nothing else: a mistyped address used to store it under whatever was typed, and answered with a server error on PostgreSQL. (#620)
    • On Your details, a telephone number or a postal address typed into the empty row is saved: the page answered with a server error instead, and lost everything else typed on it. (#454)
    • Server settings → Defaults shows every language ticked while nothing has been narrowed, as the page says, and saving it that way still stores nothing; a partly translated language keeps its region in the list, "français (France)". (#322)
    • A refused language list on Server settings → Defaults no longer blanks the name, language and time zone above it, so saving that card afterwards cannot wipe the stored defaults. (#494)
    • Going Back to Companies, Applications or Listings after narrowing or sorting the table could show the table alone, with no title, masthead or styles. Those pages now say that an htmx request gets a different answer, so the browser's cache no longer hands back the fragment. (#646)
    • A capture is refused with its reason when the site redirects to something that is not an address, or its name stops resolving between two lookups; the API answered 500 and the page said only that something went wrong. A page naming a character set that is not one is read as UTF-8. (#607)
    • A row's menu on Server settings → People is no longer cut off inside the table: every menu opens over the page, beside its trigger, and upwards where there is no room below. Each person there has their picture too. (#310)
    • The "Name, if Other" box appears only when the kind is Other, and a name given another
      kind is blanked on save. (#284)
    • Every third-party work in the tree carries its notice, htmx is pinned and synced like the
      rest, THIRD-PARTY.md is the register, and Postulo states its copyright. (#279)
    • Tables carry a hidden caption, and discarding a capture links to where it went. (#260)
    • Under Windows High Contrast every control keeps a border and the funnel bars their value;
      a page printed from the dark theme comes out as ink on white. (#277)
    • Row actions name their row, tooltips became text, the invitation link is whole, new-tab
      links say so, fields about you carry their autocomplete purpose. (#276)
    • Every sideways-scrolling table is a named stop the arrow keys scroll, the failure alert
      can be dismissed, and the column-resize handle is visible before hover. (#275)
    • A field's border and focus ring clear 3:1 in both themes, the current section is
      underlined, and nothing is said by colour alone. (#274)
    • Five lists and grids that kept the reading measure now take the screen. (#273)
    • On Companies, a company's name opens the company, and renaming it where it sits is the
      pencil beside the name. (#252)
    • Nine catalogues wrote one in the plural that also counts twenty-one; the forms are
      real now. (#250)
    • The image workflow asks the registry for the tags it pushed and attaches the bill of
      materials to the release. (#251)
    • Export my data and Import carry the reminders that are about no application, which were left out of the archive (format 30). Older archives still import. (#334)
    • The archive carries the files sent with an application and a sent document's sent to line, so a restored account keeps what went with each application and a sent PDF keeps its place (format 31). (#469)
    • manage.py restore onto a database that was never migrated, the state POSTULO_SKIP_MIGRATE=1 leaves, restores instead of ending in a traceback. (#476)
    • manage.py restore counts the SMTP password, the mail consent and the transports' secrets under the field key, not only connections, and no longer says nothing is lost when they cannot be read. (#481)
    • Expired export archives, and captured pages past their days, are deleted by the export page too, at most once an hour, so an instance without the scheduler no longer keeps every archive for good. (#539)
    Downloads
  • v0.3.0 926881c357

    Postulo 0.3.0
    All checks were successful
    Dev image / image (push) Successful in 11m23s
    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 / test (3.14) (push) Successful in 6m17s
    CI / browser (push) Successful in 11m56s
    Stable

    tiagoagueda released this 2026-09-16 17:22:26 +00:00 | 1179 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 | 1369 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