Every link on the agreement page answered 404 — the download visibly, the
two inline ones just as dead — and nothing anywhere said why. The files
were there, whole and correct.
An artisan run inside a container is root; PHP-FPM is www-data. Laravel's
local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out
as root-only. The web process could not enter it, `Storage::exists()`
answered false, and the routes did exactly what they were told: abort 404.
No exception, no log line, a page full of links to nothing.
Both writers — the command and the console's upload form — now store with
"public" visibility, which is the file MODE and nothing to do with the web:
0755/0644 instead of 0700/0600. The disk's root is outside the document
root either way, and the two routes still check who is asking. The command
also warns if a file it just wrote is not readable, because the alternative
is finding out from a customer.
The download itself now looks like one: the `download` attribute on both
anchors, and in the console a bordered control rather than a third link in
a row of two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Art. 28(3) GDPR wants a contract wherever personal data is processed on
somebody else's behalf, which is the whole of what this product does. "In
writing" there includes electronic form (Art. 28(9)), so a document the
customer can read plus a recorded acceptance is enough — no signature on
paper. The website already promises "AV-Vertrag inklusive", which means it
has to be obtainable without asking us for it. It was not obtainable at
all.
**The text is never this application's.** An operator uploads the document
their lawyer wrote, names the version, and publishes it; the measures ride
along as a second file, because they are an annex to the agreement and
"which measures applied when this customer accepted" has to have one
answer. Inventing the text here would have been worse than having none.
**Uploading and publishing are two acts.** Acceptance is per version, so
publishing leaves every customer who accepted the previous one outstanding
again — correct, and far too expensive to trigger by dropping a file on a
form. It goes through a confirmation modal that says exactly that (R23).
**The customer's side** is a card in the contract tab: read the agreement,
read the measures, one press to conclude it. What that press records is
what makes it evidence rather than a flag — the version, the moment, the
address it came from, and the login that pressed. Pressing twice is one
agreement (unique index, not a check somebody can forget), and a
superseded acceptance is kept rather than overwritten: it was true when it
was made, and the history is the point.
Nothing renders until a version is in force. A card offering an agreement
that does not exist is worse than the silence.
The files live on the private disk and are served through routes that
check who is asking — an agreement is not a public asset, and a guessable
URL to one would be a list of who our customers are. The customer route
takes no version parameter: which document applies is ours to say.
`dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps
the platform running does not thereby decide what every customer is asked
to agree to — and a capability written under `web` since the 2026-07-29
move lands in a guard nothing authenticates against, which is how this one
first shipped answering 403 to a role that visibly had it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>