Metadata-Version: 2.4
Name: plm-shared
Version: 1.0.0
Summary: TracePulse PLM shared contracts: Kernel API surface, idempotency, capabilities, knowledge envelope, pricing, artifact store, trace context. Frozen in US-W1.0; consumed by US-W1.1+ and US-CR.0+.
Author: TracePulse
License: Proprietary
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: pydantic>=2.5.0
Requires-Dist: pydantic-settings>=2.0
Requires-Dist: starlette<1.0,>=0.27
Requires-Dist: PyYAML>=6.0
Provides-Extra: db
Requires-Dist: SQLAlchemy>=2.0; extra == "db"
Requires-Dist: psycopg[binary]>=3.1; extra == "db"
Requires-Dist: alembic>=1.13; extra == "db"
Provides-Extra: s3
Requires-Dist: boto3>=1.34; extra == "s3"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-asyncio; extra == "test"
Requires-Dist: httpx<1.0,>=0.27; extra == "test"

# plm-shared

Frozen contracts shared across TracePulse PLM product lines (Core,
Skill Kernel, Skill Packages, Knowledge, Workbench, Studio,
Connectors). Owned by US-W1.0; consumed by US-W1.1 → US-W1.13 and
US-CR.0 → US-CR.13 without modification.

US-W1.0 ships in 7 PRs:

| PR  | Scope                                                             |
|-----|-------------------------------------------------------------------|
| PR-1 | package skeleton + Pydantic models + IdempotencyKey + Capability |
| PR-2 | Alembic migrations 0001-0007 + SQLAlchemy ORM + `db.py`          |
| PR-3 | migration 0008 (Postgres roles) + append-only audit suite         |
| PR-4 | migration 0009 (RLS) + `middleware.py` + tenant-isolation audit   |
| PR-5 | `ObjectStoreBackend` (S3 wire protocol) + `[s3]` extra + tests    |
| PR-6 | `SqlAlchemyKernelEmitter` + migration 0011 (pricing dedup) + integ |
| PR-7 | `import-linter` contracts + BL5 + CI workflow + AC + QA + close   |

## Surface

| Module                      | Purpose                                                     |
|-----------------------------|-------------------------------------------------------------|
| `kernel_api`                | Pydantic args + `KernelEmitter` Protocol (frozen)           |
| `sqlalchemy_kernel_emitter` | Concrete `SqlAlchemyKernelEmitter` impl (PR-6)              |
| `idempotency`               | `IdempotencyKey` + `compute_content_hash`                   |
| `capabilities`              | `Capability` enum + YAML loader                             |
| `knowledge_envelope`        | `KnowledgeEnvelopeV1` frozen schema                         |
| `trace_context`             | `TraceContext` ContextVar + W0.6 bridge                     |
| `pricing`                   | `compute_cost` + `CostStatus` + `PricingSnapshot` dataclass |
| `artifact_store`            | `ArtifactStorageBackend` Protocol + `LocalFilesystemBackend` + `ObjectStoreBackend` |
| `middleware`                | `PlmTenantMiddleware` (ASGI) + `parse_core_caller` + `cors_allowed_headers` |
| `db`                        | SQLAlchemy 2.0 ORM models + `tenant_scoped_session`         |

## Kernel API (US-W1.0 / AC-2)

`plm_shared.kernel_api.KernelEmitter` is the **frozen** 4-method
Protocol that every product line uses to record events. The
authorised emitter boundary is Core: product lines submit decisions
to Core (e.g. Workbench routes HITL approvals), and Core is the
authorised emitter through `plm-shared`.

### Protocol

```python
from plm_shared.kernel_api import KernelEmitter
from plm_shared.kernel_api import (
    EmitTelemetryArgs,
    EmitGovernanceArgs,
    EmitLlmCallArgs,
    EmitConnectorCallArgs,
)

class KernelEmitter(Protocol):
    def emit_telemetry(self, args: EmitTelemetryArgs) -> None: ...
    def emit_governance(self, args: EmitGovernanceArgs) -> None: ...
    def record_llm_call(self, args: EmitLlmCallArgs) -> None: ...
    def record_connector_call(self, args: EmitConnectorCallArgs) -> None: ...
```

Every method returns `None` on success — including the **silent replay**
path (200 OK: same key + same content). A logical-key collision
(same identity, different content) raises
`plm_shared.sqlalchemy_kernel_emitter.IdempotencyConflict`.

### Args

```python
EmitTelemetryArgs(
    event_type:      str,                  # e.g. "knowledge_call"
    span_id:         str,
    capability:      str,                  # capability tag for the event
    payload:         Dict[str, Any] = {},  # event-specific shape
    idempotency_key: IdempotencyKey,
)

EmitGovernanceArgs(
    governance_event_type: str,            # e.g. "hitl_resolved"
    span_id:               str,
    payload:               Dict[str, Any] = {},
    idempotency_key:       IdempotencyKey,
)

EmitLlmCallArgs(
    span_id:           str,
    model:             str,
    prompt_tokens:     int,
    completion_tokens: int,
    pricing_version:   str,                 # "litellm-live" | "overrides-static" | "unavailable"
    cost_eur:          Optional[float] = None,
    cost_status:       str,                 # "pending" | "calculated" | "unavailable"
    idempotency_key:   IdempotencyKey,
)

EmitConnectorCallArgs(
    span_id:         str,
    connector_id:    str,                   # e.g. "3dx-rest"
    capability:      str,                   # e.g. "read.parts"
    envelope:        Dict[str, Any] = {},
    pricing_version: str,
    cost_eur:        Optional[float] = None,
    cost_status:     str,
    idempotency_key: IdempotencyKey,
)
```

### Concrete emitter — `SqlAlchemyKernelEmitter`

```python
from plm_shared.sqlalchemy_kernel_emitter import (
    IdempotencyConflict,
    SqlAlchemyKernelEmitter,
)
from plm_shared.idempotency import IdempotencyKey, compute_content_hash
from plm_shared.kernel_api import EmitLlmCallArgs

tenant_id = "00000000-0000-0000-0000-000000000000"
emitter = SqlAlchemyKernelEmitter(tenant_id=tenant_id)

payload = {"model": "gpt-4o", "prompt_tokens": 100}
key = IdempotencyKey(
    tenant_id=tenant_id,
    trace_id="trace-X",
    span_id="span-1",
    event_type="llm_call",
    content_hash=compute_content_hash(payload),
)

try:
    emitter.record_llm_call(EmitLlmCallArgs(
        span_id="span-1",
        model="gpt-4o",
        prompt_tokens=100,
        completion_tokens=50,
        pricing_version="litellm-live",
        cost_eur=0.0042,
        cost_status="calculated",
        idempotency_key=key,
    ))
    # 200 — inserted, OR 200 — silent replay (same key + same content)
except IdempotencyConflict:
    # 409 — same logical identity, different content
    raise
```

The emitter:

- Opens a `tenant_scoped_session(tenant_id)` so RLS engages on INSERT
  (migration 0009 — fail-closed for an unset GUC).
- Stamps the `idempotency_key` UNIQUE; on collision, dispatches by
  constraint name (`<table>_idempotency_key_key` → 200 silent replay,
  `uq_<table>_logical` → 409 raise).
- For LLM and connector calls, upserts a `pricing_snapshots` row keyed
  on `(tenant_id, model, snapshot_date)` (migration 0011) and stores
  the FK on the call row. `pricing_version="unavailable"` skips the
  snapshot — the `cost_status` column carries the authoritative signal.

### Backend wiring (deferred to US-CR.0)

`PlmTenantMiddleware` is published in `plm_shared.middleware` but is
NOT yet installed in `02_App/backend/main.py`. US-CR.0 owns the
ordering — it adds `IdentityMiddleware` first, then plumbs
`PlmTenantMiddleware` behind it, then wires CORS so that
`CORSMiddleware` wraps both (failure responses still carry CORS
headers). Conv D leaves the middleware available-but-unwired so
US-CR.0 lands the full ordering in one place.

## Migration chain

| Rev | Subject                                                       |
|-----|---------------------------------------------------------------|
| 0001 | foundational tables `tenants`, `runs` + `quality_level` ENUM + `actor_kind` ENUM |
| 0002 | `telemetry_events` + composite index + UNIQUE `idempotency_key` |
| 0003 | `llm_calls` + `cost_*` columns + UNIQUE `idempotency_key`     |
| 0004 | `connector_calls` + connector-specific fields                |
| 0005 | `artifacts` + storage_uri + retention_until                   |
| 0006 | quality_level + cost_status transition triggers               |
| 0007 | `mcrc_v1_view` (§1-§8 projection)                             |
| 0008 | Postgres roles `plm_kernel_writer` + `plm_migrator`           |
| 0009 | Row-Level Security on the 5 tenant-scoped tables + `plm_migrator BYPASSRLS` |
| 0010 | logical UNIQUE constraints (backs the 200/409 IdempotencyKey contract) |
| 0011 | `pricing_snapshots` dedup table + nullable FKs                |
| 0012 | `plm_migrator` table privileges (SELECT/INSERT/UPDATE/DELETE on the 7 tenant-scoped tables — closes the privilege gap surfaced by the live-Postgres validation event; BYPASSRLS alone does not grant table access) |

Run the chain with `alembic upgrade head` from `02_App/plm-shared/`
(needs `PG_DSN` exported).

## Install (developer)

From the `02_App/backend/` virtualenv:

```bash
cd 02_App/backend
pip install -e ../plm-shared           # base install
pip install -e "../plm-shared[db]"     # + SQLAlchemy / psycopg / alembic
pip install -e "../plm-shared[s3]"     # + boto3 (ObjectStoreBackend)
pip install -e "../plm-shared[db,s3]"  # combined
```

The `02_App/backend/requirements.txt` already lists `-e ../plm-shared`
so a clean `pip install -r requirements.txt` from `02_App/backend/`
picks it up. **`pip install` MUST run from `02_App/backend/`** because
pip resolves the editable path relative to the invocation CWD.

## Tests

```bash
cd 02_App/plm-shared
python -m pytest tests/ -v
```

Audit + migration suites under `tests/audit/` and `tests/migrations/`
are gated on `PG_DSN`. Bring up the dev compose first:

```bash
docker compose -f dev/postgres.yml up -d
export PG_DSN=postgresql+psycopg://tracepulse:tracepulse@localhost:5432/tracepulse_dev
cd 02_App/plm-shared
python -m pytest tests/audit/ tests/migrations/ -v
```

S3-protocol live tests under `tests/test_object_store_backend.py` gate
on `S3_ENDPOINT_URL` (skip-when-unset). Bring up minio (or any
S3-protocol endpoint) and export `S3_ENDPOINT_URL`, `S3_ACCESS_KEY`,
`S3_SECRET_KEY`, `S3_BUCKET`.

## Architecture conformance gate (US-W1.0 / AC-4)

`pyproject.toml` declares one `[tool.importlinter]` Forbidden contract
banning `sqlalchemy` from every plm-shared module except `db` and
`sqlalchemy_kernel_emitter`. Run it from the package root:

```bash
cd 02_App/plm-shared
lint-imports --config pyproject.toml
```

The companion **BL5** rule in
`02_App/backend/scripts/architecture_guardrails.py` (warn-only at
launch) regex-scans for `INSERT INTO {telemetry_events|llm_calls|connector_calls|pricing_snapshots}`
outside `kernel_api` / `sqlalchemy_kernel_emitter` / migrations.
