Releases: Particle-Academy/prism-human-plus
Release list
v0.3.0
Two writers: a lost update is no longer silent
A human editing the same surface as the agent used to have their work
overwritten with NOTHING REPORTED. The agent read, the person committed, the
agent wrote, and both writes succeeded — which is what a lost update looks like
from the inside. The only party who could tell was whoever watched their change
disappear.
That is now detected. It is not resolved, and the difference is deliberate.
HOW IT WORKS
Optimistic concurrency, with the comparison left where the knowledge is. A tool
result may carry a revision marker; the package stores it on the attachment and
pins every later call to it; a surface that says the marker is stale gets the
call refused with SurfaceChangedUnderYou (code surface_changed_under_you),
nothing written, and the stored marker dropped so the agent can read again.
The refusal is written for a MODEL to read mid-turn, because that is who
receives it. It names the tool, the revision it was working from, and the one
thing not to do: repeat the call with the same arguments, which is how the other
change gets overwritten.
IT DOES NOT MERGE, AND WILL NOT
This package never models what a surface's state is — tools come from the
surface's own tools/list and the data behind them is opaque here. A merge
invented at this layer would be guessing at a document it cannot read, and would
be wrong silently. The agent is told its read is stale and decides, because it
is the only party that knows what it was trying to achieve.
WHAT YOUR SURFACE HAS TO DO
Mint a revision on results — _meta.revision preferred, or a top-level
revision, surfaceRevision, etag, or integer version. The marker is
opaque: never parsed, ordered or compared here, capped at 512 bytes.
Reject a stale one. The pin arrives as params._meta.revision on tools/call.
Reject with HTTP 409, or a JSON-RPC error whose code or data.code is 409,
conflict, revision_mismatch, revision_stale, stale_revision or
precondition_failed.
READ THIS BEFORE RELYING ON THE CHECK
$humanPlus->conflictDetection($owner, $id); // a ConflictDetection enum
NotObserved the surface has not answered; nothing is known
Unavailable answered, mints nothing — writes are unpinned, and a
concurrent edit WILL be lost silently. The one definite
negative.
Minted mints revisions, so every call is pinned. Whether it ENFORCES
the pin is NOT observable from here. Half a green light.
Enforced has actually refused a stale pin. Proven, because it happened.
MINTED IS NOT PROTECTION. A surface that mints a revision and ignores the pin
looks identical from here and loses every update. That is not hypothetical: the
first integrator is exactly that surface today, and they found it by reading
their own source rather than trusting this package's sentence.
There is deliberately no "require enforcement" mode. A surface with one writer
legitimately never rejects anything and is indistinguishable from one that
cannot, so a flag demanding proof would refuse every write on a healthy surface
until a conflict happened to occur. Enforced is evidence when it arrives, never
a precondition.
requireRevision: true refuses a call to a surface in Unavailable. The first call
is always allowed — there is no way to know what a surface supplies before it has
answered, and refusing it would refuse the read that finds out. Off by default.
It requires MINTING, not protection.
IF YOU BUILT AGAINST MAIN BETWEEN 1383012 AND fe4d6d2
conflictDetection() briefly returned ?bool, and its true was documented as
"a lost update will be caught" — which measured minting and claimed enforcement.
It was never in a released tag, so a pinned version was never affected; this note
is for anyone tracking main, and for anyone who wrote the assertion the way the
old documentation described.
// was
assert($humanPlus->conflictDetection($owner, $id) === true);
// now — and note this asserts MINTING, which is what the old check
// actually measured. There is no assertion available for protection.
assert($humanPlus->conflictDetection($owner, $id) !== ConflictDetection::Unavailable);
A COARSE REVISION MANUFACTURES REFUSALS THAT CONFLICT WITH NOTHING
A per-surface counter means a human editing one screen bumps the marker an
agent's write to a different screen is pinned against, so the refusal is true by
the marker and wrong by intent. That is the marker's granularity, not this
package — the fix is a finer revision and belongs to the surface. Flagged so a
refusal rate higher than expected does not read as a defect here.
ALSO
Every check between an invitation and the wire already lived in one RelayEndpoint
both transports compose (v0.2.0); the conflict path adds nothing to it.
A JSON-RPC error's code and message used to be DISCARDED — a refused argument, a
misconfigured tool and an internal error were one indistinguishable sentence.
The reason comes through now.
A HasErrorCode contract, implemented by the three new failures. The older
exceptions do not carry codes yet; that is said in the contract rather than left
looking like an oversight.
Thanks to the first integrator for both halves of this: the transport, and then
finding that the detector for it measured the cheap half.
v0.2.0
A relay transport for hosts that cannot hold a stream open
Adds LongPollRelayTransport. Nothing is removed and no public constructor
signature changed, so an existing binding to SsePostRelayTransport needs no
edit.
WHAT IT IS FOR
Not throughput. It parks a worker for exactly as long as the SSE transport
does, and neither transport is a concurrency fix — that is a capacity question
in your host, answered with workers and a lowered timeoutSeconds.
It is for the hosts where the STREAMING LEG DOES NOT SURVIVE THE TRIP:
- a queue worker or FaaS runtime with no streaming HTTP client, or one that
kills a request producing no bytes for N seconds; - a forward proxy or corporate gateway that buffers responses, so the SSE
frames arrive in one lump when the stream closes — after the exchange has
already timed out; - a runtime that materialises the response body before user code sees it, which
makesstream => truea lie nothing reports.
In all three the failure is silent or looks exactly like a timeout, and tuning
timeoutSeconds does not touch it. Every leg here is a discrete, ordinary, short
request that any HTTP client anywhere can make.
If SSE works for you, keep it. It is one round trip with no polling interval.
$transport = new LongPollRelayTransport(
$http,
allowedRelayHosts: ['relay.fancy.example'],
timeoutSeconds: 15,
egressProxy: 'http://egress.internal:3128',
pollWaitSeconds: 5,
);
THE RELAY HALF OF THE CONTRACT
The broker is Fancy's, so what this expects is stated rather than assumed:
POST {base}/{session}/inbox?token&client
GET {base}/{session}/outbox?token&client&wait=5&after={cursor}
POST {base}/{session}/unregister?token&client
wait is an ASK, not a requirement. A broker that ignores it still works: the
transport times each poll, so one that came back fast and empty is paced before
the next and one that took its time is not. That means it can be pointed at a
plain queue endpoint never built for long polling, and neither kind of broker
has to declare which it is.
Four poll response shapes are accepted — a bare frame, a list, {frames,cursor},
{events,next} — because refusing three of them would make every new relay a
code change rather than a binding. pollPath is a constructor argument
(default outbox) for the same reason.
ONE EGRESS POLICY, PROVED AGAINST BOTH TRANSPORTS
Every check between an invitation and the wire now lives in RelayEndpoint,
which both transports compose: URL shape, host and port allow-lists,
resolved-address check, egress proxy requirement, auth mode, and telling a 401
from a 410. This is the security-relevant part of the release.
Two copies of an SSRF guard would not drift on the day the second was written.
They drift the third time somebody fixes a bug in one of them, and no
per-transport test can see it — each file goes on passing against its own class
for ever. So the suite runs ONE corpus of hostile invitations through BOTH
bindings, and that was mutation-checked rather than assumed: forcing the
long-poll transport to skip the egress requirement turns the corpus red.
Adding a third transport to that corpus is one line, and a transport that
forgets to compose RelayEndpoint fails on the day it is written.
v0.1.0 — first release
Human+ participant presence for Prism Harness: a person joins a live surface as a participant, and the agent gets the tools that surface offers — except the ones reserved for the human.
The fix this release carries
That reservation is the security property the package exists to hold. A tool name is chosen by the surface, and a single trailing space defeated the reservation in all three languages, under every trust level, with nothing raised:
| tool name | admitted to the agent? |
|---|---|
terminal_confirm |
reserved — correct |
terminal_confirm (one trailing space) |
ADMITTED |
An agent that can call a confirmation tool approves its own proposals, and the surface cannot tell that apart from a person clicking the button.
Fixed by normalising the name against an explicit codepoint set — spelled identically in PHP, TypeScript and Python rather than each language's own trim(), which would have closed the ASCII hole and opened three new Unicode ones — and then refusing any name that is not a well-formed identifier. Verified by a cross-language corpus rather than by reasoning.
Also in this release
- Tool results re-enter the prompt inside a nonce-delimited untrusted-output envelope, under a byte budget.
- The relay transport requires credential-free HTTPS through a trusted egress proxy, and never disables TLS verification.
- No logging anywhere in
src/, so there is nothing to leak through it.
Audit
55 tests, PHPStan clean, Pint clean, CI green, zero open Dependabot alerts, no binary or generated files in the package.
The relay token travels in the query string by default because browser EventSource cannot set an Authorization header; relays that implement header auth can opt into authMode: 'bearer'.