Metadata-Version: 2.5
Name: papeete-actor-simple
Version: 0.1.0
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>=0.5.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-simple

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

```
request   ask an actor to do something.  It yields an ACK, never a result.
query     ask an actor something.        It yields an ANSWER.
```

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-simple
```

## The restriction is the product

`papeete-actor` publishes the ecosystem's contracts and gates them. Nothing in the ecosystem
*runs* an actor: a card declares `offers` — "what I can be asked to do" — and a `mailbox`, but
no code turns those declarations into a box that receives a message, decides, and replies.

This is that box, deliberately crippled. `papeete-actor-card/v1` describes an actor with four
directions; two of them are **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.

> **A simple actor is a conformant papeete-actor card that declares no publications and no
> subscriptions.**

Not "must not" — **cannot**. There is no payload kind 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`.

## A request still has no return value

`papeete-actor-card/v1` is explicit: a request completes *"as a refusal, or LATER as a
publication the sender may have subscribed to"* (ADR-PA-0009 §4). Publications are banned
here, so this deployment supplies the other half — **the disposition is discovered by query**:

```
Auditor  -> Archivist   request  {offer: record-deviation, subject: …, means: …}
Archivist -> Auditor    ack      {disposition: accepted, because: …}          ref: req-0001

          …Archivist does the work in its own repo, under its own review…

Auditor  -> Archivist   query    {offer: deviation-state, about: req-0001}
Archivist -> Auditor    answer   {state: resolved, says: …, records: […]}
```

The rule survives intact, and the two verbs become mutually load-bearing rather than merely
coexisting: without `query`, nothing a `request` starts could ever be found again
([ADR-PAS-0002](./adr/ADR-PAS-0002-a-request-completes-by-query.md)).

## One actor using another — the coupling

`dependencies` on a card is **the actors whose contract I resolve**, and it is a *business*
edge: the coupled actor holds knowledge this one does not hold, and computes what this one may
not compute. What an actor is *built with* never appears there — that is interior — and the
contract it conforms to is already stated by `card:`.

The two worked examples are two repos, and each declares one coupling:

```
tests/e2e/scenario1_two_actors_in_process/actors/     <- the context: a small information
                                                           system, two actors wide, living
                                                           inside the scenario that tests it
  profile.yaml                        the deployment's own facts: its rails, and where its index is
  registry.yaml                       WHERE each card lives. The sole authority; no card says.
  archivist/  papeete-actor.yaml      EXA.ARCHIVIST   dependencies: [EXA.AUDITOR @ 0.1.0]
              ledger.py               its store of record, and the only thing it writes
              ledger.yaml             that store, rendered after the worked conversation
  auditor/    papeete-actor.yaml      EXA.AUDITOR     dependencies: [EXA.ARCHIVIST @ 0.1.0]
              triage.py               the coupling, in code
```

The registry is written against **`ecosystem-registry/v0`**, the contract papeete-actor 0.5.0
added for exactly this file, so its own whole-ecosystem tools run over the examples unchanged —
all three in CI, on every push:

```bash
papeete-actor lint-registry tests/e2e/scenario1_two_actors_in_process/actors/registry.yaml
#   ok  conforms to ecosystem-registry/v0 (2 row(s): 2 actor, 0 dangling, 0 external)
papeete-actor lint-card --registry tests/e2e/scenario1_two_actors_in_process/actors/registry.yaml tests/e2e/scenario1_two_actors_in_process/actors/*/papeete-actor.yaml
papeete-actor check     --workspace tests --registry tests/e2e/scenario1_two_actors_in_process/actors/registry.yaml
#   ok  joined 2 card(s): 0 publications, 2 at papeete-actor-card/v1
```

The second is the **cross-card join** — dangling subscriptions, unsubscribed publications,
unpinned scripted subscriptions — run over two example actors exactly as it runs over the seven
real ones. That line is the restriction's central claim put to the ecosystem's own tool instead
of asserted here: a context that publishes nothing and pulls nothing gives the join nothing to
report. And a coupling naming an id the registry does not place fails the first command with
`resolves nowhere in registry.yaml` — a check this package cannot perform for itself.

What an accepted `record-deviation` actually writes — the caller's grounds kept as a
quotation, never restated as the Archivist's own finding:

```yaml
- ref: req-0001                    # opaque by contract. Nothing downstream may parse it.
  door: record-deviation
  rail: contract-deviation
  opened_by: Auditor
  subject: publication/v2 requires a payload shape for a fact that was never emitted
  reported:                        # THEIRS. Quoted, attributed, unedited.
    by: Auditor
    means: 'my card carries `shape: none` with a status explaining why, and the join …'
  recorded_by: EXA.ARCHIVIST       # MINE. The judgement and the write.
  state: resolved
```

A refusal reaches no entry — there is nothing to record about work an actor declined to take.
And the entry never crosses the membrane: `answer.records` is a list of strings by contract,
so what a caller reads back is one quotable line per entry, not the store.

A debt report reaching the Auditor is sometimes not debt — it is a shape that cannot express
something true. Finishing that triage needs two things the Auditor does not have:

```
Archivist -> Auditor     request  {offer: report-debt, subject, means}
    Auditor -> Archivist   query    …deviation-state   "does your ledger already carry this?"   KNOWLEDGE
    Auditor -> Archivist   request  …record-deviation  "yours to judge, and yours to record"    COMPUTATION
Auditor  -> Archivist    ack      {disposition, because}
```

The Auditor never writes in that ledger and does not decide the outcome — the Archivist is
free to refuse it. What it keeps is a quotation and a `ref`, so whoever reads `debt-state`
later can go and check at the source. And **the door is not a string typed anywhere**: the
coupling resolves `EXA.ARCHIVIST` to an exact card, and the card names the door for the
nature and rail the case has. Delete the declaration and the run stops at `resolve` — an
undeclared coupling is refused, because an edge a reader of the card cannot see is one nobody
can audit ([ADR-PAS-0005](./adr/ADR-PAS-0005-a-coupling-is-declared-and-resolved-by-a-context.md)).

## Determinism sits at existence, never at interpretation

| deterministic — never the model | judged — the engine |
|---|---|
| is the envelope present and well-formed | should this request be accepted, on the grounds given |
| does the answering card declare this door | what does my own state say in answer to this |
| **do I hold an exchange under this `ref`** | |
| does the payload validate against its kind | |

The split is **structural, not policed**. The JSON Schema an engine is constrained by is built
from each payload's `judged:` fields alone, so `answer.state` is not a field a model is trusted
to get right — it is one the model is never shown and cannot emit. A query about a `ref`
nobody opened returns `state: unknown` **without calling the engine at all**.

## The engine is a port, not a vendor

```python
class Engine(Protocol):
    def judge(self, *, system: str, prompt: str, schema: dict) -> 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-simple[claude]'` |
| `openai` | **CI asserts the protocol with this one** | `pip install 'papeete-actor-simple[openai]'` |

Running one suite against two unrelated vendors is the evidence for a claim
`papeete-actor-card/v1` already makes — *"what a papeete-actor is built with is interior; the
card is membrane"*. A suite that only ever ran against the model the runtime was developed
against could not tell a contract from a habit
([ADR-PAS-0003](./adr/ADR-PAS-0003-the-engine-is-a-port.md)).

The system prompt is **generated from the card**, so impersonating a different actor is a
matter of pointing at a different `papeete-actor.yaml` — never of editing a prompt.

## Try it

```bash
papeete-actor-simple contracts        # …and the deployment profile in effect
papeete-actor-simple lint-card tests/e2e/scenario1_two_actors_in_process/actors/*/papeete-actor.yaml

# Two things this gate CARRIES and never decides — where coupled actors live, and which
# rails this deployment routes on. Both default sensibly; neither is this package's to own.
papeete-actor-simple lint-card --registry path/to/registry.yaml --profile path/to/profile.yaml CARD
papeete-actor-simple converse --from tests/e2e/scenario1_two_actors_in_process/actors/auditor \
                              --to   tests/e2e/scenario1_two_actors_in_process/actors/archivist --engine scripted
```

```python
from papeete_actor_simple.actor import SimpleActor
from papeete_actor_simple.engine import resolve
from papeete_actor_simple.mailbox import InProcessMailbox

box = InProcessMailbox()
engine = resolve("claude")                       # or "openai", or "scripted"
auditor   = SimpleActor.from_card("tests/e2e/scenario1_two_actors_in_process/actors/auditor",   engine, mailbox=box)
archivist = SimpleActor.from_card("tests/e2e/scenario1_two_actors_in_process/actors/archivist", engine, mailbox=box)

peer = auditor.resolve("EXA.ARCHIVIST")          # a declared coupling, located by the context
door = peer.doors("action", "contract-deviation")[0]

ack = auditor.request(to=peer.name, offer=door.id,
                      subject="publication/v2 has no slot for a fact that was never emitted",
                      means="my card carries `shape: none` and the gate cannot tell why")

answer = auditor.query(to=peer.name, about=ack.ref, asks="what became of it?",
                       offer=peer.doors("query", "contract-deviation")[0].id)

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

## The contract is in this repo

[`src/papeete_actor_simple/schemas/`](./src/papeete_actor_simple/schemas/) — ordinary committed
source. **The package IS `simple-actor-protocol/v0`**, not a runtime that goes looking for it,
which is [ADR-PA-0001](https://github.com/papeete-hub/papeete-actor/blob/main/adr/ADR-PA-0001-papeete-actor-is-sovereign.md)'s
position applied here. `uv build` reaches nothing outside its own checkout, and
`papeete-actor-simple contracts` is the assertion that the schema arrived in the wheel.

`papeete-actor-card/v1` is the other way round: **consumed at a pin, never re-authored**. Not
one rule about a card appears in this package — `card.py` calls `papeete_actor.cards.lint` and
merges its findings ([ADR-PAS-0004](./adr/ADR-PAS-0004-the-protocol-is-sovereign-here.md)).

Two deployment facts travel the same way. **Where a coupled actor lives** comes from a
registry, and **which rails a card's offers may name** comes from a deployment profile
(ADR-PA-0016) — neither is a contract, both are facts about one deployment, and this package
carries them to the gate that owns them without reading either. `papeete-actor-simple
contracts` prints the profile in effect beside the contract versions, because a card that
lints on one machine and fails on another usually differs by exactly that.

The four payload kinds are candidates for `inter-agent-message/v0`, whose doctrine says new
kinds *"slot in without disturbing the layers already there"*. They are proven here, under a
restriction, before anyone proposes moving a shape that seven cards declare. The route home is
papeete-actor's own `contract-deviation` door.

## Impersonation — where this is heading

`SimpleActor.from_card(path, engine)` boots an actor from **any** card. Today
`reliever-design`'s card would be **refused**: it declares a publication and two subscriptions.
That refusal is the honest statement of the direction of travel — a business actor becomes
impersonatable when, and only when, some part of its work can be expressed inside the
restriction. See [`doc/SIMPLE-ACTOR.md`](./doc/SIMPLE-ACTOR.md).

## Licence

MIT.
