Metadata-Version: 2.5
Name: papeete-actor-synchronous-messaging
Version: 0.2.1
Summary: A runnable papeete-actor restricted to two verbs — request and query. No publications, no subscriptions.
Author-email: Papeete Consulting <yoann.remy@outlook.com>
License-Expression: MIT
Keywords: actor-model,agents,bounded-context,conformance,llm
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.11
Requires-Dist: papeete-actor-message>=0.3.0
Requires-Dist: papeete-actor>=1.0.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: claude
Requires-Dist: anthropic>=0.69; extra == 'claude'
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: openai
Requires-Dist: openai>=1.40; extra == 'openai'
Description-Content-Type: text/markdown

# papeete-actor-synchronous-messaging

A [papeete-actor](https://github.com/papeete-hub/papeete-actor) you can **run**, restricted to
two verbs.

```
request   ask an actor to do something.
query     ask an actor something.
```

That is the whole vocabulary. No publications, no subscriptions, no event log, no outbox, no
positions, no cadence — and no method on this package that emits an unaddressed fact.

```bash
pip install papeete-actor-synchronous-messaging
```

## The restriction is the product

`papeete-actor` publishes the ecosystem's identity contract. Nothing in the ecosystem *runs* an
actor: a manifest declares who it is, but no code turns that into a box that receives a call,
decides, and replies.

This is that box, deliberately crippled. An earlier design tried to describe an actor with four
directions; two of them were **unaddressed facts, pulled on somebody else's cadence**, and that
is where the hard problems live — positions, redelivery, backfill, supersession, dead letters.
Cutting them out leaves a synchronous, addressed, refusable conversation, small enough to
prove correct in CI and therefore small enough to be the first thing anyone actually runs.

> **This is a papeete-actor that declares no publications and no subscriptions.**

Not "must not" — **cannot**. There is no verb for an unaddressed fact, and no method that would
take one. The guarantee is an absence, which is why the suite can assert it with `hasattr`.

## Back to basics: not even the message's wire shape is fixed here

This package used to also own a message contract — an envelope, a `ref`, four fixed payload
kinds (`request`/`ack`/`query`/`answer`) with their own required fields. That is gone
([ADR-PAS-0007](./adr/ADR-PAS-0007-back-to-basics-actions-and-queries-only.md)). What this
package is now is narrower and more literal than its own name once implied: it lets a caller
address a door an actor declared, with `request` for an action and `query` for a query, and it
hands back whatever the answering actor's own code produced. What crosses a door — whether it
correlates with anything, what "accepted" actually means, what a `line`/`says` reports — is not
this package's business; that is dealt with in the actor's own code, as
[`examples/deterministic/waiter/order_book.py`](./examples/deterministic/waiter/order_book.py) and
[`examples/deterministic/customer/decide.py`](./examples/deterministic/customer/decide.py) do. What CAN be this package's
business, when a door opts in, is *shape*: a request payload is checked against the fields the
door's `door_schema` message declares, and — where a door also names a `completion_schema`
message — the final reply is checked against that too (`synchronous-messaging-doors/v1`,
[ADR-PAS-0009](./adr/ADR-PAS-0009-a-door-constrains-its-request-and-may-constrain-its-completion.md)).

**A door still has to name something real, though.** Every door is a *message* —
[`papeete-actor-message`](https://github.com/papeete-hub/papeete-actor-message)'s own
`actor-message.yaml`/`actor-data.yaml` say what data exists and how it groups into an intent,
wire-agnostically. This package is the downstream repo that package's own ADR-PAM-0001 left the
wiring to: a door naming no message in the actor's own catalog is refused
(`card.py`'s cross-check). `papeete-actor-message` is a dependency this package always has —
not an unused import, a gate.

```python
ack = customer.request(to="Waiter", action="take-order", table_number=5,
                       subject="the wild mushroom risotto",
                       means="the standard order, no allergies flagged")
# ack == {"accepted": True, "order_id": "order-0001", "line": "order-0001: recorded — ..."}
# — the Waiter's own vocabulary. A different actor would hand back something else entirely.

answer = customer.query(to="Waiter", query="order-status", about=ack["order_id"],
                        asks="what became of it?")
```

## An actor's folder, in four files, three of them not ours

```
examples/deterministic/waiter/
  actor.yaml                       papeete-actor-manifest/v0 — name, description. Owned by
                                    papeete-actor, consumed here at a pin, never restated.
  actor-data.yaml                  papeete-actor-data/v0 — a named, minimally-typed data
                                    dictionary. Owned by papeete-actor-message, at a pin.
  actor-message.yaml               papeete-actor-message/v1 — named messages, each a pure
                                    regrouping of data references (with an optional required/
                                    optional split) under an intent. Owned by
                                    papeete-actor-message, at a pin. Wire-agnostic by that
                                    package's own design.
  actor-synchronous-messaging.yaml synchronous-messaging-doors/v1 — actions and queries, each
                                    naming a door_schema message and, optionally, a
                                    completion_schema message and an engine. Owned by this
                                    package.
  order_book.py                    the Waiter's own store, and the only thing it writes
```

`actor-synchronous-messaging.yaml` is a closed shape: `manifest`, `actions`, `queries` at the
top level, `id`/`means`/`completion`/`door_schema` on each door, plus optional
`completion_schema`/`engine` — nothing else. No `hard_boundary`, no `autonomy`, no
`nature`/`rail`/`accepts_from`. Which list a door sits in *is* its nature; there is nothing left
to classify once it is placed. What a door's `door_schema` (and `completion_schema`, when given)
name, though, is not free: each must resolve against `actor-message.yaml`'s own
`messages[].name` — a door rides messages, never strings invented on the spot; its own `id`
need not equal either. `engine`, when given, names a slot in the `engines` registry an `Actor`
is built with — resolved at construction, not against the message catalog
([ADR-PAS-0010](./adr/ADR-PAS-0010-a-door-names-its-own-engine.md)).

```bash
papeete-actor-synchronous-messaging lint-card examples/deterministic/customer examples/deterministic/waiter
```

## There is no coupling to resolve

An earlier version of this package had a `dependencies:` section, a `Context`, and a
`registry.yaml` — a whole layer between "I have a peer's id" and "I can call it." That layer is
gone. An actor that wants to call another one simply does, by the mailbox name it already has —
often the name a call arrived UNDER:

```python
class Decisions:
    def confirm_substitution(self, actor, payload, from_, judged=None):
        # `from_` is the Waiter — the actor that opened THIS exchange. Calling it back needs
        # no coupling, no context, no registry: just its name, and door ids this file knows.
        known = actor.query(to=from_, query="order-status", asks="what does my order say?")
        ack = actor.request(to=from_, action="take-order", subject=..., means=...)
```

Knowing a peer's door ids is that actor's own business knowledge, the same way any real
service client knows the API it depends on. Nothing here discovers it, and nothing refuses an
undeclared edge anymore — the edge is just a call.

## Determinism sits at existence — now a declared fact of the door, not just a pattern

A door names which engine answers it, right in the card
([ADR-PAS-0010](./adr/ADR-PAS-0010-a-door-names-its-own-engine.md)) — `receive()` asks it
before dispatching to whatever answers the door, and hands the result to that door's own
registered handler as `judged`. A door naming none never reaches an engine at all, which is how
a pure lookup like `order-status` stays a lookup — and, since
[ADR-PAS-0012](./adr/ADR-PAS-0012-an-engine-is-for-judgement-a-fixed-rule-set-could-not-replace.md),
how `examples/deterministic/customer`/`examples/deterministic/waiter` stay lookups **and fact-checks** entirely: neither
card names an `engine:` anywhere, because "does this table exist, and is the dish on tonight's
menu" is a plain check against this actor's own state, never a vendor's opinion.

```yaml
# actor-synchronous-messaging.yaml
actions:
  - id: take-order
    door_schema: take-order-cmd
    completion_schema: [accepted-order-result, refused-order-result]
    # no `engine:` — a fact-check against this actor's own table roster and menu
queries:
  - id: order-status
    door_schema: order-status-cmd
    completion_schema: order-status-result
    # no `engine:` — a pure lookup against this actor's own state, never asks one
```

`examples/llm-judged/buyer`/`examples/llm-judged/delivery-person` is where the repo's one genuinely judged door
lives instead: `report-issue` names `engine: scripted`, because "is this account of a
late/wrong/damaged delivery credible, and what remedy does it warrant" has no fixed table that
could answer it.

**One handler per door, not one callable for all of them
([ADR-PAS-0011](./adr/ADR-PAS-0011-one-handler-per-door-not-one-work-for-all.md)).** A
"take-order service" is a plain function named `take_order` — the same way it would be an HTTP
endpoint elsewhere — not a branch inside a bigger dispatcher. Which door calls which function is
`Actor`'s own lookup (`actions={"take-order": order_book.take_order}`), never a business
author's `if door == ...`:

```python
def order_status(self, actor, payload, from_, judged=None):
    # A LOOKUP. "Do I hold this?" is a dict check — receive() never touched an engine to get
    # here, because this door's own YAML names none. `judged` is always `None`.
    ...

def take_order(self, actor, payload, from_, judged=None):
    # A FACT-CHECK, not a judgement (ADR-PAS-0012) — `take-order` names no `engine:` either;
    # `judged` is always `None` here too. Does the table exist, and is the dish on tonight's
    # menu? Both are plain checks against this actor's own state.
    if payload.get("table_number") not in self.TABLES:
        return {"accepted": False, "because": "not one of my tables"}
    ...
```

## The engine is a port, not a vendor

```python
class Engine(Protocol):
    def judge(self, *, system: str, prompt: str, schema: dict | None = None) -> dict: ...
```

Three adapters, and neither vendor is a dependency of this package:

| engine | used for | needs |
|---|---|---|
| `scripted` | the conformance suite — no network, no key, runs on fork PRs | nothing |
| `claude` | local development — **never runs in CI** (`pytest -m claude`) | `pip install 'papeete-actor-synchronous-messaging[claude]'` |
| `openai` | **CI asserts the protocol with this one** | `pip install 'papeete-actor-synchronous-messaging[openai]'` |

`examples/llm-judged/buyer`/`examples/llm-judged/delivery-person`'s `report-issue` is the only door in the repo any of
these three actually answer — [ADR-PAS-0012](./adr/ADR-PAS-0012-an-engine-is-for-judgement-a-fixed-rule-set-could-not-replace.md)
narrowed `engine:` to doors a fixed rule set genuinely could not answer; `examples/deterministic/customer`/
`examples/deterministic/waiter` name none anywhere. Running one suite against two unrelated vendors is the
evidence for this package's own claim — *"what a papeete-actor is built with is interior; the
doors are membrane"* ([ADR-PAS-0003](./adr/ADR-PAS-0003-the-engine-is-a-port.md)).
`schema` is optional: `Actor.judge()` passes a door's own `completion_schema`, resolved from the
message it names, when it has one, and passes none when it doesn't — there is no fixed payload
shape of this package's own to constrain an engine to.

`Actor` is built with `engines: dict[str, Engine]` — a registry of pre-built instances, keyed by
whichever symbolic name a door's own `engine:` field names. Credential wiring (API keys, which
model, `client=`) is entirely the caller's business; `Actor` never resolves an engine by name
itself, it only calls whichever pre-built instance a door's declared key resolves to.

The system prompt is **generated from the doors**, so impersonating a different actor is a
matter of pointing at a different folder — never of editing a prompt.

## Try it

`examples/deterministic/customer` and `examples/deterministic/waiter` are real, run-it-yourself actors — clone this repo,
`pip install -e .`, and the commands below just work. See `examples/README.md` for more.

```bash
papeete-actor-synchronous-messaging contracts
papeete-actor-synchronous-messaging lint-card examples/deterministic/customer examples/deterministic/waiter
papeete-actor-synchronous-messaging converse --from examples/deterministic/customer \
                              --to   examples/deterministic/waiter --engine scripted
```

```python
from papeete_actor_synchronous_messaging.actor import Actor
from papeete_actor_synchronous_messaging.mailbox import InProcessMailbox

box = InProcessMailbox()
# NO `engines=` AT ALL — neither card names one (ADR-PAS-0012). Every door here answers from
# this actor's own state.
customer = Actor.from_card("examples/deterministic/customer", mailbox=box)
waiter   = Actor.from_card(
    "examples/deterministic/waiter", mailbox=box,
    # One handler per door (ADR-PAS-0011) — see examples/deterministic/waiter/order_book.py for the real
    # thing, backed by real state instead of this one throwaway lambda.
    actions={"take-order": lambda actor, payload, from_, judged=None:
             {"accepted": True, "order_id": "order-0001", "line": "recorded"}},
    queries={"order-status": lambda actor, payload, from_, judged=None:
             {"says": "from the order book.", "orders": ["order-0001"]}},
)

ack = customer.request(to="Waiter", action="take-order", table_number=5,
                       subject="the wild mushroom risotto",
                       means="the standard order, no allergies flagged")

answer = customer.query(to="Waiter", query="order-status", about=ack.get("order_id"),
                        asks="what became of it?")

assert not hasattr(customer, "publish")           # the guarantee is the absent method
```

`examples/llm-judged/buyer`/`examples/llm-judged/delivery-person` is the pair that DOES need an `engine=` registry —
`report-issue` is the repo's one door a real vendor's judgement actually answers:

```python
from papeete_actor_synchronous_messaging.engine import resolve

engine = resolve("claude")                        # or "openai", or "scripted"
engines = {"scripted": engine}                     # the slot name `report-issue` names
delivery_person = Actor.from_card("examples/llm-judged/delivery-person", engines=engines, mailbox=box)
```

## The contract is in this repo

[`src/papeete_actor_synchronous_messaging/schemas/`](./src/papeete_actor_synchronous_messaging/schemas/)
— ordinary committed source. **The package IS `synchronous-messaging-doors/v1`**, not a runtime
that goes looking for it. `uv build` reaches nothing outside its own checkout, and
`papeete-actor-synchronous-messaging contracts` is the assertion that the schema arrived in the
wheel.

**Identity is not this package's job, and neither is a message's own shape.** `papeete-actor`
supplies an actor's identity (`name`, `description`, in `actor.yaml`) at a pin.
`papeete-actor-message` supplies its data dictionary and message catalog (`actor-data.yaml`,
`actor-message.yaml`) at a pin. This package reads both and adds only the doors — the wiring
from a message name to `request` or `query`.

## Impersonation — where this is heading

`Actor.from_card(path, engines=...)` boots an actor from **any** folder holding `actor.yaml`,
`actor-data.yaml`, `actor-message.yaml`, and `actor-synchronous-messaging.yaml`. A business
actor's own identity file already exists today (`actor.yaml` is `papeete-actor`'s own
contract); it becomes impersonatable here the moment its business is also expressed as data,
messages, and a doors sidecar naming them — no bigger card contract to satisfy, no
`hard_boundary`/`autonomy` to declare, because this package no longer asks for them. See
[`doc/SYNCHRONOUS-MESSAGING.md`](./doc/SYNCHRONOUS-MESSAGING.md).

## Licence

MIT.
