Metadata-Version: 2.4
Name: plm-shared
Version: 1.0.3
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
Requires-Dist: fastapi>=0.110
Requires-Dist: SQLAlchemy>=2.0
Requires-Dist: sqlmodel>=0.0.16
Requires-Dist: PyPDF2>=3.0.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: docs
Requires-Dist: pdoc>=14.0; extra == "docs"
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

> **Standalone repo since 2026-06-30 (FTR-924).** Extracted from
> `plm-engine-core/shared/` into `github.com/plm-engine/plm-shared`.
> See [docs/POST-SPLIT.md](docs/POST-SPLIT.md) for the split context,
> India onboarding pointer, and PRD §6.1 cross-reference.
>
> **Integrating against plm-shared?** Start with the consumer guide
> at [docs/federation-contract-guide.md](docs/federation-contract-guide.md)
> and the runnable e2e example at
> [examples/consume_plm_shared.py](examples/consume_plm_shared.py).

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.

The Wave 1 origin story (7 PRs that built US-W1.0 pre-extraction) is
preserved in this repo's commit history + the retrospective
[RELEASE-NOTES-v1.0.0.md](RELEASE-NOTES-v1.0.0.md). Post-extraction
(FTR-924, 2026-06-30) the shipping model is release-please + PyPI:
see [CHANGELOG.md](CHANGELOG.md) for every published version.

## 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

Frozen head: **0024**. Full 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 (BYPASSRLS alone does not grant table access) |
| 0013 | `audit_log` table for identity events (US-CR.0 PR-2)          |
| 0014 | cost-canonicalisation columns on LLM + connector calls (US-W1.12 Conv H) |
| 0015 | artifact offload schema extension — prompt/completion refs (US-W1.5 Conv I) |
| 0016 | promote Wave 0 metadata to typed columns on `telemetry_events` (US-W1.13 Conv I) |
| 0017 | `hitl_tickets` HITL Control Plane persistence (US-CR.13)      |
| 0018 | `hitl_ticket_comments` HITL comment persistence (US-CR.13)    |
| 0019 | `connector_access_index` Knowledge curation index (FTR-603 Wave 5 Conv C) |
| 0020 | `mcrc_v1_view` widening — pricing + rate source projections (Wave 5 Conv D / FTR-605) |
| 0021 | widen `runs.outcome` enum to include `awaiting_hitl` (US-DC.2 Wave 6 Conv B) |
| 0022 | realised-baseline correction — DC-3 telemetry columns (US-DC.3 Wave 6 Conv D) |
| 0023 | telemetry workload indexes for DC-5 read-path (US-DC.5 Wave 6 Conv D) |
| 0024 | agent-invocations rename + realised-baseline correction (US-DC.4 Wave 6 Conv E) |

Run the chain with `alembic upgrade head` from this repo root (needs
`PG_DSN` exported).

## Install (developer)

In a workspace context (uv workspace at `c:\AIxPLM\plm-engine\`),
`uv sync` resolves `plm-shared` editable as a workspace member. As
an external consumer:

```bash
pip install plm-shared          # base install — from PyPI
pip install 'plm-shared[db]'    # + SQLAlchemy / psycopg / alembic
pip install 'plm-shared[s3]'    # + boto3 (ObjectStoreBackend)
pip install 'plm-shared[db,s3]' # combined
```

Pin per D3 = Option A (additive→minor, breaking→major): `plm-shared = "^1.0"`
in your sibling's `pyproject.toml`. See [docs/POST-SPLIT.md](docs/POST-SPLIT.md).

## Tests

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

Audit + migration suites under `tests/audit/` and `tests/migrations/`
are gated on `PG_DSN`. CI brings up the matching Postgres service in
`.github/workflows/audit-pg.yml` (postgres:15.7-alpine, `tracepulse_ci`
DB). Locally, bring Postgres up with plain `docker run` (the workspace
also provides `dev/postgres.yml`, but the compose file is only present
inside the plm-engine workspace clone, not this standalone repo):

```bash
# tracepulse_ci is the canonical DB name — matches CI (audit-pg.yml
# + example-smoke) and the local dev-compose file. Keeping one name
# means reproducing a CI failure locally needs no path translation.
docker run -d --name plm-shared-pg \
  -e POSTGRES_USER=tracepulse \
  -e POSTGRES_PASSWORD=tracepulse \
  -e POSTGRES_DB=tracepulse_ci \
  -p 5432:5432 \
  postgres:15.7-alpine
export PG_DSN=postgresql+psycopg://tracepulse:tracepulse@localhost:5432/tracepulse_ci
alembic upgrade head
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 repo root:

```bash
cd 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.
