Files
sientia-dataops-opc-ingestor/docs/superpowers/plans/2026-07-13-opc-gateway.md

916 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OPC Gateway Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Evolve `sientia-dataops-opc-ingestor` into an OPC Gateway that reads by subscription (multi-name fan-out), reads actively (scheduled + on-demand via gRPC), writes OPC via NATS, manages certs via MinIO+Mongo, and emits detailed metrics — following the OPC best practices of SIENTIAPDE-1879.
**Architecture:** Approach B — *engines per capability* over a shared `OpcConnectionPool` (connections keyed by server, not by slot). Input adapters (`NatsWriteConsumer`, `GrpcServer`) feed the engines; a `DataSink` handles egress (MongoDB reads + Postgres `write_status`; the legacy Kafka egress is removed). Coordination (slot lease/heartbeat) is reused from the existing Redis layer.
**Tech Stack:** Python 3.11, `asyncua`, `nats-py` (Core NATS), `grpcio`/`grpcio-tools`, `prometheus_client`, `sientia_do` dataops-library (Mongo/Redis/MinIO/Postgres), pytest + pytest-asyncio, ruff (line-length 100, single quotes).
**Spec:** `docs/superpowers/specs/2026-07-13-opc-gateway-design.md` (read it first — contracts C1–C4, metrics, §13 1879 checklist).
**Scope:** This plan is the **Gateway only** (repo `sientia-dataops-opc-ingestor`). Orchestrator (1974) and Laborious (1975) are parallel tracks with their own plans in their own repos; here they appear only as the contracts C1 (slot v2), C2 (NATS message), C3 (write_status).
**Conventions (match the repo):**
- Managers/engines extend `SientiaMonitoring` (`sientia_do.observability.sientia_monitoring`) → gives `self.logger`, `self.pod_id`, `await self.emit_metric(...)`, `await self.send_notification_async(...)`.
- Metrics defined in `ingestor/metrics.py` via `prometheus_client`.
- Package stays `ingestor/` (do **not** rename — the Helm health probe and Gitea `PYTHON_APP` entrypoint depend on `ingestor.app`). New capabilities go in new subpackages.
- Test command everywhere: `python -m pytest <path> -v`. Lint: `ruff check ingestor tests`.
---
## File Structure
**New subpackages under `ingestor/`** (one clear responsibility each; keep files < ~500 lines, functions < ~20 lines per 1879):
- `ingestor/connection/pool.py` — `OpcConnectionPool`: `asyncua` clients keyed by `server_id`, lazy open, reuse, idle-eviction, reconnect.
- `ingestor/connection/security.py` — `build_security(...)`: `Basic256Sha256`, `secure_channel_timeout = int(session_timeout*1.25)`, cert file paths.
- `ingestor/connection/certs.py` — `CertProvider`: read `platform_certificates` (Mongo) + download cert/key from MinIO (`sientia-dataops` bucket) to an `emptyDir`/tmpfs path.
- `ingestor/connection/clock.py` — `MonotonicClock`: single `time.monotonic()` reference + optional NTP; `elapsed()`, `now_wall()`.
- `ingestor/converters.py` — `to_variant_value(raw, data_type)`: safe type coercion (bool-string bug fix + `try/except`).
- `ingestor/engines/subscription.py` — `SubscriptionEngine`: slot v2 → subscription → multi-name fan-out to `DataSink`.
- `ingestor/engines/active_read.py` — `ActiveReadEngine`: scheduled reads + `read_now()` for on-demand.
- `ingestor/engines/write.py` — `WriteEngine`: OPC write with status_code/timestamp/quality flags, rate-limit, error mapping, → `DataSink.update_write_status`.
- `ingestor/io/slots.py` — `load_slot(redis, slot_id)`: parse slot v2 (C1) into typed structures.
- `ingestor/io/data_sink.py` — `DataSink`: `publish_reading(...)` (MongoDB fan-out) + `update_write_status(...)` (Postgres UPDATE by `timestamp`+`model_id`).
- `ingestor/adapters/nats_consumer.py` — `NatsWriteConsumer`: subscribe `opc.write` (queue group `gateway-writers`) → `WriteEngine`.
- `ingestor/adapters/grpc_server.py` — `GrpcServer`: `ReadTag` RPC → `ActiveReadEngine.read_now`.
- `ingestor/proto/opc_gateway.proto` (+ generated `*_pb2.py`, `*_pb2_grpc.py`).
- `ingestor/config.py` — extend with new env (NATS, gRPC, cert bucket, NTP).
- `ingestor/metrics.py` — extend with new metrics (§9 of spec).
**Reused as-is / lightly touched:** `ingestor/managers/resource_manager.py` (lease/heartbeat), `ingestor/managers/data_manager.py` (MongoDB egress — wrapped by `DataSink`; the legacy Kafka path is removed in Task 9b), `ingestor/app.py` (wire new engines in F4).
**Tests mirror source:** `tests/unit/connection/…`, `tests/unit/engines/…`, `tests/unit/io/…`, `tests/unit/adapters/…`, `tests/unit/test_converters.py`. Add an `__init__.py` to each new test dir (the repo's existing test dirs use them for collection, e.g. `tests/unit/managers/__init__.py`).
---
## Phase Delivery & Commit Ledger
**Delivery model: one PR per phase (F0…F4), opened only at the very end.** To avoid one giant PR, each phase is delivered and reviewed as its own PR. But **no PR is opened until every phase is complete, tested, and working** — we build all phases on this feature branch first, then split them into per-phase PRs at the end. This section is the ledger that makes that split possible.
### Phase close-out (the LAST task of every phase — Tasks 7.end, 10.end, 13.end, 16.end, 21.end)
At the end of each phase Fx, before starting the next:
1. Confirm the phase is green: full `python -m pytest tests -v` passes and `ruff check ingestor tests` is clean.
2. Record the phase's commit SHAs (in order) in the ledger below, with the phase's base (parent of its first commit).
3. **Do NOT open the PR yet** — PRs are opened only after F4 closes (see "Final: open per-phase PRs").
### Commit ledger
**F0 — Connection core** — base `498030d` (merge-base with `main`). Status: ✅ complete, 162 unit tests green, ruff clean.
- `0694f6a` chore(gateway): add nats-py, grpcio deps (Task 1)
- `3dd4f60` feat(gateway): add write/active-read/error metrics (Task 2)
- `55f6721` feat(gateway): safe type converter (1879 BUG-1/BUG-2) (Task 3)
- `d6cb1e4` feat(gateway): monotonic clock helper (1879) (Task 4)
- `b05c2fd` feat(gateway): Basic256Sha256 + channel>session lifetime (1879) (Task 5)
- `ec2a324` feat(gateway): cert provider from MinIO+Mongo (sientia-dataops) (Task 6)
- `5d30a5c` feat(gateway): OpcConnectionPool (per-server, reuse, idle-evict) (Task 7)
- `0b29857` fix(gateway): per-server lock in OpcConnectionPool to avoid duplicate connects (Task 7)
- `b57be9f` refactor(gateway): drop dead error_stack and unused metadata from OpcConnectionPool (ponytail) (Task 7)
- Also fixed a pre-existing test flake in `a08fd88` (gc.collect() in test_ingestor_manager) — include in the F0 code PR.
- ⚠️ The branch also carries **docs** commits (design/spec/plan) interleaved in time (`6f6f3bf`, `c980092`, `524f4d6`, `8a05ff7`, `941c6c0`, `7cffc26`, `bbe10a4`, `09e4290`, plus the gRPC/Kafka doc updates). Those are **not** F0 code — the aig-docs equivalents already ship in PR #76; the repo `docs/superpowers/**` commits can go in a separate small docs PR or ride with F0. Keep them out of the F0 **code** PR scope.
**F1 — Subscription reads (C1)** — base `00f919c`. Status: ✅ complete, 168 unit tests green, ruff clean.
- `44df022` feat(gateway): slot v2 loader (contract C1) (Task 8)
- `ae88ac6` feat(gateway): DataSink.publish_reading multi-name fan-out (Task 9)
- `323bf24` refactor(gateway): remove legacy Kafka egress (MongoDB-only) (Task 9b)
- `00c74e5` feat(gateway): SubscriptionEngine multi-name fan-out (C1) (Task 10)
- Carry-forward to F4/later (from F1 reviews):
- **Task 18 (wiring):** decide how many `SubscriptionEngine` instances the app builds. Current code keeps a single engine-wide `non_receive_count` and `check_opc_listenning(server_name)` takes a param — safe only if **one engine per OPC server**. If one engine is shared across servers, a datachange on server A masks silence on server B → must partition cycle/non-receive state per server. Make this deliberate at wiring time.
- **Task 12 (DataSink):** drop the currently-unused `metadata` param from `DataSink.__init__` while adding `update_write_status`.
- **F4 cleanup:** `README.md` and `tests/functional/test_single_node.py`/`conftest.py` still reference Kafka (stale/commented dead code) — remove during F4 hardening (Task 21 sweep).
**F2 — Active write via NATS (C2, C3):** _(fill at F2 close-out)_
**F3 — Active reads + gRPC (C4):** _(fill at F3 close-out)_
**F4 — Wiring, health, deploy, hardening:** _(fill at F4 close-out)_
### Final: open per-phase PRs (only after F4 is complete, tested, working)
Since all phases live on one feature branch, split them at the end using the recorded SHA ranges: for each phase in order F0→F4, create a phase branch off `main` (or stacked on the previous phase branch) containing only that phase's code commits (cherry-pick the ledger's SHA range), then open one PR per phase targeting `main`, in phase order. This keeps each PR small and reviewable instead of one giant diff.
---
## Phase F0 — Foundations (deps, config, metrics, connection pool)
### Task 1: Add dependencies
**Files:**
- Modify: `requirements.txt` (and `pyproject.toml` if deps are declared there)
- [ ] **Step 1: Add libs**
Add to `requirements.txt`:
```
nats-py>=2.6
grpcio>=1.60
grpcio-tools>=1.60
```
(`asyncua`, `prometheus_client`, `sientia_do`, `pandas` already present. Bump `sientia_do` to the version that ships the generic Mongo methods — `feature/SIENTIAPDE-1590` / ≥ current pin.)
- [ ] **Step 2: Install & verify import**
Run: `pip install -r requirements.txt && python -c "import nats, grpc; print('ok')"`
Expected: `ok`
- [ ] **Step 3: Commit**
```bash
git add requirements.txt pyproject.toml
git commit -m "chore(gateway): add nats-py, grpcio deps"
```
---
### Task 2: New metrics (spec §9)
**Files:**
- Modify: `ingestor/metrics.py`
- Test: `tests/unit/test_metrics.py` (extend existing)
- [ ] **Step 1: Write failing test** — assert the new metric objects exist and accept labels.
```python
# tests/unit/test_metrics.py
import ingestor.metrics as m
def test_gateway_metrics_exist():
m.OPC_WRITE_TOTAL.labels(pod_id='p', server_name='s', model_id='1', result='success')
m.OPC_WRITE_LATENCY.labels(pod_id='p', server_name='s')
m.OPC_ACTIVE_READ_TOTAL.labels(pod_id='p', server_name='s', result='success')
m.OPC_ACTIVE_READ_LATENCY.labels(pod_id='p', server_name='s')
m.OPC_WRITE_STATUS_BY_MODEL.labels(pod_id='p', model_id='1', status='ok')
m.OPC_TAG_VALUE.labels(pod_id='p', tag_name='t', model_id='1')
m.OPC_READ_LAG.labels(pod_id='p', server_name='s')
m.OPC_ERROR_BY_TYPE.labels(pod_id='p', server_name='s', error_type='BadTimeout')
m.OPC_DISCONNECTIONS_TOTAL.labels(pod_id='p', server_name='s')
```
- [ ] **Step 2: Run — expect FAIL** (`AttributeError`). Run: `python -m pytest tests/unit/test_metrics.py -v`
- [ ] **Step 3: Implement** — append to `ingestor/metrics.py`:
```python
# --- Gateway: Write metrics ---
OPC_WRITE_TOTAL = Counter(
'opc_write_total', 'OPC active write attempts',
['pod_id', 'server_name', 'model_id', 'result'], # result: success|error
)
OPC_WRITE_LATENCY = Histogram(
'opc_write_latency_seconds', 'OPC active write latency', ['pod_id', 'server_name'],
)
OPC_WRITE_STATUS_BY_MODEL = Gauge(
'opc_write_status_by_model', 'Last write status per model (1=ok,0=error)',
['pod_id', 'model_id', 'status'],
)
# --- Gateway: Active read metrics ---
OPC_ACTIVE_READ_TOTAL = Counter(
'opc_active_read_total', 'OPC active read attempts',
['pod_id', 'server_name', 'result'],
)
OPC_ACTIVE_READ_LATENCY = Histogram(
'opc_active_read_latency_seconds', 'OPC active read latency', ['pod_id', 'server_name'],
)
OPC_READ_LAG = Gauge(
'opc_read_lag_seconds', 'Lag between OPC source timestamp and machine time',
['pod_id', 'server_name'],
)
OPC_TAG_VALUE = Gauge(
'opc_tag_value', 'Last value read per tag/model', ['pod_id', 'tag_name', 'model_id'],
)
# --- Gateway: connection/error metrics ---
OPC_DISCONNECTIONS_TOTAL = Counter(
'opc_disconnections_total', 'OPC disconnections', ['pod_id', 'server_name'],
)
OPC_ERROR_BY_TYPE = Counter(
'opc_error_by_type_total', 'OPC errors by type', ['pod_id', 'server_name', 'error_type'],
)
```
- [ ] **Step 4: Run — expect PASS.** `python -m pytest tests/unit/test_metrics.py -v`
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): add write/active-read/error metrics"`
---
### Task 3: Safe type converter (1879 BUG-1 & BUG-2)
**Files:**
- Create: `ingestor/converters.py`
- Test: `tests/unit/test_converters.py`
- [ ] **Step 1: Write failing tests** (the bool-string bug is the whole point):
```python
# tests/unit/test_converters.py
import pytest
from ingestor.converters import to_typed_value, ConversionError
@pytest.mark.parametrize('raw,expected', [
('false', False), ('False', False), ('0', False), ('no', False),
('off', False), ('', False), ('true', True), ('1', True), (True, True),
])
def test_bool_from_json_string(raw, expected):
assert to_typed_value(raw, 'boolean') is expected
def test_float_and_int():
assert to_typed_value('42.5', 'float') == 42.5
assert to_typed_value('7', 'int') == 7
@pytest.mark.parametrize('raw,dtype', [
(None, 'int'), ('abc', 'float'), ('123.123', 'int'), (None, 'float'),
])
def test_invalid_raises_conversion_error(raw, dtype):
with pytest.raises(ConversionError):
to_typed_value(raw, dtype)
```
- [ ] **Step 2: Run — expect FAIL** (module missing). `python -m pytest tests/unit/test_converters.py -v`
- [ ] **Step 3: Implement** `ingestor/converters.py`:
```python
"""Safe coercion of JSON-transported values into typed OPC write values.
Fixes 1879 BUG-1 (bool('false') is True in Python) and BUG-2 (uncaught
TypeError/ValueError from int/float on bad input).
"""
_FALSE_STRINGS = {'false', '0', 'no', 'off', ''}
class ConversionError(ValueError):
"""Raised when a raw value cannot be coerced to the requested type."""
def _to_bool(raw) -> bool:
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
return str(raw).strip().lower() not in _FALSE_STRINGS
def to_typed_value(raw, data_type: str):
"""Coerce raw (often a JSON string) to the given data_type.
data_type: 'boolean' | 'int' | 'float' | 'double' | 'string'.
Raises ConversionError on any invalid input (never a bare TypeError).
"""
dtype = data_type.strip().lower()
try:
if dtype in ('bool', 'boolean'):
return _to_bool(raw)
if dtype in ('int', 'integer'):
if isinstance(raw, str) and ('.' in raw or raw.strip() == ''):
raise ConversionError(f'invalid int: {raw!r}')
return int(raw)
if dtype in ('float', 'double', 'real'):
return float(raw)
if dtype in ('str', 'string'):
return str(raw)
except (TypeError, ValueError) as e:
raise ConversionError(f'cannot convert {raw!r} to {data_type}: {e}') from e
raise ConversionError(f'unknown data_type: {data_type!r}')
```
- [ ] **Step 4: Run — expect PASS.** `python -m pytest tests/unit/test_converters.py -v`
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): safe type converter (1879 BUG-1/BUG-2)"`
---
### Task 4: Monotonic clock helper (1879 §2)
**Files:**
- Create: `ingestor/connection/clock.py`, `ingestor/connection/__init__.py`
- Test: `tests/unit/connection/test_clock.py`
- [ ] **Step 1: Failing test**
```python
# tests/unit/connection/test_clock.py
from ingestor.connection.clock import MonotonicClock
def test_elapsed_is_non_negative_and_monotonic():
clk = MonotonicClock()
a = clk.elapsed()
b = clk.elapsed()
assert a >= 0 and b >= a # never negative even if wall clock jumps back
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `ingestor/connection/clock.py`:
```python
import time
class MonotonicClock:
"""Single monotonic reference for latency/timeout math (1879 §2).
Wall-clock (`now_wall`) is only for human-facing timestamps; all elapsed/
timeout math must use `elapsed()` so NTP re-sync can't produce negatives.
"""
def __init__(self):
self._start = time.monotonic()
def elapsed(self) -> float:
return time.monotonic() - self._start
@staticmethod
def now_wall() -> float:
return time.time()
```
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): monotonic clock helper (1879)"`
> **Note (calibration):** NTP sync + wall-clock quality downgrade (`Good_Local_Override 0xD8`) are flag-driven and belong to the WriteEngine (Task 11). Leave a `# ponytail:` marker here noting NTP option is deferred until a real clock-drift case appears.
---
### Task 5: Security builder (1879 §1, §3)
**Files:**
- Create: `ingestor/connection/security.py`
- Test: `tests/unit/connection/test_security.py`
- [ ] **Step 1: Failing test** — assert channel timeout is 1.25× session and policy is Sha256.
```python
# tests/unit/connection/test_security.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.connection.security import apply_security, SESSION_TIMEOUT_MS
@pytest.mark.asyncio
async def test_apply_security_sets_sha256_and_channel_lifetime():
client = MagicMock()
client.set_security = AsyncMock()
await apply_security(client, 'urn:x', 'c.pem', 'k.pem', None)
assert client.session_timeout == SESSION_TIMEOUT_MS
assert client.secure_channel_timeout == int(SESSION_TIMEOUT_MS * 1.25)
args, kwargs = client.set_security.call_args
from asyncua.crypto.security_policies import SecurityPolicyBasic256Sha256
assert args[0] is SecurityPolicyBasic256Sha256
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `ingestor/connection/security.py`:
```python
from asyncua.crypto.security_policies import SecurityPolicyBasic256Sha256
SESSION_TIMEOUT_MS = 600_000 # 10 min; tune per server (Samarco: long sessions degrade)
async def apply_security(client, server_uri, cert_path, private_key_path, server_cert_path):
"""Apply Basic256Sha256 + certificate auth (1879 §1) and set channel > session
lifetime (1879 §3: secure_channel_timeout = session_timeout * 1.25)."""
client.application_uri = server_uri
await client.set_security(
SecurityPolicyBasic256Sha256,
certificate=cert_path,
private_key=private_key_path,
server_certificate=server_cert_path,
)
client.session_timeout = SESSION_TIMEOUT_MS
client.secure_channel_timeout = int(SESSION_TIMEOUT_MS * 1.25)
```
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): Basic256Sha256 + channel>session lifetime (1879)"`
---
### Task 6: Certificate provider (MinIO + Mongo)
**Files:**
- Create: `ingestor/connection/certs.py`
- Test: `tests/unit/connection/test_certs.py`
- [ ] **Step 1: Failing test** — reads `platform_certificates` doc, downloads both objects, returns local paths.
```python
# tests/unit/connection/test_certs.py
from unittest.mock import AsyncMock
import pytest
from ingestor.connection.certs import CertProvider
@pytest.mark.asyncio
async def test_fetch_returns_local_paths(tmp_path):
mongo = AsyncMock()
mongo.find_one.return_value = {
'client_cert': {'minio_object_name': 'platform/certificates/client_cert_c.pem'},
'private_key': {'minio_object_name': 'platform/certificates/private_key_k.pem'},
}
minio = AsyncMock()
provider = CertProvider(mongo, minio, cache_dir=str(tmp_path), bucket='sientia-dataops')
cert, key = await provider.fetch()
assert cert.endswith('c.pem') and key.endswith('k.pem')
assert minio.download_file.await_count == 2
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `ingestor/connection/certs.py` — read the single `platform_certificates` doc via `mongo.find_one({}, 'platform_certificates')`, `await minio.download_file(bucket, object_name, local_path)` into `cache_dir` (must be a writable `emptyDir`/tmpfs — root FS is read-only in the pod, spec §12). Return `(cert_path, key_path)`. Keep it < 20 lines.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): cert provider from MinIO+Mongo (sientia-dataops)"`
> Confirm the exact `MongoDBRepository.find_one`/`MinioRepository.download_file` signatures against the pinned `sientia_do`; adapt the calls if they differ.
---
### Task 7: OpcConnectionPool
**Files:**
- Create: `ingestor/connection/pool.py`
- Test: `tests/unit/connection/test_pool.py`
- [ ] **Step 1: Failing tests** — (a) same `server_id` returns the same client (reuse); (b) a connect failure increments `OPC_CONNECTIONS_FAILED` and re-raises.
```python
# tests/unit/connection/test_pool.py
from unittest.mock import AsyncMock, patch
import pytest
from ingestor.connection.pool import OpcConnectionPool
@pytest.mark.asyncio
async def test_get_reuses_connection_per_server(monkeypatch):
pool = OpcConnectionPool(cert_provider=AsyncMock(), metadata={}, logger=AsyncMock())
fake_client = AsyncMock()
with patch('ingestor.connection.pool.Client', return_value=fake_client):
c1 = await pool.get('srv', {'url': 'opc.tcp://h:4840', 'use_security': False})
c2 = await pool.get('srv', {'url': 'opc.tcp://h:4840', 'use_security': False})
assert c1 is c2
assert fake_client.connect.await_count == 1
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `ingestor/connection/pool.py` — `OpcConnectionPool` keyed by `server_id`:
- `async get(server_id, server_cfg)`: if a live client exists, return it; else create `Client(url, timeout=10)`, if `use_security` call `apply_security(...)` with certs from `cert_provider.fetch()`, `await client.connect()`, store, emit `OPC_CONNECTIONS_TOTAL`/`OPC_CONNECTION_STATUS`. On failure emit `OPC_CONNECTIONS_FAILED`, drop entry, re-raise.
- `async release_idle(max_idle_s)`: disconnect clients unused past TTL (track last-use via `MonotonicClock`), emit `OPC_DISCONNECTIONS_TOTAL`.
- `async close_all()`.
- Reuse the existing `OpcManager.disconnection_fallback` pattern for robust disconnect.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): OpcConnectionPool (per-server, reuse, idle-evict)"`
---
### Task 7.end: F0 close-out (see [Phase Delivery](#phase-delivery--commit-ledger))
- [x] Full `pytest tests` green + `ruff` clean (162 passed).
- [x] F0 commits recorded in the [Commit Ledger](#commit-ledger).
- [ ] PR intentionally NOT opened yet — opened only after F4 closes.
---
## Phase F1 — Subscription reads with multi-name fan-out (contract C1)
### Task 8: Slot v2 loader
**Files:**
- Create: `ingestor/io/slots.py`, `ingestor/io/__init__.py`
- Test: `tests/unit/io/test_slots.py`
- [ ] **Step 1: Failing test** — parse the C1 structure incl. `names[]` and `active_read`.
```python
# tests/unit/io/test_slots.py
from ingestor.io.slots import parse_slot
RAW = {'server1': {'server_id': '1', 'url': 'opc.tcp://h:4840', 'use_security': True,
'subscription_period_ms': 1000, 'tags': {'ns=2;i=2': {'frequency': '15000',
'active_read': {'enabled': True, 'cycle_multiple': 3},
'names': [{'collection': 'raw_x', 'tag_name': 'Counter', 'model_name': 'm1', 'model_id': '1'},
{'collection': 'raw_y', 'tag_name': 'Contador', 'model_name': 'm2', 'model_id': '2'}]}}}}
def test_parse_slot_multi_name():
slot = parse_slot(RAW)
tag = slot['server1']['tags']['ns=2;i=2']
assert len(tag['names']) == 2
assert tag['active_read']['cycle_multiple'] == 3
```
- [ ] **Step 2–4:** implement `parse_slot(raw)` (validate shape, default `active_read={'enabled': False}`, ensure `names` is a list); run to green.
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): slot v2 loader (contract C1)"`
---
### Task 9: DataSink.publish_reading (MongoDB fan-out)
**Files:**
- Create: `ingestor/io/data_sink.py`
- Test: `tests/unit/io/test_data_sink_publish.py`
- [ ] **Step 1: Failing test** — one reading with N names publishes N documents (one per collection), each carrying `tag_name`/`model_id`.
```python
# tests/unit/io/test_data_sink_publish.py
from unittest.mock import AsyncMock
import pytest
from ingestor.io.data_sink import DataSink
@pytest.mark.asyncio
async def test_publish_reading_fans_out_per_name():
dm = AsyncMock()
sink = DataSink(data_manager=dm, postgres=AsyncMock(), logger=AsyncMock())
names = [{'collection': 'raw_x', 'tag_name': 'Counter', 'model_id': '1', 'model_name': 'm1'},
{'collection': 'raw_y', 'tag_name': 'Contador', 'model_id': '2', 'model_name': 'm2'}]
await sink.publish_reading(tag='ns=2;i=2', value=42, timestamp='2026-07-13T00:00:00Z', names=names)
assert dm.publish.await_count == 2
```
- [ ] **Step 2–4:** implement `DataSink.publish_reading(tag, value, timestamp, names)` — loop `names`, build `{tag, name: tag_name, model_id, timestamp, value}`, call `self.data_manager.publish(collection, doc)` (reuses existing MongoDB egress). Emit `OPC_TAG_VALUE`. Run to green.
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): DataSink.publish_reading multi-name fan-out"`
---
### Task 9b: Remove the legacy Kafka egress
Simplification requested by the feature owner (2026-07-14): the Gateway persists reads to MongoDB only.
**Files:**
- Modify: `ingestor/managers/data_manager.py` (remove the `KafkaProducer`, the Kafka branch of `publish`, and `shutdown` of the producer)
- Modify: `ingestor/metrics.py` (remove `KAFKA_MESSAGES_SENT`, `KAFKA_MESSAGES_ERRORS`, `KAFKA_CONNECTION_STATUS`)
- Modify: `.env.example` (remove `EXPORT_TO_KAFKA`, `KAFKA_SERVERS`)
- Modify: affected tests (`tests/unit/managers/test_data_manager.py`, any metrics test referencing `kafka_*`)
- [ ] **Step 1:** grep for `kafka` across `ingestor/` and `tests/` to enumerate every reference.
- [ ] **Step 2:** remove the Kafka producer/branch/config/metrics; keep the MongoDB path intact.
- [ ] **Step 3:** update/trim the affected tests so they no longer assert Kafka behavior.
- [ ] **Step 4:** run the full suite (`python -m pytest tests -v`) and `ruff check` — all green.
- [ ] **Step 5: Commit** — `git commit -am "refactor(gateway): remove legacy Kafka egress (MongoDB-only)"`
---
### Task 10: SubscriptionEngine (fan-out on datachange)
**Files:**
- Create: `ingestor/engines/subscription.py`, `ingestor/engines/__init__.py`
- Test: `tests/unit/engines/test_subscription.py`
- [ ] **Step 1: Failing test** — a datachange for an address with 2 names calls `DataSink.publish_reading` once with both names; resets cycle/non-receive counters; records `OPC_READ_LAG`.
```python
# tests/unit/engines/test_subscription.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.engines.subscription import SubscriptionEngine
@pytest.mark.asyncio
async def test_datachange_fans_out():
sink = AsyncMock()
eng = SubscriptionEngine(pool=AsyncMock(), sink=sink, logger=AsyncMock(), metadata={})
eng.nodes['ns=2;i=2'] = {'names': [{'tag_name': 'a'}, {'tag_name': 'b'}],
'cycle_rule': {'cycle_increment': 1, 'cycle_count': 3}}
node = MagicMock(); node.__str__ = lambda self: 'ns=2;i=2'
data = MagicMock()
data.monitored_item.Value.Value.Value = 42
await eng.datachange_notification(node, 42, data)
assert sink.publish_reading.await_count == 1
_, kwargs = sink.publish_reading.call_args
assert len(kwargs['names']) == 2
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `SubscriptionEngine` by adapting `ingestor/managers/opc_manager.py`:
- Subscriptions created via `pool.get(...)` + `client.create_subscription(period, self)` (engine is the callback handler).
- `datachange_notification` extracts value + `SourceTimestamp`, computes lag (`OPC_READ_LAG` = wall_now − source_ts) and calls `await self.sink.publish_reading(tag, value, ts, names=self.nodes[tag]['names'])` (fan-out — replaces the old per-`topics` loop).
- Keep `check_cycles`/`check_opc_listenning` behavior.
- Slot lease/heartbeat: reuse `ResourceManager` (unchanged); the engine consumes `parse_slot(...)` output.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): SubscriptionEngine multi-name fan-out (C1)"`
---
### Task 10.end: F1 close-out (see [Phase Delivery](#phase-delivery--commit-ledger))
- [x] Full `pytest tests` green (168) + `ruff` clean.
- [x] F1 commit SHAs recorded in the [Commit Ledger](#commit-ledger) (base `00f919c`).
- [ ] PR intentionally NOT opened yet — opened only after F4 closes.
---
## Phase F2 — Active write via NATS (contracts C2, C3)
### Task 11: WriteEngine
**Files:**
- Create: `ingestor/engines/write.py`
- Test: `tests/unit/engines/test_write.py`
- [ ] **Step 1: Failing tests** — (a) writes a `DataValue` with `StatusCode` Good + `SourceTimestamp` when flags on; (b) a converter failure returns an error result (not an exception) and increments `OPC_ERROR_BY_TYPE`; (c) rate-limit enforces a min pause between writes.
```python
# tests/unit/engines/test_write.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.engines.write import WriteEngine, WriteResult
@pytest.mark.asyncio
async def test_write_ok_sets_status_and_timestamp():
node = AsyncMock()
client = MagicMock(); client.get_node.return_value = node
pool = AsyncMock(); pool.get.return_value = client
eng = WriteEngine(pool=pool, sink=AsyncMock(), logger=AsyncMock(), metadata={},
write_timestamp=True, write_quality=True, min_pause_s=0)
res = await eng.write({'server_id': 's', 'addr': 'ns=2;i=5', 'value': '42.5',
'data_type': 'float', 'model_id': '1', 'timestamp': '2026-07-13T00:00:00Z',
'schema': 'sientia_data', 'table_name': 't'})
assert isinstance(res, WriteResult) and res.ok
node.write_value.assert_awaited() # DataValue passed
@pytest.mark.asyncio
async def test_write_bad_type_returns_error_not_raise():
client = MagicMock(); client.get_node.return_value = AsyncMock()
pool = AsyncMock(); pool.get.return_value = client
eng = WriteEngine(pool=pool, sink=AsyncMock(), logger=AsyncMock(), metadata={}, min_pause_s=0)
res = await eng.write({'server_id': 's', 'addr': 'ns=2;i=5', 'value': 'abc',
'data_type': 'int', 'model_id': '1', 'timestamp': 't', 'schema': 'x', 'table_name': 't'})
assert not res.ok and res.status == 'error'
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `ingestor/engines/write.py`:
- `WriteResult` dataclass: `ok: bool`, `status: str` (`ok|error|session_bad`), `opc_status_code: str`, `message: str`.
- **Per-message flags (C2):** `write_timestamp`/`write_quality` arrive on each NATS message; resolve as `cmd.get('write_timestamp', self.write_timestamp)` (per-message wins; config is the fallback default). Do not hard-code engine-global-only behavior.
- `async write(cmd)`: rate-limit (min pause via `MonotonicClock`); `value = to_typed_value(cmd['value'], cmd['data_type'])` inside try/except → on `ConversionError` return error `WriteResult` + `OPC_ERROR_BY_TYPE`; build `ua.DataValue(ua.Variant(value, ua_type), StatusCode_=ua.StatusCode(ua.StatusCodes.Good) if write_quality else None, SourceTimestamp=parse(ts) if write_timestamp else None)`; `node = client.get_node(addr); await node.write_value(dv)`; map OPC exceptions → `error_type` for `OPC_ERROR_BY_TYPE`; emit `OPC_WRITE_TOTAL`/`OPC_WRITE_LATENCY`/`OPC_WRITE_STATUS_BY_MODEL`; then `await self.sink.update_write_status(cmd, res)`.
- Quality downgrade `Good_Local_Override (0xD8)` when a clock problem is detected — leave a `# ponytail:` marker; wire only when NTP/clock detection is added.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): WriteEngine (status_code/timestamp/flags, safe converter, rate-limit)"`
---
### Task 12: DataSink.update_write_status (Postgres, contract C3)
**Files:**
- Modify: `ingestor/io/data_sink.py`
- Test: `tests/unit/io/test_data_sink_status.py`
- [ ] **Step 1: Failing test** — issues an `UPDATE <schema>.<table> SET write_status=... WHERE timestamp=... AND model_id=...`.
```python
# tests/unit/io/test_data_sink_status.py
from unittest.mock import AsyncMock
import pytest
from ingestor.io.data_sink import DataSink
from ingestor.engines.write import WriteResult
@pytest.mark.asyncio
async def test_update_write_status_targets_model_row():
pg = AsyncMock()
sink = DataSink(data_manager=AsyncMock(), postgres=pg, logger=AsyncMock())
cmd = {'schema': 'sientia_data', 'table_name': 'pred_m1',
'model_id': '1', 'timestamp': '2026-07-13T00:00:00Z'}
await sink.update_write_status(cmd, WriteResult(ok=True, status='ok', opc_status_code='Good', message=''))
assert pg.execute.await_count == 1
sql = pg.execute.call_args[0][0]
assert 'update' in sql.lower() and 'write_status' in sql.lower()
```
- [ ] **Step 2–4:** implement `update_write_status(cmd, result)` — parametrized UPDATE on `cmd['schema']`.`cmd['table_name']` filtered by `timestamp`+`model_id`; use the `sientia_do` Postgres client (`self.postgres`). **Never f-string the values** (SQL injection) — bind params; the identifiers (schema/table) are validated against `[A-Za-z0-9_]+`. Run to green.
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): DataSink.update_write_status Postgres (C3)"`
> **Cross-repo note (1975):** the column only appears on *new* per-model tables once Laborious's predictions DataFrame includes `write_status`; *existing* tables need the Flyway backfill `ALTER TABLE ... ADD COLUMN IF NOT EXISTS write_status`. Both are 1975/migrations work — this task only writes the value.
---
### Task 13: NatsWriteConsumer (contract C2)
**Files:**
- Create: `ingestor/adapters/nats_consumer.py`, `ingestor/adapters/__init__.py`
- Test: `tests/unit/adapters/test_nats_consumer.py`
- [ ] **Step 1: Failing test** — an incoming NATS message is JSON-decoded and dispatched to `WriteEngine.write`; malformed JSON is logged and dropped (no raise).
```python
# tests/unit/adapters/test_nats_consumer.py
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.adapters.nats_consumer import NatsWriteConsumer
@pytest.mark.asyncio
async def test_message_dispatched_to_write_engine():
we = AsyncMock()
consumer = NatsWriteConsumer(nats_url='nats://x:4222', write_engine=we, logger=AsyncMock())
msg = MagicMock(); msg.data = json.dumps({'server_id': 's', 'addr': 'ns=2;i=5'}).encode()
await consumer._handle(msg)
we.write.assert_awaited_once()
@pytest.mark.asyncio
async def test_bad_json_does_not_raise():
consumer = NatsWriteConsumer(nats_url='x', write_engine=AsyncMock(), logger=AsyncMock())
msg = MagicMock(); msg.data = b'not-json'
await consumer._handle(msg) # must not raise
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** `NatsWriteConsumer`:
- `async start()`: `self.nc = await nats.connect(self.nats_url)`; `await self.nc.subscribe('opc.write', queue='gateway-writers', cb=self._handle)` (queue group → exactly one instance handles each message).
- `async _handle(msg)`: `json.loads` in try/except (drop + log on error); `await self.write_engine.write(payload)` (fire-and-forget — no reply).
- `async stop()`: drain/close.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): NatsWriteConsumer (Core NATS, queue group) (C2)"`
---
### Task 13.end: F2 close-out (see [Phase Delivery](#phase-delivery--commit-ledger))
- [ ] Full `pytest tests` green + `ruff` clean.
- [ ] Record F2 commit SHAs (base + range) in the [Commit Ledger](#commit-ledger).
- [ ] Do NOT open the PR yet.
---
## Phase F3 — Active reads: scheduled + on-demand gRPC (contract C4)
### Task 14: ActiveReadEngine
**Files:**
- Create: `ingestor/engines/active_read.py`
- Test: `tests/unit/engines/test_active_read.py`
- [ ] **Step 1: Failing test** — `read_now(server_id, addr)` reads via pool, persists via `DataSink.publish_reading`, and returns value+status+timestamps.
```python
# tests/unit/engines/test_active_read.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.engines.active_read import ActiveReadEngine
@pytest.mark.asyncio
async def test_read_now_persists_and_returns():
dv = MagicMock(); dv.Value.Value = 42
node = AsyncMock(); node.read_data_value.return_value = dv
client = MagicMock(); client.get_node.return_value = node
pool = AsyncMock(); pool.get.return_value = client
sink = AsyncMock()
eng = ActiveReadEngine(pool=pool, sink=sink, logger=AsyncMock(), metadata={})
res = await eng.read_now('s', 'ns=2;i=2', names=[{'collection': 'c', 'tag_name': 't', 'model_id': '1'}])
assert res['value'] == 42 and res['persisted'] is True
sink.publish_reading.assert_awaited_once()
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** — `read_now(...)` (read via `node.read_data_value()`, emit `OPC_ACTIVE_READ_*`, persist, return dict) and `scheduled_tick(slot)` (iterate tags with `active_read.enabled`, read every `cycle_multiple` ticks, persist). Run to green.
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): ActiveReadEngine (scheduled + read_now)"`
---
### Task 15: gRPC proto + codegen
**Files:**
- Create: `ingestor/proto/opc_gateway.proto`
- Generate: `ingestor/proto/opc_gateway_pb2.py`, `_pb2_grpc.py`
- [ ] **Step 1: Write proto** (contract C4):
```proto
syntax = "proto3";
package opcgateway;
service OpcGateway {
rpc ReadTag(ReadTagRequest) returns (ReadTagResponse);
}
message ReadTagRequest { string server_id = 1; string addr = 2; string data_type = 3; }
message ReadTagResponse {
string value = 1; string opc_status_code = 2;
string source_timestamp = 3; string server_timestamp = 4; bool persisted = 5;
}
```
- [ ] **Step 2: Generate stubs**
Run: `python -m grpc_tools.protoc -I ingestor/proto --python_out=ingestor/proto --grpc_python_out=ingestor/proto ingestor/proto/opc_gateway.proto`
Expected: `opc_gateway_pb2.py` and `opc_gateway_pb2_grpc.py` created. Fix the generated import to be package-relative if needed.
- [ ] **Step 3: Commit** — `git commit -am "feat(gateway): gRPC proto + generated stubs (C4)"`
---
### Task 16: GrpcServer
**Files:**
- Create: `ingestor/adapters/grpc_server.py`
- Test: `tests/unit/adapters/test_grpc_server.py`
- [ ] **Step 1: Failing test** — `ReadTag` delegates to `ActiveReadEngine.read_now` and maps the dict to the response message.
```python
# tests/unit/adapters/test_grpc_server.py
from unittest.mock import AsyncMock
import pytest
from ingestor.adapters.grpc_server import ReadTagServicer
from ingestor.proto import opc_gateway_pb2 as pb
@pytest.mark.asyncio
async def test_readtag_maps_response():
eng = AsyncMock()
eng.read_now.return_value = {'value': '42', 'opc_status_code': 'Good',
'source_timestamp': 'ts', 'server_timestamp': 'ts2', 'persisted': True}
servicer = ReadTagServicer(active_read=eng, slot_lookup=AsyncMock())
resp = await servicer.ReadTag(pb.ReadTagRequest(server_id='s', addr='ns=2;i=2'), None)
assert resp.value == '42' and resp.persisted is True
```
- [ ] **Step 2–4:** implement `ReadTagServicer` (async grpc.aio) + `GrpcServer.start()/stop()` binding `grpc.aio.server()` on the configured port; look up `names` for the address from the slot layer (or accept none). Run to green.
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): gRPC server ReadTag (C4)"`
---
### Task 16.end: F3 close-out (see [Phase Delivery](#phase-delivery--commit-ledger))
- [ ] Full `pytest tests` green + `ruff` clean.
- [ ] Record F3 commit SHAs (base + range) in the [Commit Ledger](#commit-ledger).
- [ ] Do NOT open the PR yet.
---
## Phase F4 — Wiring, health, deploy, hardening
### Task 17: Config additions
**Files:**
- Modify: `ingestor/config.py` (or wherever env is read), `.env.example`
- Test: `tests/unit/test_config.py`
- [ ] **Step 1–4 (TDD):** add env: `NATS_URL`, `NATS_WRITE_SUBJECT` (default `opc.write`), `GRPC_PORT` (default `50051`), `GRPC_ENABLED`, `CERT_BUCKET` (default `sientia-dataops`), `CERT_CACHE_DIR` (default `/tmp/opc-certs`), `WRITE_TIMESTAMP`/`WRITE_QUALITY`/`WRITE_MIN_PAUSE_S`, `NTP_ENABLED`. Test defaults. Run to green.
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): config for NATS/gRPC/certs/write flags"`
---
### Task 18: Wire engines + adapters into app
**Files:**
- Modify: `ingestor/app.py`, `ingestor/ingestor.py`
- Test: `tests/unit/test_app.py` (extend)
- [ ] **Step 1: Failing test** — app startup constructs pool + engines + adapters and sets `APP_UP=1`; shutdown stops adapters, closes pool, sets `APP_UP=0`.
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement** — in startup: build `OpcConnectionPool`, `DataSink`, `SubscriptionEngine`, `ActiveReadEngine`, `WriteEngine`, start `NatsWriteConsumer` and (if `GRPC_ENABLED`) `GrpcServer`; register them in the main loop and signal handlers. Reuse the existing `start_http_server(HTTP_METRICS_PORT)` for `/metrics`.
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit** — `git commit -am "feat(gateway): wire pool+engines+adapters into app"`
---
### Task 19: Health probe alignment (spec §9/§10)
**Files:**
- Modify: `ingestor/app.py` (ensure `APP_UP` is set on the `:9090/metrics` endpoint), deploy values (`values/opc-gateway.yaml` if present in this repo; otherwise note for the Helm repo).
- [ ] **Step 1:** confirm `app_up{pod_id=...} 1` is exposed on `:9090/metrics` (already emitted; verify the probe string `curl -sf http://localhost:9090/metrics | grep -q '^app_up{.*} 1'` matches).
- [ ] **Step 2:** if a `values/opc-gateway.yaml` lives here, set the exec probe + `waitForDependencies` (Gitea, MongoDB, Redis, Postgres, MinIO, NATS) + ServiceMonitor label `release: kube-prometheus-stack`. Otherwise add a `docs/deploy-notes.md` capturing these for the Helm repo.
- [ ] **Step 3: Commit** — `git commit -am "chore(gateway): app_up health probe + deploy notes"`
---
### Task 20: Dockerfile / entrypoint
**Files:**
- Modify: `Dockerfile` (or note for `sientia-container-images`)
- [ ] **Step 1:** ensure the image runs `ingestor.app`, non-root, `readOnlyRootFilesystem`-compatible (writable `/tmp` for `CERT_CACHE_DIR`); pin base image; no `latest`. Hadolint clean.
- [ ] **Step 2:** Run `hadolint Dockerfile` (or note).
- [ ] **Step 3: Commit** — `git commit -am "chore(gateway): dockerfile entrypoint + read-only-fs cert cache"`
---
### Task 21: Full test + lint sweep and 1879 checklist verification
**Files:** all
- [ ] **Step 1:** `python -m pytest tests -v` → all green.
- [ ] **Step 2:** `ruff check ingestor tests` → clean.
- [ ] **Step 3:** Walk spec §13 (1879) and check each box in code: Sha256, monotonic, status_code+timestamp, timestamp/quality flags, rate-limit, BUG-1/BUG-2, channel>session, OPC error typing, file/function size. Add any missing micro-test.
- [ ] **Step 4: Commit** — `git commit -am "test(gateway): full sweep + 1879 checklist verification"`
---
### Task 21.end: F4 close-out + open per-phase PRs (see [Phase Delivery](#phase-delivery--commit-ledger))
- [ ] Full `pytest tests` green + `ruff` clean; all F0–F4 phases complete, tested, working.
- [ ] Record F4 commit SHAs (base + range) in the [Commit Ledger](#commit-ledger).
- [ ] **Now** open one PR per phase (F0→F4) from the recorded SHA ranges, in order — this is the only point where PRs are opened.
---
## Deferred / out of this plan (tracked in spec §17)
- NATS provisioning in air-gapped Helm (platform).
- Moving 1569 cert write path to `sientia-dataops` (platform + 1569 app).
- gRPC caller identity / auth model (Vitor) → may add auth to `GrpcServer` later.
- Flyway `write_status` column migration + Laborious DataFrame change (1975 plan).
- JetStream durability (revisit if Core NATS loss becomes real).
## Open validation notes for the implementer
- Verify exact `sientia_do` signatures (`MongoDBRepository.find_one`, `MinioRepository.download_file`, Postgres `execute`) against the pinned version; adapt calls.
- `asyncua` `ua.DataValue`/`ua.Variant`/`StatusCode` construction: confirm the constructor kwargs on the pinned `asyncua` (1.1.5) before Task 11.
- The functional test against `sientia-dataops-opc_simulator` (sibling repo) is a good end-to-end smoke once F0–F3 land.