-
Postulo 0.3.0
StableAll checks were successfulDev image / image (push) Successful in 11m23sCI / test (3.14) (push) Successful in 6m17sRelease / 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 / browser (push) Successful in 11m56sreleased this
2026-09-16 17:22:26 +00:00 | 105 commits to main since this release🔒 Security
-
The capture limit now bounds captures, not the form.
POSTULO_CAPTURE_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
-