plm-shared 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. plm_shared-0.1.0/PKG-INFO +264 -0
  2. plm_shared-0.1.0/README.md +241 -0
  3. plm_shared-0.1.0/plm_shared/__init__.py +11 -0
  4. plm_shared-0.1.0/plm_shared/_w1_13_backfill.py +159 -0
  5. plm_shared-0.1.0/plm_shared/agents/__init__.py +14 -0
  6. plm_shared-0.1.0/plm_shared/agents/base_agent.py +110 -0
  7. plm_shared-0.1.0/plm_shared/agents/domain_classifier.py +86 -0
  8. plm_shared-0.1.0/plm_shared/agents/domain_prompts.py +99 -0
  9. plm_shared-0.1.0/plm_shared/agents/raci_extractor.py +80 -0
  10. plm_shared-0.1.0/plm_shared/artifact_publisher.py +342 -0
  11. plm_shared-0.1.0/plm_shared/artifact_store.py +244 -0
  12. plm_shared-0.1.0/plm_shared/autonomy.py +188 -0
  13. plm_shared-0.1.0/plm_shared/autonomy_gating.yaml +73 -0
  14. plm_shared-0.1.0/plm_shared/capabilities.py +64 -0
  15. plm_shared-0.1.0/plm_shared/capabilities.yaml +29 -0
  16. plm_shared-0.1.0/plm_shared/capability_registry/__init__.py +49 -0
  17. plm_shared-0.1.0/plm_shared/capability_registry/types.py +261 -0
  18. plm_shared-0.1.0/plm_shared/capability_registry.yaml +355 -0
  19. plm_shared-0.1.0/plm_shared/cors.py +223 -0
  20. plm_shared-0.1.0/plm_shared/db.py +599 -0
  21. plm_shared-0.1.0/plm_shared/errors/__init__.py +29 -0
  22. plm_shared-0.1.0/plm_shared/errors/envelope.py +32 -0
  23. plm_shared-0.1.0/plm_shared/governance/__init__.py +142 -0
  24. plm_shared-0.1.0/plm_shared/governance/bus.py +155 -0
  25. plm_shared-0.1.0/plm_shared/governance/envelope.py +168 -0
  26. plm_shared-0.1.0/plm_shared/governance/events.py +300 -0
  27. plm_shared-0.1.0/plm_shared/governance/hitl.py +198 -0
  28. plm_shared-0.1.0/plm_shared/governance/invariants.py +224 -0
  29. plm_shared-0.1.0/plm_shared/governance/middleware.py +249 -0
  30. plm_shared-0.1.0/plm_shared/governance/signed_conformance.py +187 -0
  31. plm_shared-0.1.0/plm_shared/idempotency.py +67 -0
  32. plm_shared-0.1.0/plm_shared/identity.py +313 -0
  33. plm_shared-0.1.0/plm_shared/invocation_kinds.py +64 -0
  34. plm_shared-0.1.0/plm_shared/kernel_api.py +169 -0
  35. plm_shared-0.1.0/plm_shared/knowledge/__init__.py +66 -0
  36. plm_shared-0.1.0/plm_shared/knowledge/primitives_v1.py +287 -0
  37. plm_shared-0.1.0/plm_shared/knowledge_envelope.py +45 -0
  38. plm_shared-0.1.0/plm_shared/llm_errors.py +329 -0
  39. plm_shared-0.1.0/plm_shared/mcp/__init__.py +13 -0
  40. plm_shared-0.1.0/plm_shared/mcp/federation/__init__.py +44 -0
  41. plm_shared-0.1.0/plm_shared/mcp/federation/builder.py +416 -0
  42. plm_shared-0.1.0/plm_shared/mcp/federation/manifest_v1.py +134 -0
  43. plm_shared-0.1.0/plm_shared/middleware.py +201 -0
  44. plm_shared-0.1.0/plm_shared/models/__init__.py +24 -0
  45. plm_shared-0.1.0/plm_shared/models/cleansing.py +284 -0
  46. plm_shared-0.1.0/plm_shared/models/db.py +292 -0
  47. plm_shared-0.1.0/plm_shared/models/document_schemas.py +94 -0
  48. plm_shared-0.1.0/plm_shared/models/issue_schemas.py +104 -0
  49. plm_shared-0.1.0/plm_shared/models/parse_schemas.py +76 -0
  50. plm_shared-0.1.0/plm_shared/parsers/__init__.py +15 -0
  51. plm_shared-0.1.0/plm_shared/parsers/bpmn_text.py +152 -0
  52. plm_shared-0.1.0/plm_shared/plm_schemas.py +243 -0
  53. plm_shared-0.1.0/plm_shared/pricing.py +196 -0
  54. plm_shared-0.1.0/plm_shared/protocols/__init__.py +52 -0
  55. plm_shared-0.1.0/plm_shared/protocols/_registry.py +130 -0
  56. plm_shared-0.1.0/plm_shared/protocols/services/__init__.py +8 -0
  57. plm_shared-0.1.0/plm_shared/protocols/services/agent.py +55 -0
  58. plm_shared-0.1.0/plm_shared/protocols/services/database.py +83 -0
  59. plm_shared-0.1.0/plm_shared/protocols/services/feature_flags.py +47 -0
  60. plm_shared-0.1.0/plm_shared/protocols/services/llm.py +142 -0
  61. plm_shared-0.1.0/plm_shared/protocols/services/plan_analysis.py +69 -0
  62. plm_shared-0.1.0/plm_shared/protocols/services/rag.py +67 -0
  63. plm_shared-0.1.0/plm_shared/protocols/services/retry.py +109 -0
  64. plm_shared-0.1.0/plm_shared/protocols/services/settings.py +69 -0
  65. plm_shared-0.1.0/plm_shared/protocols/services/telemetry.py +50 -0
  66. plm_shared-0.1.0/plm_shared/skill_auth.py +161 -0
  67. plm_shared-0.1.0/plm_shared/skill_auth.yaml +97 -0
  68. plm_shared-0.1.0/plm_shared/sqlalchemy_kernel_emitter.py +522 -0
  69. plm_shared-0.1.0/plm_shared/system_context.py +59 -0
  70. plm_shared-0.1.0/plm_shared/telemetry/__init__.py +16 -0
  71. plm_shared-0.1.0/plm_shared/trace/__init__.py +55 -0
  72. plm_shared-0.1.0/plm_shared/trace/conformance_gate.py +145 -0
  73. plm_shared-0.1.0/plm_shared/trace/envelope.py +130 -0
  74. plm_shared-0.1.0/plm_shared/trace/manifest.py +75 -0
  75. plm_shared-0.1.0/plm_shared/trace/promotion_rule.py +92 -0
  76. plm_shared-0.1.0/plm_shared/trace/quality_levels.py +85 -0
  77. plm_shared-0.1.0/plm_shared/trace_context.py +205 -0
  78. plm_shared-0.1.0/plm_shared/utils/__init__.py +7 -0
  79. plm_shared-0.1.0/plm_shared/utils/plm_tools.py +531 -0
  80. plm_shared-0.1.0/plm_shared.egg-info/PKG-INFO +264 -0
  81. plm_shared-0.1.0/plm_shared.egg-info/SOURCES.txt +115 -0
  82. plm_shared-0.1.0/plm_shared.egg-info/dependency_links.txt +1 -0
  83. plm_shared-0.1.0/plm_shared.egg-info/requires.txt +17 -0
  84. plm_shared-0.1.0/plm_shared.egg-info/top_level.txt +1 -0
  85. plm_shared-0.1.0/pyproject.toml +129 -0
  86. plm_shared-0.1.0/setup.cfg +4 -0
  87. plm_shared-0.1.0/tests/test_artifact_publisher.py +239 -0
  88. plm_shared-0.1.0/tests/test_artifact_store.py +90 -0
  89. plm_shared-0.1.0/tests/test_capabilities.py +61 -0
  90. plm_shared-0.1.0/tests/test_capability_registry_types.py +264 -0
  91. plm_shared-0.1.0/tests/test_conformance_gate.py +275 -0
  92. plm_shared-0.1.0/tests/test_cors_helper.py +109 -0
  93. plm_shared-0.1.0/tests/test_db_models.py +176 -0
  94. plm_shared-0.1.0/tests/test_external_trace_envelope.py +182 -0
  95. plm_shared-0.1.0/tests/test_federation_manifest.py +248 -0
  96. plm_shared-0.1.0/tests/test_governance_events.py +361 -0
  97. plm_shared-0.1.0/tests/test_hitl_types.py +175 -0
  98. plm_shared-0.1.0/tests/test_idempotency.py +73 -0
  99. plm_shared-0.1.0/tests/test_identity.py +251 -0
  100. plm_shared-0.1.0/tests/test_kernel_api_models.py +231 -0
  101. plm_shared-0.1.0/tests/test_knowledge_non_mutation.py +91 -0
  102. plm_shared-0.1.0/tests/test_knowledge_primitives_v1.py +215 -0
  103. plm_shared-0.1.0/tests/test_mge_v1.py +236 -0
  104. plm_shared-0.1.0/tests/test_mge_validator.py +271 -0
  105. plm_shared-0.1.0/tests/test_middleware.py +254 -0
  106. plm_shared-0.1.0/tests/test_object_store_backend.py +294 -0
  107. plm_shared-0.1.0/tests/test_pricing.py +186 -0
  108. plm_shared-0.1.0/tests/test_protocols_lifted_modules.py +193 -0
  109. plm_shared-0.1.0/tests/test_protocols_registry.py +99 -0
  110. plm_shared-0.1.0/tests/test_protocols_services_proxies.py +163 -0
  111. plm_shared-0.1.0/tests/test_skill_auth.py +133 -0
  112. plm_shared-0.1.0/tests/test_sqlalchemy_kernel_emitter.py +233 -0
  113. plm_shared-0.1.0/tests/test_trace_context.py +159 -0
  114. plm_shared-0.1.0/tests/test_w1_10_signed_conformance.py +389 -0
  115. plm_shared-0.1.0/tests/test_w1_13_typed_columns.py +247 -0
  116. plm_shared-0.1.0/tests/test_w1_1_skeleton.py +242 -0
  117. plm_shared-0.1.0/tests/test_w1_9_promotion_rule.py +499 -0
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.4
2
+ Name: plm-shared
3
+ Version: 0.1.0
4
+ 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+.
5
+ Author: TracePulse
6
+ License: Proprietary
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pydantic>=2.5.0
10
+ Requires-Dist: pydantic-settings>=2.0
11
+ Requires-Dist: starlette<1.0,>=0.27
12
+ Requires-Dist: PyYAML>=6.0
13
+ Provides-Extra: db
14
+ Requires-Dist: SQLAlchemy>=2.0; extra == "db"
15
+ Requires-Dist: psycopg[binary]>=3.1; extra == "db"
16
+ Requires-Dist: alembic>=1.13; extra == "db"
17
+ Provides-Extra: s3
18
+ Requires-Dist: boto3>=1.34; extra == "s3"
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Requires-Dist: pytest-asyncio; extra == "test"
22
+ Requires-Dist: httpx<1.0,>=0.27; extra == "test"
23
+
24
+ # plm-shared
25
+
26
+ Frozen contracts shared across TracePulse PLM product lines (Core,
27
+ Skill Kernel, Skill Packages, Knowledge, Workbench, Studio,
28
+ Connectors). Owned by US-W1.0; consumed by US-W1.1 → US-W1.13 and
29
+ US-CR.0 → US-CR.13 without modification.
30
+
31
+ US-W1.0 ships in 7 PRs:
32
+
33
+ | PR | Scope |
34
+ |-----|-------------------------------------------------------------------|
35
+ | PR-1 | package skeleton + Pydantic models + IdempotencyKey + Capability |
36
+ | PR-2 | Alembic migrations 0001-0007 + SQLAlchemy ORM + `db.py` |
37
+ | PR-3 | migration 0008 (Postgres roles) + append-only audit suite |
38
+ | PR-4 | migration 0009 (RLS) + `middleware.py` + tenant-isolation audit |
39
+ | PR-5 | `ObjectStoreBackend` (S3 wire protocol) + `[s3]` extra + tests |
40
+ | PR-6 | `SqlAlchemyKernelEmitter` + migration 0011 (pricing dedup) + integ |
41
+ | PR-7 | `import-linter` contracts + BL5 + CI workflow + AC + QA + close |
42
+
43
+ ## Surface
44
+
45
+ | Module | Purpose |
46
+ |-----------------------------|-------------------------------------------------------------|
47
+ | `kernel_api` | Pydantic args + `KernelEmitter` Protocol (frozen) |
48
+ | `sqlalchemy_kernel_emitter` | Concrete `SqlAlchemyKernelEmitter` impl (PR-6) |
49
+ | `idempotency` | `IdempotencyKey` + `compute_content_hash` |
50
+ | `capabilities` | `Capability` enum + YAML loader |
51
+ | `knowledge_envelope` | `KnowledgeEnvelopeV1` frozen schema |
52
+ | `trace_context` | `TraceContext` ContextVar + W0.6 bridge |
53
+ | `pricing` | `compute_cost` + `CostStatus` + `PricingSnapshot` dataclass |
54
+ | `artifact_store` | `ArtifactStorageBackend` Protocol + `LocalFilesystemBackend` + `ObjectStoreBackend` |
55
+ | `middleware` | `PlmTenantMiddleware` (ASGI) + `parse_core_caller` + `cors_allowed_headers` |
56
+ | `db` | SQLAlchemy 2.0 ORM models + `tenant_scoped_session` |
57
+
58
+ ## Kernel API (US-W1.0 / AC-2)
59
+
60
+ `plm_shared.kernel_api.KernelEmitter` is the **frozen** 4-method
61
+ Protocol that every product line uses to record events. The
62
+ authorised emitter boundary is Core: product lines submit decisions
63
+ to Core (e.g. Workbench routes HITL approvals), and Core is the
64
+ authorised emitter through `plm-shared`.
65
+
66
+ ### Protocol
67
+
68
+ ```python
69
+ from plm_shared.kernel_api import KernelEmitter
70
+ from plm_shared.kernel_api import (
71
+ EmitTelemetryArgs,
72
+ EmitGovernanceArgs,
73
+ EmitLlmCallArgs,
74
+ EmitConnectorCallArgs,
75
+ )
76
+
77
+ class KernelEmitter(Protocol):
78
+ def emit_telemetry(self, args: EmitTelemetryArgs) -> None: ...
79
+ def emit_governance(self, args: EmitGovernanceArgs) -> None: ...
80
+ def record_llm_call(self, args: EmitLlmCallArgs) -> None: ...
81
+ def record_connector_call(self, args: EmitConnectorCallArgs) -> None: ...
82
+ ```
83
+
84
+ Every method returns `None` on success — including the **silent replay**
85
+ path (200 OK: same key + same content). A logical-key collision
86
+ (same identity, different content) raises
87
+ `plm_shared.sqlalchemy_kernel_emitter.IdempotencyConflict`.
88
+
89
+ ### Args
90
+
91
+ ```python
92
+ EmitTelemetryArgs(
93
+ event_type: str, # e.g. "knowledge_call"
94
+ span_id: str,
95
+ capability: str, # capability tag for the event
96
+ payload: Dict[str, Any] = {}, # event-specific shape
97
+ idempotency_key: IdempotencyKey,
98
+ )
99
+
100
+ EmitGovernanceArgs(
101
+ governance_event_type: str, # e.g. "hitl_resolved"
102
+ span_id: str,
103
+ payload: Dict[str, Any] = {},
104
+ idempotency_key: IdempotencyKey,
105
+ )
106
+
107
+ EmitLlmCallArgs(
108
+ span_id: str,
109
+ model: str,
110
+ prompt_tokens: int,
111
+ completion_tokens: int,
112
+ pricing_version: str, # "litellm-live" | "overrides-static" | "unavailable"
113
+ cost_eur: Optional[float] = None,
114
+ cost_status: str, # "pending" | "calculated" | "unavailable"
115
+ idempotency_key: IdempotencyKey,
116
+ )
117
+
118
+ EmitConnectorCallArgs(
119
+ span_id: str,
120
+ connector_id: str, # e.g. "3dx-rest"
121
+ capability: str, # e.g. "read.parts"
122
+ envelope: Dict[str, Any] = {},
123
+ pricing_version: str,
124
+ cost_eur: Optional[float] = None,
125
+ cost_status: str,
126
+ idempotency_key: IdempotencyKey,
127
+ )
128
+ ```
129
+
130
+ ### Concrete emitter — `SqlAlchemyKernelEmitter`
131
+
132
+ ```python
133
+ from plm_shared.sqlalchemy_kernel_emitter import (
134
+ IdempotencyConflict,
135
+ SqlAlchemyKernelEmitter,
136
+ )
137
+ from plm_shared.idempotency import IdempotencyKey, compute_content_hash
138
+ from plm_shared.kernel_api import EmitLlmCallArgs
139
+
140
+ tenant_id = "00000000-0000-0000-0000-000000000000"
141
+ emitter = SqlAlchemyKernelEmitter(tenant_id=tenant_id)
142
+
143
+ payload = {"model": "gpt-4o", "prompt_tokens": 100}
144
+ key = IdempotencyKey(
145
+ tenant_id=tenant_id,
146
+ trace_id="trace-X",
147
+ span_id="span-1",
148
+ event_type="llm_call",
149
+ content_hash=compute_content_hash(payload),
150
+ )
151
+
152
+ try:
153
+ emitter.record_llm_call(EmitLlmCallArgs(
154
+ span_id="span-1",
155
+ model="gpt-4o",
156
+ prompt_tokens=100,
157
+ completion_tokens=50,
158
+ pricing_version="litellm-live",
159
+ cost_eur=0.0042,
160
+ cost_status="calculated",
161
+ idempotency_key=key,
162
+ ))
163
+ # 200 — inserted, OR 200 — silent replay (same key + same content)
164
+ except IdempotencyConflict:
165
+ # 409 — same logical identity, different content
166
+ raise
167
+ ```
168
+
169
+ The emitter:
170
+
171
+ - Opens a `tenant_scoped_session(tenant_id)` so RLS engages on INSERT
172
+ (migration 0009 — fail-closed for an unset GUC).
173
+ - Stamps the `idempotency_key` UNIQUE; on collision, dispatches by
174
+ constraint name (`<table>_idempotency_key_key` → 200 silent replay,
175
+ `uq_<table>_logical` → 409 raise).
176
+ - For LLM and connector calls, upserts a `pricing_snapshots` row keyed
177
+ on `(tenant_id, model, snapshot_date)` (migration 0011) and stores
178
+ the FK on the call row. `pricing_version="unavailable"` skips the
179
+ snapshot — the `cost_status` column carries the authoritative signal.
180
+
181
+ ### Backend wiring (deferred to US-CR.0)
182
+
183
+ `PlmTenantMiddleware` is published in `plm_shared.middleware` but is
184
+ NOT yet installed in `02_App/backend/main.py`. US-CR.0 owns the
185
+ ordering — it adds `IdentityMiddleware` first, then plumbs
186
+ `PlmTenantMiddleware` behind it, then wires CORS so that
187
+ `CORSMiddleware` wraps both (failure responses still carry CORS
188
+ headers). Conv D leaves the middleware available-but-unwired so
189
+ US-CR.0 lands the full ordering in one place.
190
+
191
+ ## Migration chain
192
+
193
+ | Rev | Subject |
194
+ |-----|---------------------------------------------------------------|
195
+ | 0001 | foundational tables `tenants`, `runs` + `quality_level` ENUM + `actor_kind` ENUM |
196
+ | 0002 | `telemetry_events` + composite index + UNIQUE `idempotency_key` |
197
+ | 0003 | `llm_calls` + `cost_*` columns + UNIQUE `idempotency_key` |
198
+ | 0004 | `connector_calls` + connector-specific fields |
199
+ | 0005 | `artifacts` + storage_uri + retention_until |
200
+ | 0006 | quality_level + cost_status transition triggers |
201
+ | 0007 | `mcrc_v1_view` (§1-§8 projection) |
202
+ | 0008 | Postgres roles `plm_kernel_writer` + `plm_migrator` |
203
+ | 0009 | Row-Level Security on the 5 tenant-scoped tables + `plm_migrator BYPASSRLS` |
204
+ | 0010 | logical UNIQUE constraints (backs the 200/409 IdempotencyKey contract) |
205
+ | 0011 | `pricing_snapshots` dedup table + nullable FKs |
206
+ | 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) |
207
+
208
+ Run the chain with `alembic upgrade head` from `02_App/plm-shared/`
209
+ (needs `PG_DSN` exported).
210
+
211
+ ## Install (developer)
212
+
213
+ From the `02_App/backend/` virtualenv:
214
+
215
+ ```bash
216
+ cd 02_App/backend
217
+ pip install -e ../plm-shared # base install
218
+ pip install -e "../plm-shared[db]" # + SQLAlchemy / psycopg / alembic
219
+ pip install -e "../plm-shared[s3]" # + boto3 (ObjectStoreBackend)
220
+ pip install -e "../plm-shared[db,s3]" # combined
221
+ ```
222
+
223
+ The `02_App/backend/requirements.txt` already lists `-e ../plm-shared`
224
+ so a clean `pip install -r requirements.txt` from `02_App/backend/`
225
+ picks it up. **`pip install` MUST run from `02_App/backend/`** because
226
+ pip resolves the editable path relative to the invocation CWD.
227
+
228
+ ## Tests
229
+
230
+ ```bash
231
+ cd 02_App/plm-shared
232
+ python -m pytest tests/ -v
233
+ ```
234
+
235
+ Audit + migration suites under `tests/audit/` and `tests/migrations/`
236
+ are gated on `PG_DSN`. Bring up the dev compose first:
237
+
238
+ ```bash
239
+ docker compose -f dev/postgres.yml up -d
240
+ export PG_DSN=postgresql+psycopg://tracepulse:tracepulse@localhost:5432/tracepulse_dev
241
+ cd 02_App/plm-shared
242
+ python -m pytest tests/audit/ tests/migrations/ -v
243
+ ```
244
+
245
+ S3-protocol live tests under `tests/test_object_store_backend.py` gate
246
+ on `S3_ENDPOINT_URL` (skip-when-unset). Bring up minio (or any
247
+ S3-protocol endpoint) and export `S3_ENDPOINT_URL`, `S3_ACCESS_KEY`,
248
+ `S3_SECRET_KEY`, `S3_BUCKET`.
249
+
250
+ ## Architecture conformance gate (US-W1.0 / AC-4)
251
+
252
+ `pyproject.toml` declares one `[tool.importlinter]` Forbidden contract
253
+ banning `sqlalchemy` from every plm-shared module except `db` and
254
+ `sqlalchemy_kernel_emitter`. Run it from the package root:
255
+
256
+ ```bash
257
+ cd 02_App/plm-shared
258
+ lint-imports --config pyproject.toml
259
+ ```
260
+
261
+ The companion **BL5** rule in
262
+ `02_App/backend/scripts/architecture_guardrails.py` (warn-only at
263
+ launch) regex-scans for `INSERT INTO {telemetry_events|llm_calls|connector_calls|pricing_snapshots}`
264
+ outside `kernel_api` / `sqlalchemy_kernel_emitter` / migrations.
@@ -0,0 +1,241 @@
1
+ # plm-shared
2
+
3
+ Frozen contracts shared across TracePulse PLM product lines (Core,
4
+ Skill Kernel, Skill Packages, Knowledge, Workbench, Studio,
5
+ Connectors). Owned by US-W1.0; consumed by US-W1.1 → US-W1.13 and
6
+ US-CR.0 → US-CR.13 without modification.
7
+
8
+ US-W1.0 ships in 7 PRs:
9
+
10
+ | PR | Scope |
11
+ |-----|-------------------------------------------------------------------|
12
+ | PR-1 | package skeleton + Pydantic models + IdempotencyKey + Capability |
13
+ | PR-2 | Alembic migrations 0001-0007 + SQLAlchemy ORM + `db.py` |
14
+ | PR-3 | migration 0008 (Postgres roles) + append-only audit suite |
15
+ | PR-4 | migration 0009 (RLS) + `middleware.py` + tenant-isolation audit |
16
+ | PR-5 | `ObjectStoreBackend` (S3 wire protocol) + `[s3]` extra + tests |
17
+ | PR-6 | `SqlAlchemyKernelEmitter` + migration 0011 (pricing dedup) + integ |
18
+ | PR-7 | `import-linter` contracts + BL5 + CI workflow + AC + QA + close |
19
+
20
+ ## Surface
21
+
22
+ | Module | Purpose |
23
+ |-----------------------------|-------------------------------------------------------------|
24
+ | `kernel_api` | Pydantic args + `KernelEmitter` Protocol (frozen) |
25
+ | `sqlalchemy_kernel_emitter` | Concrete `SqlAlchemyKernelEmitter` impl (PR-6) |
26
+ | `idempotency` | `IdempotencyKey` + `compute_content_hash` |
27
+ | `capabilities` | `Capability` enum + YAML loader |
28
+ | `knowledge_envelope` | `KnowledgeEnvelopeV1` frozen schema |
29
+ | `trace_context` | `TraceContext` ContextVar + W0.6 bridge |
30
+ | `pricing` | `compute_cost` + `CostStatus` + `PricingSnapshot` dataclass |
31
+ | `artifact_store` | `ArtifactStorageBackend` Protocol + `LocalFilesystemBackend` + `ObjectStoreBackend` |
32
+ | `middleware` | `PlmTenantMiddleware` (ASGI) + `parse_core_caller` + `cors_allowed_headers` |
33
+ | `db` | SQLAlchemy 2.0 ORM models + `tenant_scoped_session` |
34
+
35
+ ## Kernel API (US-W1.0 / AC-2)
36
+
37
+ `plm_shared.kernel_api.KernelEmitter` is the **frozen** 4-method
38
+ Protocol that every product line uses to record events. The
39
+ authorised emitter boundary is Core: product lines submit decisions
40
+ to Core (e.g. Workbench routes HITL approvals), and Core is the
41
+ authorised emitter through `plm-shared`.
42
+
43
+ ### Protocol
44
+
45
+ ```python
46
+ from plm_shared.kernel_api import KernelEmitter
47
+ from plm_shared.kernel_api import (
48
+ EmitTelemetryArgs,
49
+ EmitGovernanceArgs,
50
+ EmitLlmCallArgs,
51
+ EmitConnectorCallArgs,
52
+ )
53
+
54
+ class KernelEmitter(Protocol):
55
+ def emit_telemetry(self, args: EmitTelemetryArgs) -> None: ...
56
+ def emit_governance(self, args: EmitGovernanceArgs) -> None: ...
57
+ def record_llm_call(self, args: EmitLlmCallArgs) -> None: ...
58
+ def record_connector_call(self, args: EmitConnectorCallArgs) -> None: ...
59
+ ```
60
+
61
+ Every method returns `None` on success — including the **silent replay**
62
+ path (200 OK: same key + same content). A logical-key collision
63
+ (same identity, different content) raises
64
+ `plm_shared.sqlalchemy_kernel_emitter.IdempotencyConflict`.
65
+
66
+ ### Args
67
+
68
+ ```python
69
+ EmitTelemetryArgs(
70
+ event_type: str, # e.g. "knowledge_call"
71
+ span_id: str,
72
+ capability: str, # capability tag for the event
73
+ payload: Dict[str, Any] = {}, # event-specific shape
74
+ idempotency_key: IdempotencyKey,
75
+ )
76
+
77
+ EmitGovernanceArgs(
78
+ governance_event_type: str, # e.g. "hitl_resolved"
79
+ span_id: str,
80
+ payload: Dict[str, Any] = {},
81
+ idempotency_key: IdempotencyKey,
82
+ )
83
+
84
+ EmitLlmCallArgs(
85
+ span_id: str,
86
+ model: str,
87
+ prompt_tokens: int,
88
+ completion_tokens: int,
89
+ pricing_version: str, # "litellm-live" | "overrides-static" | "unavailable"
90
+ cost_eur: Optional[float] = None,
91
+ cost_status: str, # "pending" | "calculated" | "unavailable"
92
+ idempotency_key: IdempotencyKey,
93
+ )
94
+
95
+ EmitConnectorCallArgs(
96
+ span_id: str,
97
+ connector_id: str, # e.g. "3dx-rest"
98
+ capability: str, # e.g. "read.parts"
99
+ envelope: Dict[str, Any] = {},
100
+ pricing_version: str,
101
+ cost_eur: Optional[float] = None,
102
+ cost_status: str,
103
+ idempotency_key: IdempotencyKey,
104
+ )
105
+ ```
106
+
107
+ ### Concrete emitter — `SqlAlchemyKernelEmitter`
108
+
109
+ ```python
110
+ from plm_shared.sqlalchemy_kernel_emitter import (
111
+ IdempotencyConflict,
112
+ SqlAlchemyKernelEmitter,
113
+ )
114
+ from plm_shared.idempotency import IdempotencyKey, compute_content_hash
115
+ from plm_shared.kernel_api import EmitLlmCallArgs
116
+
117
+ tenant_id = "00000000-0000-0000-0000-000000000000"
118
+ emitter = SqlAlchemyKernelEmitter(tenant_id=tenant_id)
119
+
120
+ payload = {"model": "gpt-4o", "prompt_tokens": 100}
121
+ key = IdempotencyKey(
122
+ tenant_id=tenant_id,
123
+ trace_id="trace-X",
124
+ span_id="span-1",
125
+ event_type="llm_call",
126
+ content_hash=compute_content_hash(payload),
127
+ )
128
+
129
+ try:
130
+ emitter.record_llm_call(EmitLlmCallArgs(
131
+ span_id="span-1",
132
+ model="gpt-4o",
133
+ prompt_tokens=100,
134
+ completion_tokens=50,
135
+ pricing_version="litellm-live",
136
+ cost_eur=0.0042,
137
+ cost_status="calculated",
138
+ idempotency_key=key,
139
+ ))
140
+ # 200 — inserted, OR 200 — silent replay (same key + same content)
141
+ except IdempotencyConflict:
142
+ # 409 — same logical identity, different content
143
+ raise
144
+ ```
145
+
146
+ The emitter:
147
+
148
+ - Opens a `tenant_scoped_session(tenant_id)` so RLS engages on INSERT
149
+ (migration 0009 — fail-closed for an unset GUC).
150
+ - Stamps the `idempotency_key` UNIQUE; on collision, dispatches by
151
+ constraint name (`<table>_idempotency_key_key` → 200 silent replay,
152
+ `uq_<table>_logical` → 409 raise).
153
+ - For LLM and connector calls, upserts a `pricing_snapshots` row keyed
154
+ on `(tenant_id, model, snapshot_date)` (migration 0011) and stores
155
+ the FK on the call row. `pricing_version="unavailable"` skips the
156
+ snapshot — the `cost_status` column carries the authoritative signal.
157
+
158
+ ### Backend wiring (deferred to US-CR.0)
159
+
160
+ `PlmTenantMiddleware` is published in `plm_shared.middleware` but is
161
+ NOT yet installed in `02_App/backend/main.py`. US-CR.0 owns the
162
+ ordering — it adds `IdentityMiddleware` first, then plumbs
163
+ `PlmTenantMiddleware` behind it, then wires CORS so that
164
+ `CORSMiddleware` wraps both (failure responses still carry CORS
165
+ headers). Conv D leaves the middleware available-but-unwired so
166
+ US-CR.0 lands the full ordering in one place.
167
+
168
+ ## Migration chain
169
+
170
+ | Rev | Subject |
171
+ |-----|---------------------------------------------------------------|
172
+ | 0001 | foundational tables `tenants`, `runs` + `quality_level` ENUM + `actor_kind` ENUM |
173
+ | 0002 | `telemetry_events` + composite index + UNIQUE `idempotency_key` |
174
+ | 0003 | `llm_calls` + `cost_*` columns + UNIQUE `idempotency_key` |
175
+ | 0004 | `connector_calls` + connector-specific fields |
176
+ | 0005 | `artifacts` + storage_uri + retention_until |
177
+ | 0006 | quality_level + cost_status transition triggers |
178
+ | 0007 | `mcrc_v1_view` (§1-§8 projection) |
179
+ | 0008 | Postgres roles `plm_kernel_writer` + `plm_migrator` |
180
+ | 0009 | Row-Level Security on the 5 tenant-scoped tables + `plm_migrator BYPASSRLS` |
181
+ | 0010 | logical UNIQUE constraints (backs the 200/409 IdempotencyKey contract) |
182
+ | 0011 | `pricing_snapshots` dedup table + nullable FKs |
183
+ | 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) |
184
+
185
+ Run the chain with `alembic upgrade head` from `02_App/plm-shared/`
186
+ (needs `PG_DSN` exported).
187
+
188
+ ## Install (developer)
189
+
190
+ From the `02_App/backend/` virtualenv:
191
+
192
+ ```bash
193
+ cd 02_App/backend
194
+ pip install -e ../plm-shared # base install
195
+ pip install -e "../plm-shared[db]" # + SQLAlchemy / psycopg / alembic
196
+ pip install -e "../plm-shared[s3]" # + boto3 (ObjectStoreBackend)
197
+ pip install -e "../plm-shared[db,s3]" # combined
198
+ ```
199
+
200
+ The `02_App/backend/requirements.txt` already lists `-e ../plm-shared`
201
+ so a clean `pip install -r requirements.txt` from `02_App/backend/`
202
+ picks it up. **`pip install` MUST run from `02_App/backend/`** because
203
+ pip resolves the editable path relative to the invocation CWD.
204
+
205
+ ## Tests
206
+
207
+ ```bash
208
+ cd 02_App/plm-shared
209
+ python -m pytest tests/ -v
210
+ ```
211
+
212
+ Audit + migration suites under `tests/audit/` and `tests/migrations/`
213
+ are gated on `PG_DSN`. Bring up the dev compose first:
214
+
215
+ ```bash
216
+ docker compose -f dev/postgres.yml up -d
217
+ export PG_DSN=postgresql+psycopg://tracepulse:tracepulse@localhost:5432/tracepulse_dev
218
+ cd 02_App/plm-shared
219
+ python -m pytest tests/audit/ tests/migrations/ -v
220
+ ```
221
+
222
+ S3-protocol live tests under `tests/test_object_store_backend.py` gate
223
+ on `S3_ENDPOINT_URL` (skip-when-unset). Bring up minio (or any
224
+ S3-protocol endpoint) and export `S3_ENDPOINT_URL`, `S3_ACCESS_KEY`,
225
+ `S3_SECRET_KEY`, `S3_BUCKET`.
226
+
227
+ ## Architecture conformance gate (US-W1.0 / AC-4)
228
+
229
+ `pyproject.toml` declares one `[tool.importlinter]` Forbidden contract
230
+ banning `sqlalchemy` from every plm-shared module except `db` and
231
+ `sqlalchemy_kernel_emitter`. Run it from the package root:
232
+
233
+ ```bash
234
+ cd 02_App/plm-shared
235
+ lint-imports --config pyproject.toml
236
+ ```
237
+
238
+ The companion **BL5** rule in
239
+ `02_App/backend/scripts/architecture_guardrails.py` (warn-only at
240
+ launch) regex-scans for `INSERT INTO {telemetry_events|llm_calls|connector_calls|pricing_snapshots}`
241
+ outside `kernel_api` / `sqlalchemy_kernel_emitter` / migrations.
@@ -0,0 +1,11 @@
1
+ """plm-shared — frozen contracts shared across TracePulse PLM product lines.
2
+
3
+ US-W1.0 (PR-1): the modules in this package publish the Kernel emission
4
+ API surface, idempotency key, capability registry, knowledge envelope,
5
+ trace context, pricing helpers, and artifact-store protocol consumed by
6
+ the rest of Wave 1. Models in `kernel_api`, `idempotency`,
7
+ `knowledge_envelope`, and `capabilities` are frozen — never modified by
8
+ downstream stories — so the rest of the wave can be implemented in
9
+ parallel without contract drift.
10
+ """
11
+ __version__ = "0.1.0"
@@ -0,0 +1,159 @@
1
+ """US-W1.13 / Conv I — idempotent backfill of Wave 0 metadata to typed columns.
2
+
3
+ Private module implementing the actual UPDATE statements + summary
4
+ shape used by the W1.13 backfill. Surfaced at the CLI level by
5
+ ``02_App/plm-shared/scripts/backfill_w1_13_typed_columns.py``; the
6
+ audit test (`tests/audit/test_w1_13_backfill.py`) imports
7
+ ``run_backfill`` from this module directly.
8
+
9
+ Walks every `telemetry_events` row whose typed column is NULL,
10
+ reads the equivalent value from `payload` JSONB, writes it into
11
+ the new column. Re-running on a fully-backfilled DB yields zero
12
+ further updates (W1.13 §AC-7 — idempotency).
13
+
14
+ Runs as `plm_migrator` per migration 0008 + Decision #16:
15
+ `plm_kernel_writer` is REVOKED UPDATE on `telemetry_events` per
16
+ the append-only rule, and a backfill IS an UPDATE. The script
17
+ issues `SET LOCAL ROLE plm_migrator` inside each transaction so
18
+ even a superuser-mode invocation stays faithful to the production
19
+ privilege contract.
20
+
21
+ llm_calls.pricing_source backfill is NOT covered here — the column
22
+ lives in PG, but pre-0016 telemetry-side rows that carried
23
+ `pricing_source` rode in `telemetry_events.payload`, not
24
+ `llm_calls.payload` (`llm_calls` has no payload JSONB column).
25
+ The legacy backend's SQLite `agent_invocations.metadata.pricing_source`
26
+ column is handled by `02_App/backend/database.py`'s startup
27
+ ALTER-TABLE pattern, not by this PG backfill.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from dataclasses import dataclass
33
+ from typing import Optional
34
+
35
+ from sqlalchemy import create_engine, text
36
+ from sqlalchemy.engine import Engine
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BackfillSummary:
41
+ """Per-column rowcounts from a single backfill invocation.
42
+
43
+ A second invocation against a fully-backfilled DB returns
44
+ all-zeros — that's the AC-7 idempotency contract.
45
+ """
46
+
47
+ updated_invocation_kind: int
48
+ updated_is_eval: int
49
+ updated_parent_invocation_id: int
50
+ updated_root_invocation_id: int
51
+ updated_system_context: int
52
+
53
+ @property
54
+ def total(self) -> int:
55
+ return (
56
+ self.updated_invocation_kind
57
+ + self.updated_is_eval
58
+ + self.updated_parent_invocation_id
59
+ + self.updated_root_invocation_id
60
+ + self.updated_system_context
61
+ )
62
+
63
+
64
+ _INVOCATION_KIND_VALUES = ("user", "system", "hidden_retry", "background")
65
+
66
+
67
+ _BACKFILL_INVOCATION_KIND = text("""
68
+ UPDATE telemetry_events
69
+ SET invocation_kind = (payload->>'invocation_kind')::invocation_kind_v1
70
+ WHERE invocation_kind IS NULL
71
+ AND payload ? 'invocation_kind'
72
+ AND payload->>'invocation_kind' = ANY(:valid_kinds)
73
+ """)
74
+
75
+ # `is_eval` is non-nullable with default `false`. The migration
76
+ # already populated every legacy row to `false` via the server
77
+ # default; the only useful UPDATE is for rows whose payload
78
+ # explicitly carries `is_eval=true`. Idempotent: re-run on a
79
+ # flipped row no-ops because the WHERE filters it out.
80
+ _BACKFILL_IS_EVAL = text("""
81
+ UPDATE telemetry_events
82
+ SET is_eval = true
83
+ WHERE is_eval = false
84
+ AND payload ? 'is_eval'
85
+ AND lower(payload->>'is_eval') IN ('true', '1')
86
+ """)
87
+
88
+ _BACKFILL_PARENT_INVOCATION_ID = text("""
89
+ UPDATE telemetry_events
90
+ SET parent_invocation_id = (payload->>'parent_invocation_id')::uuid
91
+ WHERE parent_invocation_id IS NULL
92
+ AND payload ? 'parent_invocation_id'
93
+ AND payload->>'parent_invocation_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
94
+ """)
95
+
96
+ _BACKFILL_ROOT_INVOCATION_ID = text("""
97
+ UPDATE telemetry_events
98
+ SET root_invocation_id = (payload->>'root_invocation_id')::uuid
99
+ WHERE root_invocation_id IS NULL
100
+ AND payload ? 'root_invocation_id'
101
+ AND payload->>'root_invocation_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
102
+ """)
103
+
104
+ _BACKFILL_SYSTEM_CONTEXT = text("""
105
+ UPDATE telemetry_events
106
+ SET system_context = payload->>'system_context'
107
+ WHERE system_context IS NULL
108
+ AND payload ? 'system_context'
109
+ """)
110
+
111
+
112
+ def run_backfill(engine: Engine) -> BackfillSummary:
113
+ """Apply every per-column backfill UPDATE; return a counts summary.
114
+
115
+ Each UPDATE runs in its own transaction so a partial failure
116
+ on one column doesn't roll back successful work on previous
117
+ columns. `SET LOCAL ROLE plm_migrator` is issued inside each
118
+ transaction so the privilege check happens even when the
119
+ connecting user is the dev superuser. Idempotent — every
120
+ WHERE clause filters rows that already have the typed column
121
+ populated.
122
+ """
123
+ counts = {}
124
+ for label, statement in (
125
+ ("invocation_kind", _BACKFILL_INVOCATION_KIND),
126
+ ("is_eval", _BACKFILL_IS_EVAL),
127
+ ("parent_invocation_id", _BACKFILL_PARENT_INVOCATION_ID),
128
+ ("root_invocation_id", _BACKFILL_ROOT_INVOCATION_ID),
129
+ ("system_context", _BACKFILL_SYSTEM_CONTEXT),
130
+ ):
131
+ with engine.begin() as conn:
132
+ conn.execute(text("SET LOCAL ROLE plm_migrator"))
133
+ params = (
134
+ {"valid_kinds": list(_INVOCATION_KIND_VALUES)}
135
+ if label == "invocation_kind"
136
+ else {}
137
+ )
138
+ result = conn.execute(statement, params)
139
+ counts[label] = result.rowcount or 0
140
+
141
+ return BackfillSummary(
142
+ updated_invocation_kind=counts["invocation_kind"],
143
+ updated_is_eval=counts["is_eval"],
144
+ updated_parent_invocation_id=counts["parent_invocation_id"],
145
+ updated_root_invocation_id=counts["root_invocation_id"],
146
+ updated_system_context=counts["system_context"],
147
+ )
148
+
149
+
150
+ def build_engine_from_env(dsn: Optional[str] = None) -> Engine:
151
+ """Resolve `PG_DSN` from env (or the explicit override) and bind."""
152
+ url = (dsn or os.environ.get("PG_DSN", "")).strip()
153
+ if not url:
154
+ raise RuntimeError(
155
+ "PG_DSN is not set. Export it before running the W1.13 "
156
+ "backfill (e.g. "
157
+ "`postgresql+psycopg://tracepulse:tracepulse@localhost:5432/tracepulse_dev`)."
158
+ )
159
+ return create_engine(url, future=True)
@@ -0,0 +1,14 @@
1
+ """Lifted ``agents/`` modules (Wave 6.7 Conv A).
2
+
3
+ Per Decision #177 — these are not Protocol indirections; they are the
4
+ **canonical** homes of ``BaseAgent`` (abstract base class for the multi-
5
+ agent PDF→BPMN chain), the domain classifier / RACI extractor, and the
6
+ domain-specific prompt prefixes. Lifted from
7
+ ``02_App/backend/agents/`` via ``git mv`` so sibling packages
8
+ (:mod:`plm_skill_packages`) can import them without depending on
9
+ ``02_App/backend/``.
10
+
11
+ The legacy paths under ``02_App/backend/agents/`` remain as thin
12
+ re-export shims for the duration of the DC-1 90-day soak, matching the
13
+ W1.4 RunTaskTracker + Wave 6.5 Conv A/B precedent.
14
+ """