A platform can see that an installation is running. It cannot see
whether anybody is still using it, and the difference is what separates
a customer from an abandoned free instance holding a database.
So the status probe gains one field:
"activity": { "last_staff_login_at": "2026-08-24T21:13:32+00:00" }
Null means no staff account has ever signed in, and the key is emitted
either way. That is the whole care in this change: "they said never" and
"we got no answer" have to stay distinguishable, because collapsing them
is how a broken probe reads as a dormant fleet.
Only interactive sign-ins count. Laravel's Login event does not fire for
token authentication, so an integration polling every hour cannot make
an empty installation look busy -- which matters when the reading is
used to decide something.
Derived from the activity log rather than denormalised onto users. A
column would cost a migration, a listener change and a backfill to save
one indexed MAX() over a table with a handful of rows on exactly the
installations anybody asks this about. Nothing prunes the log, and
erasure anonymises entries rather than removing them -- actor_type
survives on purpose -- so the answer does not change when the person who
gave it is forgotten.
Requested by the ProjectSend Cloud control plane, which has no other way
to learn the date. Recorded in docs/api-todo.md as deliberately a
command rather than an endpoint, for the reason the command exists at
all: it observes, it does not accept instructions.
Asked for by the platform side, and the reason is better than
convenience. Their reconciler's rule is that it observes an end state and
never sends an instruction. `docker exec … php -r '…'` to reach a public
method is an instruction with the caller's argv in it, however harmless
the argv, and it would have been the first crack in that rule. A named
command is an observation, the same kind of thing as reading a directory
size.
`--json` for a machine, plain lines for a person. Nothing here is a
secret or a credential: every field is already visible to any signed-in
administrator, which is what makes it safe to read from outside the
container.
The counts come from SeatAllowance — the code that refuses the account
past the limit — rather than from a second query that agrees with it
today. Two counts that merely agree diverge eventually, over an inactive
account or a soft-deleted one, and the divergence reads as a billing
fault rather than a counting one.
Unlimited is emitted as null, with a test saying so, because the failure
if a reader takes it for zero is a customer on the most expensive plan
whose instance refuses to create a single client. The platform side
independently landed the same care on the emitting end, omitting the
variable rather than sending it empty.
It also answers the question that started all of this. Diagnosing why a
tenant ignored its bucket meant reaching into a container and calling
app() by hand; `projectsend:status` now says which capabilities the
edition grants, which is where that hunt began.