# Integration tutorial How a semiconductor equipment vendor takes this library and turns it into a SECS/GEM-compliant interface on a real tool. The library gives you **the runtime stack** — wire codecs, the HSMS connection state machine, every GEM 300 sub-state-machine, persistent stores, the message catalog, and a dispatcher. What you bring is **the application**: knowledge of your tool's real sensors, recipes, alarms, processing states, and chamber I/O. This guide walks through how those two halves meet. > **Audience.** Firmware / controls engineers integrating > SECS/GEM on a tool for the first time. Familiarity with SEMI > E5/E30/E37 helps but isn't required — every spec reference is > pinned in `COMPLIANCE.md`. --- ## 0. Three ways to integrate — pick your tier This guide covers the **embedded C++** path. Since the daemon track landed there are two higher-level options that need no C++ at all: | Tier | You write | Best for | |---|---|---| | **Python client** ([clients/python](../clients/python)) | ~25 lines of Python against `secs_gemd` | new tools, lab/R&D, fastest start | | **gRPC, any language** ([proto/secsgem/v1](../proto/secsgem/v1/equipment.proto)) | a thin client in Go/C#/Java/… | existing controllers, multi-process tools | | **Embedded C++** (this guide) | a main() over `gem::EquipmentRuntime` | in-process integration, custom transports | In the daemon tiers `secs_gemd` owns the durable HSMS link — your tool software can crash/upgrade/restart without the fab host noticing. See [DAEMON_ROADMAP.md](DAEMON_ROADMAP.md) for status. --- ## 1. What you get vs. what you build ``` ┌───────────────────────────────────────────────────────────┐ │ your equipment application (you write) │ │ recipe runner • sensor polling • alarm sources • UI hooks│ ├───────────────────────────────────────────────────────────┤ │ secs-gem runtime stack (this library) │ │ data model • FSMs • SECS-II codec • HSMS connection │ │ message catalog • routers • persistence • spool │ ├───────────────────────────────────────────────────────────┤ │ OS + Asio (provided) + your serial/Ethernet driver │ └───────────────────────────────────────────────────────────┘ ``` The boundary lives at three classes: - `gem::EquipmentDataModel` — the data dictionary (SVIDs, ECIDs, CEIDs, alarms, recipes, jobs, carriers, substrates …). Your application reads/writes it; the dispatcher serves it on the wire. - `gem::Router` — maps `(stream, function) → handler`. Wire it once at startup; messages flow through it. - `hsms::Connection` (or `secsi::TcpTransport` for SECS-I) — the byte-level transport. You feed it a TCP socket and a router; it runs. You don't subclass the FSMs. You don't write parsers. You don't patch the dispatcher. Your code lives in two places: **YAML** (static configuration) and **callbacks** (dynamic glue). --- ## 2. The 30-minute first connection The shortest path from `git clone` to "a host can talk to my tool": ### 2.1. Describe your tool in YAML Copy `data/equipment.yaml`, rename to your tool, and edit: ```yaml device: id: 1 # E37 SESSION-ID model_name: "ACME-PVD-3000" software_rev: "1.4.2" equipment_type: "PVD" # S1F20 EQPTYP svids: # status variables (read-only) - {id: 1, name: ControlState, units: "", type: ASCII, value: ""} - {id: 100, name: ChamberPressureTorr, units: "Torr", type: F4, value: 0.0} - {id: 101, name: WaferCounter, units: "wafer", type: U4, value: 0} ecids: # equipment constants (host can set) - {id: 10, name: ChamberSetpointTorr, units: "Torr", type: F4, value: 1.0e-6, min: "1.0e-9", max: "1.0"} ceids: # collection events - {id: 300, name: ProcessStarted} - {id: 301, name: ProcessCompleted} alarms: - {id: 1, text: "Chamber pressure out of range", category: 4} recipes: - {id: "RECIPE-A", body: "STEP HEAT 350C 60s\nEND"} host_commands: - {name: START, ack: Accept, emit_ceid: 300} - {name: STOP, ack: Accept} ``` That's the GEM data dictionary. The library will serve every S1F3/F11, S2F13/F29, S2F33-F38, S5F5, S7F19, S2F41, etc. against this YAML without any C++ changes. ### 2.2. Stand it up A minimal `main()` looks like `apps/secs_server.cpp`. In your code: ```cpp auto model = std::make_shared(); auto desc = config::load_equipment("/etc/acme/equipment.yaml", *model); auto sm = std::make_shared( gem::ControlStateMachine::default_table(), gem::ControlState::HostOffline); asio::io_context io; Server server(io, {/*port=*/5000, desc.device_id, {}}); server.on_accept([&](std::shared_ptr conn) { auto router = std::make_shared(); register_default_handlers(*router, model, sm, conn); // your function conn->set_message_handler([router, conn](const secs2::Message& m) { return router->dispatch_with_s9( [&](uint8_t f, const std::array& mhead) { conn->emit_s9(f, mhead); }, [&]() -> std::optional> { auto* h = conn->current_header(); return h ? std::optional{h->encode()} : std::nullopt; }, m); }); }); server.start(); io.run(); ``` `register_default_handlers` is the only piece of glue you write at the start. The repo's `apps/secs_server.cpp` is a complete worked example — copy it verbatim, then customize the YAML to your tool. ### 2.3. Validate before you run YAML edits are easy to get wrong: an unknown SECS-II type, a duplicate ID, a `host_command` referencing a CEID you forgot to declare. The server has a `--validate-config` mode that reads every YAML, accumulates *every* problem it can find, prints them with file and line number, and exits 0 / 1 without binding the port: ```sh secs_server --validate-config \ --config /etc/acme/equipment.yaml \ --state-table /etc/acme/control_state.yaml \ --pj-state-table /etc/acme/process_job_state.yaml \ --cj-state-table /etc/acme/control_job_state.yaml # [error] equipment.yaml:5 svids[0].type — unknown SECS-II type `WTF` # [error] equipment.yaml:7 alarms[0].category — value 200 out of range [0, 127] # [error] equipment.yaml:9 host_commands[0].emit_ceid — CEID 999 not declared in `ceids` section # 3 error(s), 0 warning(s) across 4 files ``` Run this in CI on every config change and you skip the slow load-fail-edit-restart loop the first deployment otherwise becomes. ### 2.4. Run it ```sh docker compose up server # or your own deployment # host fires up secsgem-py / wonderware / equipment manager: # selects, S1F13, S1F1, S1F3 → you're talking GEM. ``` That's the floor. From here, every section below adds capability. --- ## 3. Wiring real sensors to SVIDs The YAML's `value:` field is the *initial* value. Your application updates the live value as the tool runs. > **Thread-safety contract.** Every store in `EquipmentDataModel` is > single-threaded by design: there are no locks. All access — reads > from the dispatcher, writes from your application — must run on the > io_context that drives the HSMS connection. If your sensor polls > live on a different thread (typical), marshal the update via > `asio::post`: > > ```cpp > // Sensor-poll thread (separate from the io_context thread): > double torr = read_baratron(); > asio::post(io.get_executor(), [model, torr] { > model->svids.set_value(/*ChamberPressure=*/100, > secs2::Item::f4(float(torr))); > }); > ``` > > Calling `set_value(...)` directly from the sensor thread is a data > race against the dispatcher reading the same SVID for an inbound > S1F3 — the library has no mutex to defend you. This is also true > for every `set_*_change_handler` callback you register: those fire > on the io_context thread, and any state observers (metrics > exporters, log shippers) must be thread-safe themselves or must > hand the work off. Two patterns scale well: 1. **One updater per sensor, fixed cadence.** Each sensor's driver owns the (vid, set_value) pair and `asio::post`s into the io_context. 2. **A single refresh tick.** A periodic timer dumps all polled values at once (`refresh()` in `apps/secs_server.cpp` does this for two virtual SVIDs). Because the periodic timer runs *on* the io_context, no posting is needed. The SECS-II Item shape must match the YAML's `type:`. If the YAML says `F4` and you call `set_value(100, secs2::Item::ascii("..."))`, the host will get the string back — the library doesn't enforce a runtime check. Treat the YAML type as a contract you maintain. --- ## 4. Plugging the FSMs into your tool Every GEM 300 sub-state-machine in the library is a behavior model. You decide *when* state transitions happen by firing events: ### 4.1. Equipment processing (E116 EPT) ```cpp // At startup or whenever the operator clicks "Run": model->ept.on_event(gem::EptEvent::EnterStandby); model->ept.on_event(gem::EptEvent::EnterProductive); // Auto-emit S6F11(ControlEvent_*) on every transition: model->ept.set_state_change_handler( [&](gem::EptState, gem::EptState to, gem::EptEvent, std::chrono::milliseconds /*dwell*/) { uint32_t ceid = ept_state_to_ceid(to); // your switch/case if (!ceid || !model->is_event_enabled(ceid)) return; conn->send_data(gem::s6f11_event_report( next_dataid++, ceid, model->compose_reports_for(ceid))); }); ``` Helpers: - `model->ept.accumulated(state)` — total milliseconds spent in `state` since startup. Use it to populate E116 SVIDs. - `model->ept.reset_history()` — call at shift boundary. ### 4.2. Carriers + load ports (E87) When AMHS delivers a carrier: ```cpp model->carriers.create("CAR-A1B2", /*port=*/1, /*capacity=*/25); model->carriers.fire_id_event("CAR-A1B2", gem::CarrierIDEvent::Read); // ... host sends S3F17(ProceedWithCarrier), the registered handler // in the Router calls fire_id_event(..., ProceedWithCarrier) and // CIDS moves NotConfirmed → Confirmed. ``` When your slot-map scanner finishes: ```cpp auto* c = model->carriers.get("CAR-A1B2"); for (std::size_t i = 0; i < scan_result.size(); ++i) c->slots[i].state = scan_result[i]; // 0 empty, 1 occupied model->carriers.fire_slot_map_event("CAR-A1B2", gem::SlotMapEvent::Read); ``` The S3F19/F20 verify handler will compare against this map. ### 4.3. Substrates (E90) For each wafer you start tracking: ```cpp model->substrates.create("W-2024-001", "CAR-A1B2", /*slot=*/1); model->substrates.fire_location_event( "W-2024-001", gem::SubstrateEvent::Acquire, /*location=*/"ChamberA"); model->substrates.fire_processing_event( "W-2024-001", gem::SubstrateProcessingEvent::StartProcessing); // ... when done: model->substrates.fire_processing_event( "W-2024-001", gem::SubstrateProcessingEvent::EndProcessing); model->substrates.fire_location_event( "W-2024-001", gem::SubstrateEvent::Release, /*location=*/"OutCarrier"); ``` History is tracked per-substrate (`model->substrates.history(id)`) and can power your downtime / yield reports. ### 4.4. Process jobs + control jobs (E40 / E94) The host creates these via S16F11 / S14F9. Your application drives their internal transitions as the recipe engine progresses: ```cpp // Recipe runner reports setup done: model->process_jobs.fire_internal("PJ-1", gem::ProcessJobEvent::SetupComplete); // Operator hits Start (or autorun is on): model->process_jobs.on_host_command("PJ-1", gem::ProcessJobEvent::Start); // Recipe completed normally: model->process_jobs.fire_internal("PJ-1", gem::ProcessJobEvent::ProcessComplete); ``` CJ state cascades the same way (E94). ### 4.5. Alarms (E5 §13) ```cpp // Sensor crosses threshold: model->alarms.set(/*alid=*/1, /*set=*/true); // emits S5F1(ALCD=0x84) // Later it clears: model->alarms.set(1, false); // emits S5F1(ALCD=0x04) ``` The dispatcher takes care of the wire frame — you just toggle. ### 4.6. E84 parallel I/O handoff (AMHS) For each load port that talks to the AMHS robot: ```cpp #include "secsgem/gem/e84_asio_timers.hpp" auto* fsm = model->e84_ports.get(/*port_id=*/1); if (!fsm) { model->e84_ports.create(1); fsm = model->e84_ports.get(1); } // SEMI E84 §6 handshake timers. Defaults below are spec-typical; tune // per port. TA1=AMHS waits for L_REQ/U_REQ after VALID; TA2=equipment // waits for BUSY after port is ready; TA3=BUSY phase budget. fsm->set_timeouts({std::chrono::seconds(2), std::chrono::seconds(2), std::chrono::seconds(60)}); // Wire arm/cancel into asio so the FSM polices the real wall clock. auto driver = std::make_shared(io.get_executor(), *fsm); driver->attach(); // Keep `driver` alive for the lifetime of the FSM (e.g. as a member // of your per-port object). // Optional: log handoff faults. fsm->set_fault_handler([port_id = 1](gem::E84Fault reason) { log("E84 port " + std::to_string(port_id) + " fault: " + gem::e84_fault_name(reason)); }); // Now feed signal changes from your I/O bridge. On a real AMHS the // bridge polls or interrupts on the parallel-I/O lines: model->e84_ports.on_signal_change(1, gem::E84Signal::CS_0, true); model->e84_ports.on_signal_change(1, gem::E84Signal::VALID, true); // equipment side asserts when port is physically ready: model->e84_ports.on_signal_change(1, gem::E84Signal::L_REQ, true); // ... AMHS continues with BUSY / COMPT. ``` If TA1, TA2, or TA3 expires the FSM transitions to `HandoffFault` and the fault handler fires with the precise `E84Fault` reason. Your application is then responsible for whatever the tool's fault policy is (typically: assert your local ES line and raise an alarm). ### 4.7. Recoverable exceptions (E5 §9, S5F9–F18) For faults where you want a host/equipment recovery dialogue: ```cpp model->exceptions.post(/*exid=*/42, "VACUUM", "lost vacuum in chamber A", {"PURGE", "RECOVER", "ABORT"}); // emits S5F9 // Host picks PURGE via S5F13; the registered handler calls // model->exceptions.on_recover(42, "PURGE"), state moves to Recovering. // Your purge routine completes: model->exceptions.fire_internal(42, gem::ExceptionEvent::RecoveryComplete); // state → Cleared; S5F11 fires; entry removed. ``` --- ## 5. Persistence GEM equipment that loses power mid-job can recover gracefully because every store the library ships supports an opt-in file-backed journal. Enable per store, at startup, BEFORE the connection comes up: ```cpp auto base = std::filesystem::path("/var/lib/acme/secsgem"); model->spool.enable_persistence(base / "spool"); model->carriers.enable_persistence(base / "carriers"); model->load_ports.enable_persistence(base / "loadports"); model->substrates.enable_persistence(base / "substrates"); model->process_jobs.enable_persistence(base / "pjobs"); model->control_jobs.enable_persistence(base / "cjobs"); model->exceptions.enable_persistence(base / "exceptions"); ``` On enable, the store scans the directory, replays records into in-memory state, and from there keeps the directory in sync on every create / state-change / remove. Writes use a `.tmp + rename` pattern so a power loss mid-write can lose at most the in-flight record (older records remain coherent). Storage budget per store, roughly: - spool: one file per spooled S6F11 (typically tens of bytes each) - carriers: one file per carrier (~50 bytes + slot count) - load_ports: one file per LP (~30 bytes) - substrates: one file per wafer (~80 bytes) - pjobs: one file per active PJ (~100 bytes), plus `order.idx` - cjobs: one file per active CJ (~80 bytes) - exceptions: one file per Posted/Recovering exception Even a busy fab tool tops out at a few hundred files in each directory — well within filesystem caps. Sweep terminal-state entries (completed PJs, cleared exceptions) periodically if you care about directory size. Caveats currently captured in the persistence tests: - Substrate **history** is intentionally NOT journaled — only the *current* state of each axis. Replay starts with an empty history vector. - PJ `rcpvars` / `prprocessparams` (the optional E40 `secs2::Item` trailers) are not journaled in v1; call `set_e40_extras` again on the application side after restart if you need them. --- ## 6. Monitoring + observability ### 6.1. Connection lifecycle ```cpp conn->set_log_handler([](const std::string& m) { syslog(LOG_INFO, "hsms: %s", m.c_str()); }); conn->set_selected_handler([] { metrics.inc("hsms.selected"); }); conn->set_closed_handler([](const std::string& r) { metrics.inc("hsms.closed", {{"reason", r}}); }); ``` ### 6.2. State change observers Every store / FSM exposes a `set_*_change_handler`. Hook them up to your metrics / log pipeline: ```cpp model->control_jobs.set_state_change_handler( [](const std::string& cj, gem::ControlJobState f, gem::ControlJobState t, gem::ControlJobEvent) { log("CJ " + cj + " " + gem::control_job_state_name(f) + " → " + gem::control_job_state_name(t)); }); ``` ### 6.3. Self-emitted protocol errors Look for `S9F*` traffic in your logs. S9F3 / F5 mean the host asked for something your router doesn't handle; S9F7 means a bad body arrived; S9F9 means a reply didn't arrive in T3; S9F11 means a frame exceeded the 16 MiB cap. None of these are normal — they're real diagnostic events. ### 6.4. Prometheus exporter (worked example) `include/secsgem/metrics/prometheus.hpp` ships a minimal Registry + asio-backed HTTP server. Drop it next to your equipment and scrape from your fab's Prometheus. ```cpp #include "secsgem/metrics/prometheus.hpp" namespace metrics = secsgem::metrics; auto reg = std::make_shared(); reg->describe("secsgem_messages_total", "messages dispatched", metrics::MetricType::Counter); reg->describe("secsgem_alarms_active", "currently-active alarms", metrics::MetricType::Gauge); reg->describe("secsgem_spool_depth", "queued spool messages", metrics::MetricType::Gauge); reg->describe("secsgem_t_timer_total", "T-timer expiry by id", metrics::MetricType::Counter); // HTTP /metrics on :9090. Same io_context as the HSMS connection — // scraping runs on the strand, so updates and reads serialize for free. auto exporter = std::make_shared(io, 9090, reg); exporter->start(); // Wire counters into the connection + model hooks you already set up // in §6.1 / §6.2. These all fire on the io_context thread. conn->set_selected_handler([reg, conn] { reg->set_gauge("secsgem_hsms_selected", 1); }); conn->set_closed_handler([reg](const std::string& reason) { reg->set_gauge("secsgem_hsms_selected", 0); // T-timer expirations surface here as `reason` starting with "T". if (!reason.empty() && reason[0] == 'T') reg->inc("secsgem_t_timer_total", {{"timer", reason.substr(0, 2)}}); }); // Per-message dispatch — wrap your existing router.dispatch() call. auto orig_handler = conn->message_handler(); // (or whatever you set) conn->set_message_handler([reg, orig_handler](const secs2::Message& m) { reg->inc("secsgem_messages_total", {{"dir", "rx"}, {"stream", std::to_string(m.stream)}, {"function", std::to_string(m.function)}}); return orig_handler(m); }); // Push gauge snapshots from a periodic timer on the same io. auto poll = std::make_shared(io); std::function tick = [reg, model, poll, &tick](std::error_code ec) { if (ec) return; reg->set_gauge("secsgem_spool_depth", static_cast(model->spool.size())); std::size_t active = 0; for (auto& a : model->alarms.all()) if (model->alarms.active(a.id)) ++active; reg->set_gauge("secsgem_alarms_active", static_cast(active)); poll->expires_after(std::chrono::seconds(5)); poll->async_wait(tick); }; poll->expires_after(std::chrono::seconds(5)); poll->async_wait(tick); ``` What lands at `/metrics`: ``` # HELP secsgem_messages_total messages dispatched # TYPE secsgem_messages_total counter secsgem_messages_total{dir="rx",function="13",stream="1"} 42 # TYPE secsgem_spool_depth gauge secsgem_spool_depth 7 # TYPE secsgem_hsms_selected gauge secsgem_hsms_selected 1 ``` Wire this into your fab's Prometheus + Grafana and you've got the starter dashboard the README §3 table describes. The exporter has **no auth and no TLS** — drop nginx or Caddy in front with mTLS for production. --- ## 7. HSMS-GS: one tool, multiple MES Most fab tools talk to one MES. Some — particularly tools shared by multiple production lines or sites — need to serve two or more MES schedulers simultaneously over a single HSMS-GS connection. E37 §11 calls these "general sessions": one TCP socket, multiple session identifiers, independent SELECTED state per session. The library models this as additional sessions on the same `hsms::Connection`: ```cpp server.on_connection([](std::shared_ptr conn) { // Primary session (device_id=1) was registered by Server::Config; // add a second session for the second MES. conn->add_session(/*device_id=*/2); // Per-session message routing — each MES gets a distinct dispatcher, // distinct SVID views, distinct alarm enable state, distinct // recipe namespace if you want. Or share state via a common // EquipmentDataModel and just route messages here. conn->set_session_message_handler(1, [model_1](const secs2::Message& m) { return router_1.dispatch(m); }); conn->set_session_message_handler(2, [model_2](const secs2::Message& m) { return router_2.dispatch(m); }); // Per-session SELECT state observers. These fire when each MES // completes its Select.req handshake; independent of each other. conn->set_session_selected_handler(1, [] { log("MES-1 selected"); }); conn->set_session_selected_handler(2, [] { log("MES-2 selected"); }); }); ``` When the equipment emits an unsolicited primary (S5F1, S6F11, S10F1), choose the session explicitly: ```cpp // Alarm goes to MES-1 only. conn->send_data(/*session_id=*/1, gem::s5f1_alarm_report(0x84, 1, "high")); // Event report goes to both. auto event = gem::s6f11_event_report(0, 300, reports); conn->send_data(1, event); conn->send_data(2, event); ``` ### Active-mode (host side) GS The host (active) connection initiates Select.req for each registered session serially — session 1 first, then once 1 reaches SELECTED, session 2. Customers building a multi-tool fleet controller use the same `add_session` API on the `Client`-derived `Connection`: ```cpp client.on_connection([](std::shared_ptr conn) { conn->add_session(2); // a second tool's session conn->set_session_selected_handler(1, [] { /* tool A ready */ }); conn->set_session_selected_handler(2, [] { /* tool B ready */ }); }); ``` ### Rejection semantics A data frame whose `session_id` field doesn't match any registered session gets a Reject(EntityNotSelected) response, per E37 §7.7 — the peer's MES will see this and know to back off. See `tests/test_hsms_gs.cpp` for the wire-level coverage and `tests/test_hsms_gs_integration.cpp` for the end-to-end Server/Client pattern. --- ## 8. Recommended layout for a vendor application ``` /opt/acme-secsgem/ ├─ bin/ │ └─ secsgem-equipment # your built binary ├─ etc/ │ ├─ equipment.yaml # your tool's dictionary │ └─ control_state.yaml # your tool-specific state model ├─ var/ │ ├─ spool/ # populated at runtime │ ├─ carriers/ │ ├─ substrates/ │ ├─ pjobs/ │ ├─ cjobs/ │ └─ exceptions/ └─ share/ └─ doc/ # COMPLIANCE.md, INTEGRATION.md ``` Your application reads `etc/`, writes to `var/`, and never touches `share/`. YAML edits don't require a rebuild — restart the process. The control-state YAML is your tool's *processing* state machine — E30 deliberately leaves the concrete states (IDLE / SETUP / READY / EXECUTING / PAUSE / …) up to the tool builder. Copy `data/control_state.yaml` as a starting point. --- ## 9. Test the integration Don't ship without: 1. **Round-trip every host-facing message you serve.** The library's own test suite covers the codecs; you should also drive your YAML's specific SVIDs / CEIDs / alarms / recipes against the built-in passive server using the `interop/host_vs_cpp_server.py` harness as a template. 2. **Power-loss simulation.** Kill -9 the process mid-job, restart, confirm the stores replay the correct state. The persistence tests give you a template; copy and parameterize for your store directories. 3. **Multi-hour soak.** Spool fills up if persistence is enabled and the host link is down — make sure your fab's MES side ack-rate keeps up. Run a 24h test with the host periodically disconnecting and watch the journal directory. 4. **The two-container demo** in this repo gives you a starting harness — the host emulator (`apps/secs_client.cpp`) drives ~20 transactions through your server. Adapt it to your message set. --- ## 10. When to extend the runtime The library is open to extension. Common reasons to add code: - **A new SECS-II message** the catalog doesn't cover. Edit `data/messages.yaml`, run the codegen (built into the CMake pipeline), add a Router handler. No core code change. - **A new state machine** specific to your tool (e.g. an in-chamber cooling cycle FSM). Lift the pattern from `ept_state.hpp`: define your states + events + transition table; let your application drive it. - **An additional persistence backend** (DB instead of files). Mirror the spool `.enable_persistence` pattern — it's about 100 lines per store. If your change is broadly useful, it's worth landing in the library itself. See `COMPLIANCE.md` for the standards still on the "explicitly out of scope" list — anything there is a possible contribution. --- ## 11. Going from "stack" to "certified GEM tool" This codebase passes its own conformance harness and cross-validates against `secsgem-py`, but a real *certified* GEM tool needs more: - **Independent third-party validator**. Run a GEM RTS (Reference Test System) or equivalent against your integration. The library serves the messages; the validator decides whether your data is consistent. - **Vendor application code**. The runtime cannot, by design, know what your SVID values *should* be at any given moment. That's your domain knowledge plugged into the data model and FSMs. - **Documentation**. E30 §6.10 (Documentation capability) requires you to publish what you implement. `COMPLIANCE.md` in this repo is the template — fork it, prune to your actual coverage, ship it with your tool. - **Operations**: monitoring dashboards, alarm escalation, log retention — the standard SRE concerns, no different from any other piece of fab software.