-
Postulo 0.5.0
StableAll checks were successfulCI / Unit tests and coverage (Python 3.14) (push) Successful in 13m50sCI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 1m52sCI / Browser tests (Chromium) (push) Successful in 34m5sCI / Database tests on PostgreSQL (push) Successful in 4m49sCI / Security audit (push) Successful in 2m39sEvery Python / Unit tests (Python 3.12) (push) Successful in 15m40sEvery Python / Unit tests (Python 3.13) (push) Successful in 15m0sRelease / Publish the release (push) Successful in 1m38sDev image / Dev image: build, scan, push (push) Successful in 1h30m58sreleased 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 checkcounts, 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
fuzzyand still compiled, references name a file, the tool sets no header date, andcompilewriteslocale/status.jsoninstead 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 forci.ymlalone, 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_capturesfinds 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 statsfinds 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 stepruns 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 withcalendar_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
discoverlists 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 copyingsame_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
SyncLinknames its twin's collection and a slot, so one record can have several twins; one remote item pairs with one record (bindraisesAlreadyPaired),bindclearsremote_gone,unbindkeeps the row marked unpaired, andfor_remotefinds 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_addressesandweb_linkson 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,untilandBodyRefusedare onpostulo.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 guardrefuses, in CI and before a commit, any translation written outside Weblate,gateholds Weblate's pull requests to complete French and Portuguese, andscripts/weblate.pylocks, 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 savepointand 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_countryandlinkedin_urlare deprecated in favour of itsphone_numbersandweb_linkslists; they are still taken and returned, and will be removed in a later release. (#934)
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Postulo 0.4.0
StableAll checks were successfulCI / Unit tests (Python 3.13) (push) Successful in 14m46sCI / Unit tests (Python 3.12) (push) Successful in 16m10sDev image / Dev image: build, scan, push (push) Successful in 17m51sCI / Database tests on PostgreSQL (push) Successful in 3m57sCI / Security audit (push) Successful in 1m23sCI / Stylesheet, icons and scripts current (push) Successful in 24sCI / Unit tests and coverage (Python 3.14) (push) Successful in 10m57sCI / Browser tests (Chromium) (push) Successful in 32m36sRelease / Publish the release (push) Successful in 23sreleased 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_mediano 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
1e1000000is 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_HOSTexemption 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 answers413as a problem document instead of an HTML400. (#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: setPOSTULO_TRUSTED_PROXIESto 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 byPOSTULO_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::/10and the rest of the reserved::/8. On a host with NAT64,64:ff9b::a9fe:a9fereached the cloud metadata service. (#321) - A page, its
robots.txtand 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_datatakes 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
sizesin 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 newphone_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-BRanden-GB, notpt-br, and Serbian issr-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 --versionbefore 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,chipandalert-*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 rollbackundoes the last install. Removing one takes the dependencies nothing else uses. (#246) - API refusals are RFC 9457 problem documents (
application/problem+json, withtype,titleandstatusbesidedetail). A validation failure's field errors moved fromdetailtoerrors; a client that iterateddetailreadserrorsnow. (#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.mdno 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
withchain.
(#263) - The ESCO classification is no longer committed:
manage.py fetch_escodownloads the
revision it names intosrc/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_URLpoints 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 getrecord_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
.txtor 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_fontsand 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-IDis honoured and echoed, andPOSTULO_LOG_FORMAT=jsonis 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 (
PATCHandDELETE). 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.testingpublishes 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=truelets 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.importersis an open group,Recordis 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)withoutasked_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
babeldependency, 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
psqlin 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_esconow deletes the ESCO files of the revision it replaces once the new ones are written, andmanage.py checkreports 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_demorun no longer marks the account's address verified and primary, and vouching for an address (also increatesuperuser) 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, Victoriais 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 logicielsis 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
urlis 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, so1000no longer picks the posting of/jobs/10001. (#590) - The schema.org source reads a microdata or RDFa posting inside a page-level item (a
WebPageon the body, or the posting as itsmainEntity): 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 withRecursionError, and label places like<dt>Local<dd>Lisboaare 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#andC++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/PATCHof a company orPATCHof 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;/menow says the owner'stime_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_clashesfirst and merge each pair. (#546) - Search and the text filters now ignore the case of accented and non-Latin letters on SQLite, so
écolefinds É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
pluginscommand 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 installrefuses a name several catalogues list unless--cataloguesays 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 rollbackno 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
*Listwrappers 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
Q95besideq95, 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.3image, andrelease_tools.py checkrefuses 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 imagepostulo:local, sodocker compose pullcan no longer swap a source build for the registry's release;SECURITY.mdand the README give one upgrade path per kind of install (#403) - The PostgreSQL compose file gains the
workerservice the SQLite one has, and.env.examplenames 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_PASSWORDindocker/.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 --helpand 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 disableandremovenow 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 apipasses, and an allowance forpostulo.core.siteno longer letspostulo.core.modelsthrough. (#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/capturestakesafter_id, so a client can catch up past a run of captures sharing oneupdated_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 whenstarts_at,locationornotesisnull;nullmeans unchanged, as on the other patch calls. (#436)- A capture's
Idempotency-Keylonger 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 onPOST /interviewsandPOST /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) /logsand/metricsanswer 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_escoused to fail with a permission error writing into the image, and a file forced in with-u rootwas 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
HEADno 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=""anddir="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
ngettextthrough 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-invalidandaria-describedbyevery 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
detailis a translated sentence every time, in the language of the account that owns the token and not the client's, and carriesrequest_id, the id inX-Request-IDand 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 newestlimitand skip the rest for good.sinceis compared as a time whatever its offset, and one that is not a time is a400. (#475)- The kept log keeps what
POSTULO_LOG_BACKUPSpromises. Every process rotatedpostulo.logon 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
playwrightpackage 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
404no 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_HOSTSholds. With only the public host named it was refused with a400, 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
500and 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.mdis 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 restoreonto a database that was never migrated, the statePOSTULO_SKIP_MIGRATE=1leaves, restores instead of ending in a traceback. (#476)manage.py restorecounts 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Deleting an account or a company removes its export archives and logos from the disk, and
-
Postulo 0.3.0
StableAll checks were successfulDev image / image (push) Successful in 11m23sRelease / release (push) Successful in 19sCI / test (3.13) (push) Successful in 5m1sCI / test (3.12) (push) Successful in 5m3sCI / postgres (push) Successful in 3m50sCI / security (push) Successful in 1m42sCI / styles (push) Successful in 23sCI / test (3.14) (push) Successful in 6m17sCI / browser (push) Successful in 11m56sreleased 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_RATEis 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
htmlfetches 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 a429carrying indetailthe same sentence the form shows, and
now aRetry-Afterheader 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 anATTENDEEline, 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 ajavascript: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,
withpostulo.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 tohttp://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_allowalso 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
andDestinationRefusedare 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 twowrite_pdfarguments —
stylesheetsandxmp_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
nowweasyprint>=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-subset0beside 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, andsmtplibalready 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 thatsmtplibbase64-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,
whatuv syncactually 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 samescripts/scan-image.sha 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 singleAddField, 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 insave()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_VERSIONis 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_RATEsets 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 setPOSTULO_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_HOSTis 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 islocalhost. 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.mdgains 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-0had 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 upgradenow runs at build time, in both apt layers — the second one matters,
because an image built withPOSTULO_EXTRA_PACKAGESrefreshes 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: beforeupdateit runs against the stale index that caused this,
afterinstallthe packages just installed came from the old one, and in aRUNof 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.mdrecords 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=changemestarted an instance perfectly well. The production
settings refused to start with no key and said nothing about a bad one. Django notices —
security.W009is exactly this check — but the container runscheck --deploy --fail-level ERRORand 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 beginningdjango-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_KEYis 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: setPOSTULO_FIELD_KEYto the current key first, then change the
signing key underneath it.POSTULO_ALLOW_WEAK_SECRET_KEY=truestarts 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
typedchangemehas 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,/logsand/metricscompare
their token withhmac.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 carryingRetry-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_URLused 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, setPOSTULO_ADMIN_URLto 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.adminhas 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 inACCOUNT_RATE_LIMITSrather than a second scheme
that can drift from the first. AGETstill 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
cryptographyPostulo 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 aspostulo-messageswherever Postulo is a
dependency, and pointed at whichever repository it is run from:extract,extract --check,checkandcompileagainst a plugin's ownsrc/<package>/locale/, creating
the same sixty-eight slots core has.scripts/messages.pyis 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 carriedrequires_postulo, and the installer parsed it, stored it, and compared
it to nothing: a plugin declaring>=0.5installed 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 themdbreakpoint, 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
keepslinkedin_urlwhere it was, as the primary social profile, withweb_linksbeside
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 asksPOST /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, thewww.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/capturestakes an optionalbatch— 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
mainpublishes an image, so a change can be run before it
is released.dev-image.ymlbuilds, scans and pushes:devalongside a pinnable
:<version>-dev.<short sha>— quote the pinned one in a bug report, because:devmoves
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.:latestis never touched, which is the whole reason this is a separate workflow
rather than a second trigger onimage.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 samescripts/scan-image.sha person and a release both call. Built for the
native architecture only: the release doeslinux/arm64through 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.ymlis
workflow_dispatchonly becauseruns-on: dockerreaches 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 thedockerlabel 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 anifon 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_URLis 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.ymlhas carried the same line
since it was written and has never run, so it had never shown it. Both now read a
REGISTRY_HOSTrepository 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.shand
scripts/check-image.shwere recorded as100644, so./scripts/scan-image.sh— which
is howCONTRIBUTING.mdtells 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.shpinned
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.shgave
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 everycatafter 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 wasmsgpack1.1.2 — pip's vendored copy, arriving with
python:3.14-slim-bookworm. Not inuv.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, becauseplugins/installing.pyprefersuv,
which the image already carries deliberately. The pip branch ofinstaller()stays — it
is the right answer for an ordinarypip install postuloon 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_REPOSITORYis
Postulo/postuloon 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.pynow 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.pydoes it, and does it by hand with
--dry-run.CONTRIBUTING.md§ Giving a runner thedockerlabel said to declaredocker:hostand
was wrong for a containerised runner:hostruns the job inside the runner container,
which is Alpine with no node and no docker CLI, soactions/checkoutfails 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, becausecontainer.docker_hostis 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 aJobPostingfor 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 publishitempropmarkup 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 onhiringOrganization.addressrather thanjobLocationwas
lost outright,estimatedSalarywas 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 a0–0placeholder 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,htmlutilassembles 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 /capturesread 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/previewanswers 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 /capturestakes the fields the person changed asdata: 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
capturestoken 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
libaddressinputis 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.locationkeeps 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.mdnow 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 thingphone-numbersdoes: 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
Fromwas 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 repositorypostuloand 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
logofield
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,collectstaticran 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 isimg-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 indocs/PLUGINS.mdrather 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.mdhas 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:extractwrites each string to
the set that owns the file it came from,check,statsandcompilewalk 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
withfor_user()or one person sees another's; a redirect that skipssafe_nextbounces
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
outsidesrc/postulohas 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
lazyfrom postulo.core import siteinside 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.Consentsits besideFieldSpecas 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.mdgains 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.mdhas 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 intests/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 thanupdate(), 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 tomigrate; 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 undernot_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_VERSIONis 8.The refusal lives on
remove()and not only on the page, so a management command or a shell
meets the same rule.CONTRIBUTING.mdhas the rule for plugin authors, including why
export_foris 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 intests/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 fromsso_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_VERSIONis 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 aRefererheader 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.pyrefused 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 reasoningIndustry.namedalready 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
parentonCompanyfixes 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.groupanddescendants()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.phoneandContact.phonewere oneCharFieldeach: 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
CVItemgives — the holder is heterogeneous
and two nullable foreign keys would need a migration every time a third kind of holder
appears — with aGenericRelationon 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_numberslist where aphonestring 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_COUNTRIESused 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_TWOlooks 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 notn != 1is 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=2says
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 carriedlangon 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 isES-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-GAandGB-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=4to 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 thatsettings.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
dirattribute
has been emitted since the first release and has beenltron every page ever rendered,
because every language Postulo speaks is read left to right — so what happened under
rtlwas 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 underltr. 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.SchemaOrgSourcewas two
lines,nameandversion = "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
Manifestrather than one
optional attribute each, with@declaresattaching 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_checkableprotocols check data members, and the registry drops anything failing
isinstance, so addinglabeltoSourcePluginwould 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.organdpage-metadata
are written into thesourcefield 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, underpostulo.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 asif 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.envwritten 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.
MAILERSis 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,
becauseDEFAULT_FROM_EMAILis 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 on10.0.0.0/8is 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 frompt-ptand
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 thedraftflag, 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, becauseratois
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 isn > 1and not the Europeann != 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
fromPOSTULO_PLUGIN_CATALOGUESasname|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 arrangementaria-describedbyand
aria-invalidwere 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.htmlalready
carriesrole="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 besidesortandfilterin 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 injobs/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.createincluded.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 (registeris 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 declaringfr-frprinted exactly the
same English job titles as one declaringen-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.rolegets 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=boardasks 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, inFUNDING.mdand 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, andassets/support/NOTICE.txtrecords 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.mdcovers 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.htmlhas 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-7xlon<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_completeheld 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
releasemarker — a promise that must hold in a published version, not in
every commit — deselected by default exactly ase2ealready is, and
uv run pytest -m releaseis 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'sdocs/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 namepostulo.plugins.api
promises.docs/PLUGINS.mdstays as a pointer, because every plugin's metadata links to
it.tests/test_wiki_surface.pyreads 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 ranscripts/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 thepostulo.wikirepository, the script and the copy
are gone, andCONTRIBUTING.mdsays 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, andapp.jsstates 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 isprovider: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
toplaininstead 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.Themestops beingTextChoices, 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 atemplates/directory beside its package, exactly as it already
shipslocale/; 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.ThemeandThemeKindjoin the plugin surface, which settles one of the two questions
postulo.plugins.apihad 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:
RenderedDocumenthad acvand acover_letter,DocumentCopyhad arenderedand an
upload, andarchiving.pyaskedisinstancefor 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.CVItemdecided 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_deletebehaviours 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 theSET_NULL
the column used to carry. Deleting that PDF must delete the rows saying where its copies
went, so the cascade lives on aGenericRelationat 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 takeslangon 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-BRwas seeded frompt-PTand 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 laterRUN rmfrees nothing, because the
bytes are below. That is how 296 MB of uv's download cache shipped, and it is why 20 MB of
.posource 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 — andcollectstaticruns as plainpython, so the lock and the
manifest no longer have to be present in the shipping image purely to satisfyuv 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
collectstaticruns 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 innotifications, the store indocuments, the importer inresume, and the
telephone-numbers feature written entirely insidecorea week after the plugin
documentation said not to. Each is nowpostulo/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
outsidepostulo.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 toresume.importing— and the
store contract a plugin author writes against joinedpostulo.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-shadowrather thanoutline, because:focus-visibleowns 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 newimporterkind, 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 andapply()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 aDOCTYPEin
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.mdsays 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 pluginpostulo-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.mdsays what the funding link means..github/FUNDING.ymlheld 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." withn === 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.jsis 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_formathands an unrecognised name back to the page, so an invented format that
one locale has not defined prints the literal wordSOME_FORMATto whoever reads in it,
and there is a test overlanguages.LANGUAGESthat 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'sen_GBsays "2.30 p.m." and no template
Postulo has shipped ever did. Atest_template_lint.pyrule fails on a format written out
in a template or a view, so these do not come back one page at a time; a bareYand the
Y-m-dan<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_GBrather than
Postulo's: a calendar heading and the three detail-page dates that spelled the month out
now abbreviate it, the compact16 Sepin 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 onerole="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 carriesaria-busywhile 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 nowHX-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 insessionStorage. 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 insessionStorage, 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 onpageshow— but a form whose answer is a file
never leaves the page, sopageshownever 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 theirrobots.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_ARGSis 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 declaringpostulo.transports,postulo.outboxes,
postulo.featuresorpostulo.importerswas 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 syncreinstalls 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 byexcept Exception— which is no
guard at all againstSystemExit, 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_APPSis fixed
when the process starts, nothing mounts a plugin's URLs, andmigratehas 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 inINSTALLED_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 bystrftimeand 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 theAccept-Languageof 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 forpdf/ua-1, Chromium fortaggedandoutline— 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=abcused 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 anid.
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-noneon every input,
select, textarea and table filter compiled tooutline-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
h1on about twenty
pages and each page's own name was anh2level 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-dangerand.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'sReferer, 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_sincecursor — ask what changed since a moment, take theupdated_atof 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/documentslist takes noafter_idand 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-60kcame in as fifty against sixty thousand, because the
kwas read for one side only; the range separator matched a bareaanywhere, so
"Salary" split in the middle of a word; and the period was never read, so15 €/hwas
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 andpostulo-mcpare 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 /captureswas not paginated at all, though The capture API has said since it was
written that lists takelimitandoffsetand 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 readitemsinstead. 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 the201cannot 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-Keyof 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/knownstays what it always was, advice asked for beforehand, which does
nothing about the answer that went missing afterwards./api/v1/openapi.jsonanswered 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_sinceand answers with what changed at or after that moment, oldest change
first, and every row carries theupdated_atto 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-pluginsleaves 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, andrestorecompares. An instance rebuilt with a
new secret key and noPOSTULO_FIELD_KEYis 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 wasexecinto the running web container, with gunicorn and the
scheduler reading through it. The wiki now gives the route that works — stop the services,
thendocker compose run --rm -e POSTULO_SKIP_MIGRATE=1— andrestorerefuses 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-waland-shmbeside 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--loopat 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,0for 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 theschedulercontainer'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./metricsreports the same
heartbeat aspostulo_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, andpostulo_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 toQ95, 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 storedq95
was then invisible to everything that looked forQ95: 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, andother, which is where a staff number lives, folds nothing,
becauseAB-12should readAB-12and notab-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'ssincefilter, 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'sapplied_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, andmanage.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.ymlhas 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 firstmigrate, becausepsycopglives in an extra the build
never asked for; had it started,manage.py backupwould have stopped on “pg_dump is not
on the PATH”, becausecore/backup.pydumps 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 forsubprocess.run— so the engine was documented, offered,
and entirely unexecuted. CI now runs the database-facing tests against a real
postgres:17and 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 inmigrate'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 everyaria-describedbyon 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|lowerwas 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.shhas 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 adocker pullfor 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.docxor a.txtarrived as a file no
PDF viewer would open — served asapplication/pdftoo, 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.pyinsisted 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:indexstopped 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-0group 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 isflex: 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 asCurrently: 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-describedbyat 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) - A CV's own page ran off a 320-pixel screen in Greek and German. Its entries are the row
-
Dragging worked in Chromium and did nothing in Firefox, and the tests agreed with
Chromium. Neither drag cancelleddragenter. The specification makes an element a drop
target only once bothdragenteranddragoverare 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 senddragenteras 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..chipwas shaped around a button it did not have: the two pixels are
where.chip-removegoes, 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 withpe-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'spe-3is gone.The test that guarded this pinned
ps-3andpe-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, becausemt-5and
lg:grid-cols-2compiled 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 oneuv syncabove the line that copies the source — anduv syncinstalls 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 bareoverflow-x-autorather than the positionedscroll-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 tookrelated_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 reasonRenderedDocumentexists. (#130) -
A test could be handed a translated string because of which test ran before it.
LocaleMiddlewareactivates 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 isstyle-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.pyreads the source rather than importing it, so it knows a translation
call by the name at the call site — and three plugins importgettext_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 a429somewhere 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.cssis 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, andgit add -pwas 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 — noSMTP_SSL, nouse_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 wordtimed 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_tlsanduse_sslare 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_TLSgoes on meaning what it means for every.envthat sets it, with
the newPOSTULO_EMAIL_SECURITYwinning 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 calleddevand nothing else, andpyproject.tomldeclares two
—devande2e— with both named indefault-groupsso thatuv syncnever 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 thedevgroup was
present, all four ofe2ewere.--no-default-groupsasks 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.
uvunpacks every wheel into
~/.cache/uvand 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-cachewas
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, anddocker/Dockerfilepassedcollectstatica literal
twenty-eight characters long. Everydocker buildfailed 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 forsecurity.W009years before this rule existed. What CI does
not do is build the image: that needs a runner advertising thedockerlabel and none is
registered (#81), soimage.ymlhas 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 witharia-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 andaria-describedbynames 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-describedbyoff 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 carryfield.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-describedbyon 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 inwaitingfor ever, because the second job in that file asked for a
runner advertising thedockerlabel and no runner advertises it. Forgejo schedules a job
before it evaluates theifthat would skip it, so the job was neither skipped nor
failed: it queued, and the run never finished. The comment at the top ofci.ymlhad
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. Givingruns-onan 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 inimage.ymland 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
importedconfig/settings/prod.pyat 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 withoutPOSTULO_SECRET_KEY, and the only thing supplying one was the.envin
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.envbelongs to whoever is
developing here — by reading production in a subprocess with the environment stripped;
there is now one such reader intests/security/conftest.pyand both files use it.
Reproduced by moving.envaside, 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:.alertis 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'ssrcis 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
getBoundingClientRectdoes 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.jsonaddress, which has no word boundary in it for a browser to break; and the
server overview, 223 pixels over, wheretruncate— which setswhite-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 isposition: 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-xis 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-1around asize-3.5icon 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 atap-targetclass 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' oldurl=, and anything using[project.urls]emitsProject-URLinstead.
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.Authorwas tried beforeAuthor-email, which meant
preferring a bare name over theFirst Last <address>the other field carries. An instance
that already has plugins fills in the blanks from the.dist-infostill on its volume
rather than showing them empty for ever. A plugin may now also declare alabeland a
descriptionof its own — optional, and read through helpers rather than added to the
protocols, becauseruntime_checkablechecks 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_REDIRECTon — the
production default —SecurityMiddlewareanswered/healthzwith a 301 to
https://127.0.0.1:8000/healthz, before any view ran and before anything touched the
database. The probe iscurl -fsS, andcurl -ffails 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 plusrestart: unless-stopped
would have produced. It survived a release for the obvious reason: a check that always
passes looks exactly like a healthy service./healthzand/metricsare exempt from
the redirect now, both anchored at each end —SecurityMiddlewarematches 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./logsis 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 newtable-cardscomponent
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 showedPTwhere 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 saysimg-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.FLAGSheld the emoji andphones.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.
qrcodedraws the modules as one path filled#000000and 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 theqrtag 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-hiddenby 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
Postulo 0.2.1
StableSome checks failedCI / test (3.12) (push) Successful in 1m29sCI / test (3.13) (push) Successful in 1m34sCI / security (push) Successful in 34sCI / styles (push) Successful in 10sCI / test (3.14) (push) Successful in 1m30sCI / browser (push) Successful in 2m41sRelease / image (push) Failing after 0sRelease / release (push) Successful in 10sreleased 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 saysruns-on: dockerand
is guarded byif: 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 ofci.ymlhad 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 totimesince,
which wants a moment and reads.yearoff 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 returnsmade_at
andagenow, 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 backuponce. 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
-
Source code (ZIP)
2 downloads
-
Source code (TAR.GZ)
2 downloads
-