Day 70: When the Licence Has Teeth
Tuesday. Two strands ran in parallel today and both ended on main: the licence stopped being a label and started enforcing itself, and the chat UI stopped pretending every message had been delivered. They're unrelated in the code; they're the same kind of work in spirit.
The Seat Cap Stops Being a Suggestion
Yesterday's commit added maxUsers to the JWT schema as an always-on field. Today it does something. The licence service grew a seat-usage counter — active users plus pending invites, with a fixture-helper test that covers the awkward edges (null banned-users counted as active, expired invites not). The invite endpoint reads the count, compares it to maxUsers, and refuses the invite when the cap would be crossed. A blocked invite isn't a silent 403: the audit log gets a user.invite_blocked event with actorType and actorId, so an admin reviewing why an invite didn't go out has the trail to do it.
The settings UI grew the matching surface. The licence panel now shows seats used out of seats available — visible before you click Invite, not as an error after — and the invite button itself disables at the cap rather than letting an admin click into a refusal. The same panel learned to refresh the expiry banner after a key save instead of waiting for a page reload, which had been the small papercut you only notice once you've activated a licence three times in one debugging session.
None of this is novel infrastructure. It's the difference between a licence that exists in a database and a licence that quietly says no at the right moment. The kind of thing an admin running Pinchy under any sort of compliance pressure expects from a commerce-grade system from day one — and the kind of thing that's awkward to bolt on later, after the workflows have already started routing around its absence.
The Chat Stops Pretending
The other strand: chat delivery status. Until today, sending a message in Pinchy entered an undifferentiated state — the bubble appeared in the transcript and there was no further signal until either the assistant started replying or it didn't. Sending, sent, and failed were the same pixels on screen. As the product has grown into more interesting deployments, that ambiguity has stopped being a UI quirk and started being the thing people remember.
The fix is a reducer keyed on clientMessageId, threaded from the client through the bridge to OpenClaw and back. The server emits a userMessagePersisted ack after writing the session; the client flips the row from sending to sent. If the ack doesn't arrive within ten seconds, the row flips to failed. A failed row gets a Retry button — the kind that re-sends the same payload. A half-streamed assistant reply, where the model died mid-token, gets a different retry path: continue the turn instead of restarting it, so the user doesn't pay the latency twice and the audit trail doesn't end up with a phantom duplicate. Both retry kinds emit chat.retry_triggered audit events, distinguished by which path was taken.
An e2e test landed alongside it. A docs page in docs/ explains the chat state machine in user-facing language — what does it mean when the dot is amber — because the only thing worse than a silent failure is a silent failure with a colour change nobody can decode. Code review caught a handful of small things on the retry-button PR (the Send and Stop buttons being able to render together in a transition; a focus-stealing keyboard trap when the retry button mounted) and they went in as discrete fix commits.
openclaw-node 0.7.0
Pinchy's gateway library bumped to 0.7.0 today. The release removes continueLastTurn — the in-process retry-continue handler that the new chat retry path makes obsolete, since continuing a stream is now a first-class operation in the gateway schema rather than a side door in the runtime. The bump is a breaking change against any older Pinchy deployment, and it ships with the matching delete on the Pinchy side: dead retry-continue handler removed in the same dependency-bump commit, so the two repos move in lockstep rather than carrying parallel implementations for a transition window.
The Subscription Stack on the Odoo Side
In pinchy-odoo-addons, three commits that close the loop on Saturday's build. The first is a one-line hook into _action_confirm so the licence key actually shows up in the order confirmation mail — Saturday had wired up the mail template, but the trigger fired on a state transition the customer-facing checkout doesn't pass through, so the email had been sending without the key embedded. With the right hook, the confirmation email now contains the key the customer paid for, on the same domain that sent the invoice.
The second adds a notice on the shop thank-you page — your licence key has been emailed to the address on the order — because a customer who's just paid and is staring at a receipt page wants to know what to do next, and the answer should not be wait and see if the email arrives. The third is an accounting concern: invoices for Pinchy SKUs now route to a dedicated PIN journal, separate from the rest of the company's general accounting. That separation matters when end-of-quarter reporting wants to know what Pinchy itself made versus what the consultancy did.
CI on the addon repo got the polish it needed to actually pass: DB connection via env vars rather than positional CLI flags, python3-jwt installed in the test container (the dependency the licence module pulls in for signing), gosu dropped in favour of running Odoo as root in the test image, and the deprecated --without-demo=all flag replaced with the explicit per-module form Odoo 19 actually accepts.
Day 70
The throughline today is that several pieces of the product stopped being notional and started having consequences. A licence that says five seats now blocks the sixth invite. A chat message has a delivery state. A commerce flow with a confirmation email contains the thing the customer paid for. Each of those was implicit before; the implicit behaviour worked fine for the team that built it and was a slow leak of trust for everyone else. The shape this stretch of v0.5.0 is taking is a release where the product behaves the same way whether or not the person operating it knows the codebase — which, given the inbound conversations that have been picking up since Day 67, is increasingly the case that matters.