Metadata-Version: 2.5
Name: papeete-context
Version: 0.1.0
Summary: Ambient message context for the papeete-* mesh — a scarce, budgeted bag of correlation keys that rides W3C baggage over any carrier.
Author-email: Papeete Consulting <yoann.remy@outlook.com>
License-Expression: MIT
Keywords: baggage,context,correlation,messaging,opentelemetry
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Topic :: System :: Distributed Computing
Classifier: Topic :: System :: Logging
Requires-Python: >=3.11
Requires-Dist: opentelemetry-api>=1.27
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# papeete-context

Ambient message context for the papeete-* mesh — a scarce, budgeted bag of correlation keys that
rides [W3C Baggage](https://www.w3.org/TR/baggage/) over any carrier.

```bash
pip install papeete-context
```

## Why a separate package

[`papeete-actor-synchronous-messaging`](https://github.com/papeete-hub/papeete-actor-synchronous-messaging)
describes a **conversation**: which doors exist, what each accepts, who may knock. That is one
plane of mesh communication, and a card is the right home for it.

This package is the second plane: the context that travels with **every** message through
**every** door and that **no door declares** — correlation, nesting, attempt, verbosity. It is
not about sync versus async; the same bag rides an HTTP header, a RabbitMQ field table and a
Service Bus property map without noticing the difference.

See [ADR-PC-0001](./adr/ADR-PC-0001-context-is-ambient-and-rides-baggage.md).

## Context is ambient by definition

No actor card declares it, no door negotiates it, nothing opts in. Three things follow, and none
of them is optional once that is said:

- **It is always optional at the point of use.** If nothing declares a key, nothing can require
  it — a message may arrive from an actor minted before the key existed, or across an edge that
  scrubbed it. `get()` therefore always returns `str | None`. Anything genuinely *required*
  belongs in the payload, where the card declares it and the door can refuse without it.
- **Ambience needs a perimeter.** "Ambient" is only safe as "ambient *within the mesh*" — see
  [`scrub()`](#the-edge), whose default keeps nothing.
- **Key names become global.** Nothing local catches a collision, so the names are owned here and
  everything else must be namespaced by whoever mints it.

## Scopes nest

A request crosses A, B, C, D. Inside it, B spawns a second request across X and Y. X and Y belong
to *both*.

```python
import papeete_context as ctx

with ctx.root("request1"):          # A, B, C, D
    with ctx.child("request2"):     # X, Y — inside request1, not instead of it
        ctx.correlation_id()        # 'request1'  — the whole tree, never changes
        ctx.scope_id()              # 'request2'  — what B minted
        ctx.path()                  # 'request1/request2'
```

Three flat keys rather than one nested value, because the encoding decides whether the result can
be queried: `x-correlation-id = request1` is an exact match finding all six actors, and
`x-scope-id = request2` an exact match finding only the spawned sub-work. A joined list would
force a regex scan.

`child()` never touches the root. An actor that could overwrite it would silently reparent a
conversation, and nothing downstream could tell.

## Propagation is inherited, not built

OpenTelemetry's default global propagator is already a `CompositePropagator` carrying baggage
beside `traceparent`. Any `inject()` a binding already performs writes this bag out; any
`extract()` reads it back.

```python
from opentelemetry.propagate import inject

with ctx.root("request1"), ctx.using({ctx.ATTEMPT: "2"}):
    headers = {}
    inject(headers)
    # headers['baggage'] == 'x-correlation-id=request1,x-scope-id=request1,...'
```

HTTP headers, an AMQP field table and a Service Bus property map are all string-to-string maps —
which is exactly the shape OTel's own `Getter`/`Setter` abstracts. **That is where the
transport-independence comes from, and it is free.**

## Two carriers, two lifetimes

Baggage carries the bag *for the hop*. The envelope carries it *for the record* — the moment a
message is persisted in an outbox, a dead-letter queue, or a replay months later, the transport
frame is gone and the context has to survive inside the message itself.

```python
message = ctx.envelope("implement-task", causation_id=previous_id)
# {'id': '01JBQ8...', 'type': 'implement-task', 'created_at': '...Z',
#  'causation_id': '...', 'context': {'x-correlation-id': 'request1', ...}}

with ctx.adopt(message):            # much later, from storage
    ctx.correlation_id()            # 'request1'
```

## The registry

| Key | Tier | Carries |
|---|---|---|
| `x-correlation-id` | identity | The root of the causal tree. Never changes. |
| `x-scope-id` | identity | The innermost scope. |
| `x-scope-path` | identity | Every scope from the root inwards, `/`-joined. |
| `x-attempt` | hint | Which retry this is — **observed, never enforced**. |
| `x-trace-level` | hint | A downstream verbosity hint. |

Nothing consumes `Tier` today; the ceiling raises rather than drops. It is recorded because
whoever mints a key is the only one who reliably knows whether losing it costs legibility or
costs the conversation — and whoever eventually meets the ceiling will not.

Anything not in this table must be namespaced by its owner (`x-ben-*`).

## The ceiling

**8 entries · 1024 bytes total · 128 bytes per value.** A *logical* ceiling, chosen for the model
and set below every carrier's own limit — including W3C Baggage's advisory 8192 bytes.

Sizing to a carrier would make that carrier's capacity a load-bearing assumption nothing
declares, discovered only when you try to swap media. Choosing below all of them makes
portability a property held by construction. At 128 bytes a value, an identifier fits and content
does not.

Exceeding it raises `ContextBudgetExceeded` **where the key is added**, never where the message
is sent — enforced on send, the error names whoever transmitted the message, which may be five
hops from whoever added the offending key.

```
ContextBudgetExceeded: adding 'x-ben-blob' would make the context 1104 bytes, over the
1024-byte ceiling (scope request1/request2). Present keys: x-attempt, x-correlation-id, ...
```

Nothing is dropped and nothing truncated. While the key set is still being chosen, the right
answer to a full bag is to question the key.

## The edge

`scrub()` keeps **nothing** by default. An open bag honoured from outside the mesh is an
injection path — set `x-trace-level=debug` at the perimeter and you have turned on verbose
logging mesh-wide; assert someone else's `x-correlation-id` and you have grafted onto their
conversation.

```python
ctx.scrub(inbound_headers)                                  # {}
ctx.scrub(inbound_headers, allow=[ctx.CORRELATION_ID])      # a deliberate, visible decision
```

## What this package is not

- **It configures no SDK.** [`papeete-observability`](https://github.com/papeete-hub/papeete-observability)
  owns that and is deliberately not a dependency here — this package calls only the
  OpenTelemetry *API*, which is a genuine no-op until some other process configures the SDK.
- **It enforces no policy.** Retry, timeout, failover and dead-lettering are the mesh's third
  plane, and it is undescribed on purpose. `x-attempt` is carried to be observed, never acted on.

## Releasing

Tag-triggered, over PyPI Trusted Publishing (OIDC) — no token is stored anywhere:

```bash
# bump `version` in pyproject.toml first; the workflow verifies the two agree
git tag v0.1.0 && git push origin v0.1.0
```

`release.yml` builds, asserts the wheel imports and a scope actually nests, publishes, then
installs the exact version *back from PyPI* and re-runs the assertion — because `uv publish`
returning does not mean anyone can install it yet.
