lcp-reference-platform 1.0.0__tar.gz → 1.0.2__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 (52) hide show
  1. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/.gitignore +6 -0
  2. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/PKG-INFO +1 -1
  3. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/README.md +73 -2
  4. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/__init__.py +1 -1
  5. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/admin.py +21 -0
  6. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/matching.py +55 -4
  7. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/messages.py +4 -1
  8. lcp_reference_platform-1.0.2/lcp_platform/observability.py +23 -0
  9. lcp_reference_platform-1.0.2/lcp_platform/offer_extensions.py +151 -0
  10. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/postgres.py +30 -8
  11. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/router.py +318 -41
  12. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/service.py +73 -29
  13. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/storage.py +1117 -28
  14. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/validation.py +70 -22
  15. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/wsgi.py +16 -3
  16. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/pyproject.toml +1 -1
  17. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/tests/test_new_flows.py +251 -1
  18. lcp_reference_platform-1.0.2/tests/test_offer_extensions.py +95 -0
  19. lcp_reference_platform-1.0.2/tests/test_platform.py +1035 -0
  20. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/tests/test_postgres_integration.py +59 -1
  21. lcp_reference_platform-1.0.2/tests/test_wsgi.py +183 -0
  22. lcp_reference_platform-1.0.0/tests/test_platform.py +0 -409
  23. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/Dockerfile +0 -0
  24. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/docker-compose.yml +0 -0
  25. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/README.md +0 -0
  26. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/configmap.yaml +0 -0
  27. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/deployment.yaml +0 -0
  28. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/hpa.example.yaml +0 -0
  29. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/network-policy.example.yaml +0 -0
  30. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/pod-disruption-budget.yaml +0 -0
  31. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/pvc.example.yaml +0 -0
  32. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/secret.example.yaml +0 -0
  33. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/service.yaml +0 -0
  34. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/README.md +0 -0
  35. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/kyverno-test.yaml +0 -0
  36. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/provenance-policy.yaml +0 -0
  37. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/resources.yaml +0 -0
  38. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/sbom-policy.yaml +0 -0
  39. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/tests/verify-images/signature-policy.yaml +0 -0
  40. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/kubernetes/verify-images-kyverno.example.yaml +0 -0
  41. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/api_keys.py +0 -0
  42. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/attachments.py +0 -0
  43. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/auth.py +0 -0
  44. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/config.py +0 -0
  45. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/crypto.py +0 -0
  46. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/database.py +0 -0
  47. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/mapping.py +0 -0
  48. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/secrets.py +0 -0
  49. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/security.py +0 -0
  50. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/server.py +0 -0
  51. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/lcp_platform/worker.py +0 -0
  52. {lcp_reference_platform-1.0.0 → lcp_reference_platform-1.0.2}/tests/test_api_keys.py +0 -0
@@ -3,6 +3,7 @@
3
3
 
4
4
  # Local tooling artifacts — never published
5
5
  .gitnexus/
6
+ .freebuff/
6
7
  .claude/
7
8
  graphify-out/
8
9
  CLAUDE.md
@@ -14,3 +15,8 @@ __pycache__/
14
15
  .venv/
15
16
  data/
16
17
  *.sqlite3
18
+
19
+ # SDK build artifacts (Maven/Gradle) — never published
20
+ implementations/sdk/java/target/
21
+ implementations/sdk/kotlin/.gradle/
22
+ implementations/sdk/kotlin/build/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcp-reference-platform
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Production-oriented reference platform and router for LCP Lead Context Protocol deployments.
5
5
  Project-URL: Documentation, https://github.com/SpearSystems/LCP
6
6
  Project-URL: Source, https://github.com/SpearSystems/LCP
@@ -18,7 +18,8 @@ hardening.
18
18
  - Structured call payable evaluation and per-offer monthly quota reporting.
19
19
  - JSON Schema and strict ping-safe validation.
20
20
  - Bearer API-key and HMAC authentication.
21
- - Deterministic offer matching and auction selection.
21
+ - Deterministic offer matching and auction selection, including versioned
22
+ `lcp.platform.requirements` and `lcp.platform.service_area` extensions.
22
23
  - At-least-once buyer webhook delivery.
23
24
  - Standalone delivery worker for multi-process deployments.
24
25
  - WSGI entry point for a production HTTP process manager.
@@ -126,7 +127,10 @@ lcp-platform-admin privacy erase-lead --lead-id lead_abc123 --actor-id privacy_o
126
127
  ```
127
128
 
128
129
  Offer management is intentionally an operator/admin concern. The public LCP
129
- surface exposes active offers through `GET /v1/lcp/offers`.
130
+ surface exposes active offers through `GET /v1/lcp/offers`. For fast-changing
131
+ buyer requirements, use the versioned [requirement profiles and service areas](../../docs/REQUIREMENT-PROFILES.md)
132
+ without expanding the universal core or creating a new vertical for each
133
+ category.
130
134
 
131
135
  ## Run locally
132
136
 
@@ -167,6 +171,38 @@ gunicorn --bind 0.0.0.0:8080 --workers 3 --threads 4 \
167
171
  --access-logfile - lcp_platform.wsgi:application
168
172
  ```
169
173
 
174
+ The WSGI path handles binary attachments with the same behavior as the local
175
+ server: the attachment body limit (`LCP_MAX_ATTACHMENT_BYTES`) is selected
176
+ before `wsgi.input` is read, downloads stream raw encrypted bytes (never
177
+ `json.dumps`), and binary headers (`Content-Type`, `Content-Disposition`,
178
+ `Content-Length`) are forwarded. Upload and download with curl:
179
+
180
+ ```bash
181
+ export LCP_ATTACHMENT_FILE=scanned-evidence.pdf
182
+ export LCP_ATTACHMENT_ID=att_evidence_001
183
+ export LCP_ATTACHMENT_SHA256="$(sha256sum "$LCP_ATTACHMENT_FILE" | cut -d' ' -f1)"
184
+
185
+ # Upload: Bearer-authenticated raw bytes. The route's body limit applies before
186
+ # wsgi.input is read; replace the lead and sender IDs with your own values.
187
+ curl -sS -X POST "$LCP_ENDPOINT/v1/lcp/attachments" \
188
+ -H "Authorization: Bearer $LCP_API_KEY" \
189
+ -H "X-LCP-Sender-Id: publisher_001" \
190
+ -H "X-LCP-Lead-Id: <lead_id>" \
191
+ -H "X-LCP-Attachment-Id: $LCP_ATTACHMENT_ID" \
192
+ -H "X-LCP-Attachment-Purpose: supporting_document" \
193
+ -H "X-LCP-Filename: $LCP_ATTACHMENT_FILE" \
194
+ -H "X-LCP-Content-SHA256: $LCP_ATTACHMENT_SHA256" \
195
+ -H "X-LCP-Idempotency-Key: publisher-attachment-001" \
196
+ -H "Content-Type: application/pdf" \
197
+ --data-binary @"$LCP_ATTACHMENT_FILE"
198
+
199
+ # Download: bytes are returned unchanged with the original content headers.
200
+ curl -sS "$LCP_ENDPOINT/v1/lcp/attachments/$LCP_ATTACHMENT_ID" \
201
+ -H "Authorization: Bearer $LCP_API_KEY" \
202
+ -H "X-LCP-Sender-Id: publisher_001" \
203
+ -o evidence.bin
204
+ ```
205
+
170
206
  Run `lcp-platform-worker` as a separate supervised process. Place the
171
207
  application behind TLS; the included threaded server is intended for local
172
208
  operation and smoke tests.
@@ -189,12 +225,47 @@ multiple nodes or high throughput.
189
225
  | `GET` | `/v1/lcp/leads/{lead_id}` | Lead status and lifecycle |
190
226
  | `GET` | `/health/live` | Unauthenticated process liveness |
191
227
  | `GET` | `/health/ready` | Database-backed readiness |
228
+ | `GET` | `/v1/lcp/metrics` | Admin-scoped aggregate worker and retention metrics |
192
229
  | `GET` | `/v1/lcp/capabilities` | Capability discovery |
193
230
  | `GET` | `/v1/lcp/offers` | Active offer discovery |
194
231
  | `GET` | `/v1/lcp/schemas/{name}` | Schema discovery |
195
232
 
196
233
  The complete transport contract is [api/lcp-openapi.yaml](../../api/lcp-openapi.yaml).
197
234
 
235
+ `/v1/lcp/metrics` requires the `platform:admin` scope in an authenticated
236
+ production deployment. It returns queue age, retry depth, lease expiry,
237
+ attachment scanner/deletion backlog, and dead-letter counts only; it never
238
+ returns lead IDs, buyer IDs, tenant IDs, URLs, or consumer payloads.
239
+
240
+ Exhausted delivery and routing jobs are durable dead letters. Operators can
241
+ inspect and recover them without reading payloads:
242
+
243
+ ```bash
244
+ lcp-platform-admin dead-letter list --status OPEN
245
+ lcp-platform-admin dead-letter quarantine --job-id <job-id>
246
+ lcp-platform-admin dead-letter replay --job-id <job-id>
247
+ ```
248
+
249
+ Lifecycle events are role-scoped and idempotent. A delivered buyer reports
250
+ `ACCEPTED`/`REJECTED`/`DISPUTED`; the publisher reports `CONSENT_WITHDRAWN`
251
+ and `ERASURE_REQUEST`; every applied transition writes an audit record with
252
+ actor, event, and previous status. Status reads are projected by role — a
253
+ buyer sees only its own match decisions, payables, and events:
254
+
255
+ ```bash
256
+ # Buyer confirms the delivered lead (event -> audit row lead.accepted).
257
+ curl -sS -X POST "$LCP_ENDPOINT/v1/lcp/events" \
258
+ -H "Authorization: Bearer $LCP_API_KEY" \
259
+ -H "X-LCP-Sender-Id: buyer_001" \
260
+ -H "X-LCP-Idempotency-Key: buyer-accept-001" \
261
+ -H "Content-Type: application/json" \
262
+ -d '{"lcp":{"version":"1.0.0","message":{"type":"event","id":"<uuid>","timestamp":"2026-08-18T00:00:00Z","sender_id":"buyer_001","receiver_id":"platform_001","idempotency_key":"buyer-accept-001"},"payload":{"lead_id":"<lead_id>","event":"ACCEPTED","timestamp":"2026-08-18T00:00:00Z"}}}'
263
+
264
+ # Publisher reads status; the response contains only publisher-scoped data.
265
+ curl -sS "$LCP_ENDPOINT/v1/lcp/leads/<lead_id>" \
266
+ -H "Authorization: Bearer $LCP_API_KEY"
267
+ ```
268
+
198
269
  ## Tenant and credential model
199
270
 
200
271
  Every sender has a tenant ID and scopes. Examples include `lead:submit`,
@@ -14,4 +14,4 @@ __all__ = [
14
14
  "PublisherNormalizer",
15
15
  "RequestError",
16
16
  ]
17
- __version__ = "1.0.0"
17
+ __version__ = "1.0.2"
@@ -35,6 +35,16 @@ def main() -> None:
35
35
  erase_lead.add_argument("--lead-id", required=True)
36
36
  erase_lead.add_argument("--actor-id", default="operator")
37
37
 
38
+ dead_letter = commands.add_parser("dead-letter", help="Inspect and recover exhausted worker jobs")
39
+ dead_letter_sub = dead_letter.add_subparsers(dest="dead_letter_command", required=True)
40
+ list_dead_letter = dead_letter_sub.add_parser("list")
41
+ list_dead_letter.add_argument(
42
+ "--status", choices=["OPEN", "QUARANTINED", "REPLAYED"], default=None
43
+ )
44
+ for command_name in ("quarantine", "replay"):
45
+ command = dead_letter_sub.add_parser(command_name)
46
+ command.add_argument("--job-id", required=True)
47
+
38
48
  mapping = commands.add_parser("mapping", help="Manage publisher form mappings")
39
49
  mapping_sub = mapping.add_subparsers(dest="mapping_command", required=True)
40
50
  upsert_mapping = mapping_sub.add_parser("upsert")
@@ -70,6 +80,17 @@ def main() -> None:
70
80
  if args.privacy_command == "erase-lead":
71
81
  platform.erase_lead(args.lead_id, actor_id=args.actor_id)
72
82
  print(f"Erased lead {args.lead_id}")
83
+ elif args.command == "dead-letter":
84
+ if args.dead_letter_command == "list":
85
+ print(json.dumps(platform.store.list_dead_letters(args.status), indent=2))
86
+ elif args.dead_letter_command == "quarantine":
87
+ if not platform.store.quarantine_dead_letter(args.job_id):
88
+ raise SystemExit(f"Dead-letter job not found or already closed: {args.job_id}")
89
+ print(f"Quarantined dead-letter job {args.job_id}")
90
+ elif args.dead_letter_command == "replay":
91
+ if not platform.store.replay_dead_letter(args.job_id):
92
+ raise SystemExit(f"Dead-letter job cannot be replayed: {args.job_id}")
93
+ print(f"Replayed dead-letter job {args.job_id}")
73
94
  elif args.command == "mapping":
74
95
  with args.file.open(encoding="utf-8") as handle:
75
96
  mapping_data = json.load(handle)
@@ -7,6 +7,9 @@ from datetime import datetime, timezone
7
7
  from typing import Any
8
8
  from zoneinfo import ZoneInfo
9
9
 
10
+ from .offer_extensions import evaluate_offer_extensions
11
+ from .storage import parse_iso_datetime
12
+
10
13
 
11
14
  _COMPLETENESS = {"minimal": 1, "standard": 2, "rich": 3}
12
15
 
@@ -48,15 +51,37 @@ def _in_window(window: dict[str, Any], now: datetime) -> bool:
48
51
  return current >= start or current <= end
49
52
 
50
53
 
51
- def offer_is_available(offer: dict[str, Any], now: datetime | None = None) -> MatchResult:
52
- """Check active status and configured delivery windows."""
54
+ def offer_is_available(
55
+ offer: dict[str, Any],
56
+ now: datetime | None = None,
57
+ *,
58
+ vertical: str | None = None,
59
+ channel: str | None = None,
60
+ ) -> MatchResult:
61
+ """Check active status and configured delivery windows.
62
+
63
+ A delivery window may narrow an offer to particular verticals or channels.
64
+ Missing lead criteria cannot satisfy a restricted window; this keeps
65
+ matching fail-closed when a publisher omits a field that the buyer used to
66
+ constrain availability.
67
+ """
53
68
  reasons: list[str] = []
54
69
  if offer.get("active", True) is not True:
55
70
  reasons.append("offer_inactive")
56
71
  windows = offer.get("delivery_windows", [])
57
72
  if windows:
58
73
  current = now or datetime.now(timezone.utc)
59
- if not any(_in_window(window, current) for window in windows):
74
+ matching_windows = []
75
+ for window in windows:
76
+ allowed_verticals = window.get("verticals")
77
+ allowed_channels = window.get("channels")
78
+ criteria_match = (
79
+ (not allowed_verticals or vertical in allowed_verticals)
80
+ and (not allowed_channels or channel in allowed_channels)
81
+ )
82
+ if criteria_match and _in_window(window, current):
83
+ matching_windows.append(window)
84
+ if not matching_windows:
60
85
  reasons.append("outside_delivery_window")
61
86
  return MatchResult(not reasons, tuple(reasons))
62
87
 
@@ -76,9 +101,33 @@ def match_offer(
76
101
  compliance = payload.get("compliance", {})
77
102
  quality = payload.get("lead_quality", {})
78
103
 
79
- available = offer_is_available(offer, now)
104
+ current = now or datetime.now(timezone.utc)
105
+ available = offer_is_available(
106
+ offer,
107
+ current,
108
+ vertical=attributes.get("vertical"),
109
+ channel=payload.get("channel"),
110
+ )
80
111
  reasons.extend(available.reasons)
81
112
 
113
+ consent_expires_at = parse_iso_datetime(compliance.get("consent_expires_at"))
114
+ if consent_expires_at is not None and consent_expires_at <= current:
115
+ reasons.append("consent_expired")
116
+ extensions = offer.get("extensions", {})
117
+ if isinstance(extensions, dict):
118
+ required_purposes = extensions.get("lcp.platform.required_consent_purposes", [])
119
+ contact_purpose = extensions.get("lcp.platform.contact_purpose")
120
+ if contact_purpose and contact_purpose not in required_purposes:
121
+ required_purposes = [*required_purposes, contact_purpose] if isinstance(required_purposes, list) else [contact_purpose]
122
+ if required_purposes:
123
+ purposes = compliance.get("consent_purposes", [])
124
+ if not isinstance(required_purposes, list) or not isinstance(purposes, list):
125
+ reasons.append("consent_purpose_policy_invalid")
126
+ else:
127
+ missing = sorted({str(purpose) for purpose in required_purposes} - set(purposes))
128
+ if missing:
129
+ reasons.append("consent_purpose_missing:" + ",".join(missing))
130
+
82
131
  if attributes.get("vertical") != offer.get("vertical"):
83
132
  reasons.append("vertical_mismatch")
84
133
  allowed_publishers = offer.get("allowed_publisher_ids", [])
@@ -137,6 +186,8 @@ def match_offer(
137
186
  if offer.get("reject_blacklist_flagged") and _scrub_status(payload, "blacklist") != "clean":
138
187
  reasons.append("blacklist_not_clean")
139
188
 
189
+ reasons.extend(evaluate_offer_extensions(offer, payload))
190
+
140
191
  # Claim-language evidence is intentionally fail-closed. A deployment may
141
192
  # provide a namespaced evidence block without putting copy/PII in a ping.
142
193
  if offer.get("excluded_claim_language") and not payload.get("extensions", {}).get(
@@ -11,7 +11,7 @@ from typing import Any
11
11
  from uuid import uuid4
12
12
 
13
13
  from .auth import signature_for
14
- from .storage import now_iso
14
+ from .storage import is_expired, now_iso, parse_iso_datetime
15
15
 
16
16
 
17
17
  def _envelope(
@@ -135,6 +135,9 @@ def build_ping(
135
135
  "consent": bool(lead.get("compliance", {}).get("consent_evidence")),
136
136
  "otp_verified": bool(lead.get("compliance", {}).get("otp_verified")),
137
137
  "dnc_checked": bool(lead.get("compliance", {}).get("dnc_checked")),
138
+ "consent_valid": not is_expired(
139
+ parse_iso_datetime(lead.get("compliance", {}).get("consent_expires_at"))
140
+ ),
138
141
  },
139
142
  "quality_flags": {
140
143
  "verified_phone": lead.get("lead_quality", {}).get("verified_phone", False),
@@ -0,0 +1,23 @@
1
+ """Request-scoped observability primitives with no consumer-data logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+
7
+
8
+ _request_id: ContextVar[str | None] = ContextVar("lcp_request_id", default=None)
9
+
10
+
11
+ def set_request_id(value: str | None):
12
+ """Set the request ID for the current execution context."""
13
+ return _request_id.set(value)
14
+
15
+
16
+ def reset_request_id(token) -> None:
17
+ """Restore the previous request ID after a request finishes."""
18
+ _request_id.reset(token)
19
+
20
+
21
+ def current_request_id() -> str | None:
22
+ """Return the current request ID, if the call is inside an HTTP request."""
23
+ return _request_id.get()
@@ -0,0 +1,151 @@
1
+ """Safe, deployment-scoped offer extension matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ REQUIREMENTS_NAMESPACE = "lcp.platform.requirements"
10
+ SERVICE_AREA_NAMESPACE = "lcp.platform.service_area"
11
+ _ALLOWED_PATH_ROOTS = {"attributes", "location", "provenance"}
12
+ _ALLOWED_OPERATORS = {"equals", "in", "exists", "between", "prefix"}
13
+
14
+
15
+ def _path_value(payload: Mapping[str, Any], path: str) -> tuple[bool, Any]:
16
+ """Read one allowlisted top-level path without evaluating expressions."""
17
+
18
+ if path == "channel":
19
+ return "channel" in payload, payload.get("channel")
20
+
21
+ parts = path.split(".")
22
+ if len(parts) != 2 or parts[0] not in _ALLOWED_PATH_ROOTS or not parts[1]:
23
+ raise ValueError("path is not an allowed two-level LCP path")
24
+ container = payload.get(parts[0])
25
+ if not isinstance(container, Mapping):
26
+ return False, None
27
+ return parts[1] in container, container.get(parts[1])
28
+
29
+
30
+ def _predicate_match(predicate: Mapping[str, Any], payload: Mapping[str, Any]) -> bool:
31
+ path = predicate.get("path")
32
+ operator = predicate.get("operator")
33
+ if not isinstance(path, str) or not isinstance(operator, str):
34
+ raise ValueError("predicate requires string path and operator")
35
+ if operator not in _ALLOWED_OPERATORS:
36
+ raise ValueError(f"unsupported predicate operator: {operator}")
37
+
38
+ present, actual = _path_value(payload, path)
39
+ if operator == "exists":
40
+ expected = predicate.get("value")
41
+ if not isinstance(expected, bool):
42
+ raise ValueError("exists predicate requires a boolean value")
43
+ return present is expected
44
+ if not present:
45
+ return False
46
+
47
+ if operator == "equals":
48
+ if "value" not in predicate:
49
+ raise ValueError("equals predicate requires value")
50
+ return actual == predicate["value"]
51
+ if operator == "in":
52
+ values = predicate.get("values")
53
+ if not isinstance(values, list) or not values:
54
+ raise ValueError("in predicate requires a non-empty values array")
55
+ return actual in values
56
+ if operator == "between":
57
+ minimum = predicate.get("min")
58
+ maximum = predicate.get("max")
59
+ if (
60
+ isinstance(actual, bool)
61
+ or not isinstance(actual, (int, float))
62
+ or isinstance(minimum, bool)
63
+ or not isinstance(minimum, (int, float))
64
+ or isinstance(maximum, bool)
65
+ or not isinstance(maximum, (int, float))
66
+ or minimum > maximum
67
+ ):
68
+ raise ValueError("between predicate requires ordered numeric min and max")
69
+ return minimum <= actual <= maximum
70
+ if operator == "prefix":
71
+ prefix = predicate.get("value")
72
+ if not isinstance(prefix, str):
73
+ raise ValueError("prefix predicate requires a string value")
74
+ return isinstance(actual, str) and actual.startswith(prefix)
75
+ raise ValueError(f"unsupported predicate operator: {operator}")
76
+
77
+
78
+ def _require_profile_identity(profile: Mapping[str, Any], label: str) -> None:
79
+ if not isinstance(profile.get("profile_id"), str) or not profile["profile_id"]:
80
+ raise ValueError(f"{label} requires profile_id")
81
+ if not isinstance(profile.get("version"), str) or not profile["version"]:
82
+ raise ValueError(f"{label} requires version")
83
+
84
+
85
+ def _match_requirements(profile: Any, payload: Mapping[str, Any]) -> list[str]:
86
+ if not isinstance(profile, Mapping):
87
+ return ["requirements_profile_invalid"]
88
+ try:
89
+ _require_profile_identity(profile, "requirements profile")
90
+ predicates = profile.get("predicates")
91
+ if not isinstance(predicates, list) or not predicates:
92
+ raise ValueError("requirements profile requires predicates")
93
+ reasons: list[str] = []
94
+ for predicate in predicates:
95
+ if not isinstance(predicate, Mapping):
96
+ raise ValueError("requirements predicate must be an object")
97
+ if not _predicate_match(predicate, payload):
98
+ reasons.append("requirements_predicate_mismatch")
99
+ return reasons
100
+ except ValueError:
101
+ return ["requirements_profile_invalid"]
102
+
103
+
104
+ def _match_service_area(profile: Any, payload: Mapping[str, Any]) -> list[str]:
105
+ if not isinstance(profile, Mapping):
106
+ return ["service_area_profile_invalid"]
107
+ try:
108
+ _require_profile_identity(profile, "service-area profile")
109
+ location = payload.get("location")
110
+ if not isinstance(location, Mapping):
111
+ return ["service_area_location_missing"]
112
+ reasons: list[str] = []
113
+ for field, reason in (
114
+ ("countries", "service_area_country_not_supported"),
115
+ ("state_regions", "service_area_state_not_supported"),
116
+ ("postal_codes", "service_area_postal_code_not_supported"),
117
+ ):
118
+ allowed = profile.get(field)
119
+ if allowed is None:
120
+ continue
121
+ if not isinstance(allowed, list) or not allowed or not all(
122
+ isinstance(value, str) and value for value in allowed
123
+ ):
124
+ raise ValueError(f"{field} must be a non-empty string array")
125
+ location_field = "state_region" if field == "state_regions" else (
126
+ "postal_code" if field == "postal_codes" else "country_code"
127
+ )
128
+ actual = location.get(location_field)
129
+ if actual not in allowed:
130
+ reasons.append(reason)
131
+ return reasons
132
+ except ValueError:
133
+ return ["service_area_profile_invalid"]
134
+
135
+
136
+ def evaluate_offer_extensions(
137
+ offer: Mapping[str, Any],
138
+ payload: Mapping[str, Any],
139
+ ) -> tuple[str, ...]:
140
+ """Evaluate known platform extensions and return explainable failure reasons."""
141
+
142
+ extensions = offer.get("extensions", {})
143
+ if not isinstance(extensions, Mapping):
144
+ return ("offer_extensions_invalid",)
145
+
146
+ reasons: list[str] = []
147
+ if REQUIREMENTS_NAMESPACE in extensions:
148
+ reasons.extend(_match_requirements(extensions[REQUIREMENTS_NAMESPACE], payload))
149
+ if SERVICE_AREA_NAMESPACE in extensions:
150
+ reasons.extend(_match_service_area(extensions[SERVICE_AREA_NAMESPACE], payload))
151
+ return tuple(reasons)
@@ -130,6 +130,22 @@ class PostgresStore(Store):
130
130
  db.execute(
131
131
  "ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'"
132
132
  )
133
+ db.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS expires_at TEXT")
134
+ db.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS consent_expires_at TEXT")
135
+ db.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS suppressed INTEGER NOT NULL DEFAULT 0")
136
+ db.execute("ALTER TABLE offers ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'")
137
+ db.execute("ALTER TABLE offers ADD COLUMN IF NOT EXISTS vertical TEXT")
138
+ for row in db.execute("SELECT offer_id, offer_json FROM offers").fetchall():
139
+ stored_offer = json.loads(_decode_text(row["offer_json"]))
140
+ db.execute(
141
+ "UPDATE offers SET tenant_id = ?, vertical = ? WHERE offer_id = ?",
142
+ (
143
+ str(stored_offer.get("tenant_id", "default")),
144
+ stored_offer.get("vertical"),
145
+ row["offer_id"],
146
+ ),
147
+ )
148
+ db.execute("CREATE INDEX IF NOT EXISTS idx_offers_discovery ON offers(active, tenant_id, vertical, offer_id)")
133
149
  db.execute("ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS lease_owner TEXT")
134
150
  db.execute("ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS lease_until TEXT")
135
151
  db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS residency TEXT NOT NULL DEFAULT 'TEST'")
@@ -137,6 +153,7 @@ class PostgresStore(Store):
137
153
  db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS scan_engine TEXT NOT NULL DEFAULT 'unknown'")
138
154
  db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS scanned_at TEXT NOT NULL DEFAULT ''")
139
155
  db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS encryption TEXT NOT NULL DEFAULT 'application_encrypted'")
156
+ db.execute("ALTER TABLE attachments ADD COLUMN IF NOT EXISTS expires_at TEXT")
140
157
 
141
158
  def close(self) -> None:
142
159
  with self._lock:
@@ -150,17 +167,22 @@ class PostgresStore(Store):
150
167
  active_only: bool = True,
151
168
  ) -> list[dict[str, Any]]:
152
169
  query = "SELECT offer_json FROM offers"
170
+ clauses: list[str] = []
171
+ values: list[Any] = []
153
172
  if active_only:
154
- query += " WHERE active = 1"
155
- query += " ORDER BY offer_id"
156
- with self._lock:
157
- rows = self._connection.execute(query, ()).fetchall()
158
- offers = [json.loads(row["offer_json"]) for row in rows]
173
+ clauses.append("active = 1")
159
174
  if vertical:
160
- offers = [offer for offer in offers if offer.get("vertical") == vertical]
175
+ clauses.append("vertical = ?")
176
+ values.append(vertical)
161
177
  if tenant_id:
162
- offers = [offer for offer in offers if offer.get("tenant_id") == tenant_id]
163
- return offers
178
+ clauses.append("tenant_id = ?")
179
+ values.append(tenant_id)
180
+ if clauses:
181
+ query += " WHERE " + " AND ".join(clauses)
182
+ query += " ORDER BY offer_id"
183
+ with self._lock:
184
+ rows = self._connection.execute(query, values).fetchall()
185
+ return [json.loads(_decode_text(row["offer_json"])) for row in rows]
164
186
 
165
187
  def claim_routing_jobs(
166
188
  self,