# Vendor Daemon & gRPC API β€” Status, Known Issues, and Plan to Fab-Readiness > **This is a forward-looking roadmap, not a description of shipped behaviour.** > Every item carries a status marker. Do not read an item as "done" unless it > says βœ…. (Last full audit: 2026-06-10.) > > Status legend: βœ… done Β· 🚧 in progress Β· ⬜ planned Β· ⚠️ risk/unknown ## What this is A vendor-facing **daemon** (`secs_gemd`) that runs the SECS/GEM engine as its own process and exposes a small, name-based, language-agnostic API over gRPC, so a tool's control software (in any language) can drive the equipment without linking C++ or knowing SEMI. See `proto/secsgem/v1/equipment.proto`. The point of the daemon model: it owns the durable HSMS relationship with the host and stays conformant while the tool software restarts/upgrades/crashes. ## Current status (2026-06-10, end of day) | Piece | Status | Notes | |---|---|---| | `proto/secsgem/v1/equipment.proto` | βœ… | v1 surface designed: universal + carrier/recipe/job tiers, `Subscribe` stream, health | | `HostCommandRegistry::set_handler` behaviour hook | βœ… | the engine seam for command behaviour; tested | | `EquipmentRuntime` (engine owner) | βœ… | tested (`test_runtime.cpp`); `secs_server` runs entirely on it (live GEM300 demo passes) | | `register_default_handlers` (the 56 GEM handlers as a library fn) | βœ… | `src/gem/default_handlers.cpp`; tested (`test_default_handlers.cpp`) | | gRPC/protobuf toolchain (Dockerfile + CMake codegen) | βœ… | grpc++ 1.51 / protoc 3.21; opt-in `SECSGEM_DAEMON`, graceful skip without grpc | | `secs_gemd`: `SetVariables` / `FireEvent` / `GetControlState` / `GetVariables` | βœ… | **format-aware** both directions (declared SECS-II formats on write, `from_item` on read) and thread-safe (snapshot maps + posted writes + `read_sync` reads). In-process gRPC tests incl. run_async production mode (`test_daemon_service.cpp`, 61 assertions) | | Daemon interop vs **secsgem-py** reference host | βœ… | `interop/daemon_interop.py` (via `gemd` compose service): gRPC `SetVariables(ChamberPressure=2.5)` + `FireEvent` β†’ host receives `S6F11 CEID 300` carrying `` β€” value *and declared format* flow gRPCβ†’engineβ†’HSMSβ†’host | | Daemon interop vs **secs4j** (Java) | ⬜ | mirror the secsgem-py harness against `interop/secs4j` | | `Subscribe` hostβ†’tool command stream + `CompleteCommand` | βœ… | HCACK-4 contract implemented + tested in-process AND live vs secsgem-py (full loop: S2F41 β†’ stream β†’ complete β†’ S6F11) | | Universal RPC surface complete (vars/events/alarms/control-state/health) | βœ… | Phase A done; daemon tests 101 assertions, interop 15 checks | | Python client package (the "beautiful API") | ⬜ | thin wrapper over generated stubs | ## Known issues (found in the 2026-06-10 audit; honest list) - βœ… ~~**`GetControlState` cross-thread read.**~~ Fixed 2026-06-10: the runtime keeps an atomic control-state mirror updated via an `add_state_change_handler` observer (`HandlerSlot` primary+observers pattern), so the mirror survives `register_default_handlers` claiming the primary slot. `control_state()` is now safe from any thread. - βœ… ~~**Alarms have no name key.**~~ Optional `name:` added to the alarm config (loader + validator + shipped equipment.yaml); daemon RPCs accept the name or the stringified ALID. - ⬜ **`pvd_tool` predates the behaviour hook.** It still hard-codes `if (rcmd=="START") recipe->start(...)` in a router handler. Migrate it to `commands.set_handler` so the flagship example showcases the intended seam. - βœ… ~~**Interop harnesses are manual.**~~ `tools/run_interop.sh` runs all nine validation steps with one command (verified green); CI lanes added, pending first-push verification (Phase 0 item 2). - βœ… ~~**TSan lane doesn't cover the daemon.**~~ Covered locally + in CI with `tools/tsan.supp` (third-party-only suppressions). Caught + fixed a real test-side contract violation on its first run. - ⚠️ **macOS bind-mount staleness can break Docker builds mid-edit** (a build reading a half-synced source file). Not a product bug; re-run the build. ## The `Subscribe` design (settled β€” implement to this) `S2F42` is an *acknowledgement*, not a completion: SEMI separates "I accept your command" from "the work finished". The conformant, non-blocking flow: 1. Host sends `S2F41 START`. The engine's `on_command` handler (registered by the daemon) runs on the io thread. 2. If no tool client is subscribed β†’ fall back to the YAML declarative ack. If a tool is subscribed β†’ push the command onto its `Subscribe` stream and **return `HCACK=4` (AcceptedWillFinishLater) immediately** β€” never block the io thread or the T3 window on the tool. 3. The tool does the work and reports the outcome via `FireEvent` (success event) / `SetAlarm` (failure) β€” exactly how secsgem-py applications and commercial gateways do it. 4. `CompleteCommand` therefore only correlates/audits the command lifecycle in v1. A *synchronous gating* mode (tool decides HCACK 0/2 before the S2F42 goes out) requires a deferred-reply mechanism in the engine β€” explicitly a v2 refinement, not needed for conformance. Sub-decisions (settled 2026-06-10, implemented + tested): - v1 is a firehose: every subscriber receives every host request. - NO buffering: with no subscriber a command takes its declarative YAML ack and is not replayed on reconnect β€” never "will finish later" for work no tool will do. Documented in the proto's Subscribe contract. ## Plan β€” ordered next steps ### Phase 0 β€” structural debts (from the 2026-06-10 design review; pay before sprinting) The review's verdict: architecture and API bets are sound, but two structural debts tax every later phase, and the most valuable tests aren't automated. 1. 🚧 **Multi-observer callbacks** (THE structural blocker β€” hit twice already). `HandlerSlot` (primary slot keeps legacy set_ semantics; append-only add_ observers survive it) β€” done for `ControlStateMachine` + PJ/CJ stores, plus runtime atomic control-state mirror (race retired) and `add_link_observer` (WatchHealth foundation). ⬜ Remaining: roll the same 3-line pattern onto the other single-slot classes (comm-state, EPT, exceptions, substrates, modules, carriers, E84) as each phase needs them β€” mechanical now that the type exists. 2. 🚧 **CI the interop + conformance harnesses.** `tools/run_interop.sh` βœ… β€” one command runs ALL nine validation steps (build, unit, daemon-unit, py-host 31 checks, conformance 47, daemon bridge, spool restart, tshark, secs4j 55) with a PASS/FAIL summary; verified green end-to-end 2026-06-10. CI βœ… added but UNVERIFIED until pushed: grpc deps + `secs_gemd_tests` in the build job (fails loudly if the daemon silently drops out), and a new `python-interop` lane (py-host + conformance + daemon harness against localhost, no docker-in-docker). ⬜ Verify the lanes on the first push. 3. βœ… **Fix `CompleteCommand` proto comment** β€” it described the rejected blocking model; now states the HCACK-4 contract. 4. βœ… **Table-driven handler conformance test** β€” one ordered scenario drives 53 of the 56 handlers through `router.dispatch` (236 assertions). Golden frames: S1F13, S5F1, and a composed S6F11, all hand-computed from E5 rules (external pins, not codec-derived). 5. ⬜ **Decompose `register_default_handlers` per GEM capability** (it is a relocated main(), not a designed component) and replace magic constants (SVIDs 1/2 `refresh()`, CEIDs 400/401) with YAML role bindings (`control_state_svid:`, `cj_executing_ceid:` …). Gradual; aligns with the capability structure GEM itself defines (S1F19) and enables vendor subsetting. 6. βœ… **Standardize the mutable-read pattern** β€” `EquipmentRuntime::read_sync` (post-to-io + future with deadline; nullopt => UNAVAILABLE at the RPC edge). Precedent set by `GetVariables`; every future mutable read copies it. 7. βœ… `equipment_service.hpp` moved to `include/secsgem/daemon/` (apps/ include-path hack removed). TSan daemon lane added locally + in CI (`tools/tsan.supp` suppresses UNinstrumented system libgrpc/libabsl internals only β€” our frames stay checked). The lane caught a real contract violation on its first run (a test reading the model from the test thread under run_async β€” fixed to read_sync); now TSan-clean with halt_on_error=1. 8. βœ… Identifier-safe name validation: `ConfigValidator` warns (not errors) on non-identifier variable/event/alarm/command names β€” bindings expose names as kwargs/attributes. Format-compliance property test βœ…; unset- `Value` guard βœ…. ### Phase A β€” finish the universal daemon surface (small, unblock vendors) 1. βœ… `GetVariables` β€” `from_item` reverse conversion (scalar for 1-element arrays, List otherwise; C2-as-text and U8>2^63 noted as TODOs) + reads via `read_sync`. Tested under **run_async (production threading)** β€” write through the API, read back through the API β€” plus empty-query-returns-all, INVALID_ARGUMENT on unknown names, and a live round-trip check in `daemon_interop.py`. 2. βœ… Alarm `name:` config field (optional local key; `name` appended LAST on the Alarm struct so existing brace-inits compile unchanged) + `SetAlarm`/ `ClearAlarm` RPCs (addressable by config name AND stringified ALID). Validated end-to-end: gRPC `SetAlarm(chiller_temp_high)` -> secsgem-py host receives `S5F1 ALCD=0x84 ALID=1`. 3. βœ… `RequestControlState` β€” fires operator events on the io thread and reports what the E30 table actually did (ACCEPT iff landed in the requested state; the shipped table has NO operator path to EquipmentOffline and the test pins that honesty). βœ… `WatchHealth` β€” initial snapshot + push on link/control-state change (+ spool depth sampled at 500ms); unit-tested incl. the change push; link state still SELECTED/DISCONNECTED only (CONNECTED reserved, TODO in code). Interop covers RequestControlState; WatchHealth external check rides with Phase B. 4. βœ… Done per-item above (daemon suite at 101 assertions; interop at 15 checks). ### Phase B β€” the command stream (the big one) 5. βœ… `Subscribe`/`CompleteCommand` implemented per the HCACK-4 design. Reconnect decision settled and documented in the proto: **no buffering** β€” a command with no subscriber takes its declarative YAML ack (the honest pre-daemon behaviour) and is not replayed. Firehose fan-out; per-command forwarding handlers registered from the registry (new `names()`/`spec()` accessors); pending-id audit map. In-process tests drive a REAL S2F41 through the default-handler router on the io thread: HCACK 4 with a subscriber (params arrive on the stream), declarative Accept without, CompleteCommand known/unknown ids, fallback restored after unsubscribe. 6. βœ… The full conformant loop runs against secsgem-py live: host `S2F41 START` β†’ `S2F42 HCACK=4` β†’ tool receives Command(name=START, id) on the stream β†’ `CompleteCommand` β†’ tool fires the event β†’ host receives `S6F11`. (interop now 20 checks.) 7. ⬜ Java interop: `secs4j` host variant of the same scenario. ### Phase C β€” the beautiful Python client 8. ⬜ `clients/python/` package (`pip install secsgem-client`): wraps generated stubs in the agreed API β€” `eq.set(chamber_pressure=2.5)`, `eq.fire("wafer_complete", thickness=1.2)`, `eq.alarm("pressure_high")`, `@eq.on("START")` consuming the stream, `eq.health()`. Pure Python (no compiled ext). Ship stubs pre-generated. 9. ⬜ Example: rewrite a minimal `pvd_tool`-equivalent in ~40 lines of Python against the daemon; also migrate the C++ `pvd_tool` to `set_handler`. ### Phase D β€” GEM300 in-the-loop (process/carrier tools) 10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe β€” see proto comments), then wire `ProcessJob`/`CarrierAction` onto the stream + `ReportProcessJob`/`ReportCarrier` into the PJ/CJ/carrier stores. 11. ⬜ Recipe download (`ProcessProgram` on the stream when S7F3 lands) and EC-change notification (`ConstantChange` when S2F15 lands). 12. ⬜ Interop scenarios for jobs/carriers vs secsgem-py + secs4j. ### Phase E β€” hardening & operations 13. ⬜ gRPC exposure: default to localhost + document UDS; optional TLS creds. 14. ⬜ `tools/run_interop.sh` + CI lanes: all interop harnesses + TSan daemon lane. 15. ⬜ Daemon Prometheus metrics + supervised deployment recipe (systemd unit). 16. ⬜ Remaining Layer-1 API: traces, limits, substrates/modules, terminal services, spool depth/flush, `Describe` RPC. ### Phase F β€” fab acceptance (parallel track; the hard gate) - ⚠️ **Standards correctness remains unverified against SEMI texts** (behaviour reconstructed without the standards; interop with secsgem-py/secs4j/Wireshark mitigates but does not prove). The #1 fab-readiness risk; needs real standards access and/or a fab's MES qualification run (`docs/MES_INTEROP.md`). - ⬜ GEM compliance statement + manual matching the tool's data dictionary. - ⬜ SECS-I serial driver (asio `serial_port` adapter; FSM done) β€” only if a target tool uses RS-232.