Merge pull request 'docs(modules): module-rust phase 0 as built — the rig is current, and four plan claims were wrong' (#251) from docs/rust-phase-0 into main

Reviewed-on: #251
This commit is contained in:
2026-09-15 21:42:57 +00:00

View File

@@ -93,6 +93,16 @@ Three properties fall out, and they are the reason this shape is worth its cost:
drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it
finds a value a human has moved ([kit][kit] ch. 5).
**Phase 0 exercised all three against the real store and they hold — but it found four rules the
push path has to obey (§12.2).** The one that would have cost the most:
**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It
returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is
registered by the plugin that declares it, so **every grant naming an unloaded, renamed or
uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is
"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent
partial. The push must check `PermissionExists` (or register the name itself) and report the
difference as drift rather than assuming a write landed.
**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and
4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a
teardown. This is neither: it is *continuously reconciled state where the website is authoritative*,
@@ -171,12 +181,30 @@ direction worth stating plainly: for Teams, the plugin is *worse* than first-par
`Clans` v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes **fifteen `[HookMethod]`s and every one of
them is a mutation** — `CreateClan`, `JoinClan`, `LeaveClan`, `KickPlayer`, `PromotePlayer`,
`DemotePlayer`, `DisbandClan`, and the eight alliance verbs. **There is no read API whatsoever**: no
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`. And it raises exactly **three**
hooks — `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — **none of which is a membership
transition**.
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`.
So it cannot answer any of core's three provider questions from its published surface, while
first-party clans answer all three. Feeding the Team provider from first-party clans is therefore
> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks —
> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and
> concluded that the plugin cannot answer core's provider questions at all. **That was a grep
> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to
> a search for `CallHook("OnClan…` because the name is a `const` at the call site:
>
> ```csharp
> const string HOOK_NAME = "OnClanMemberJoined";
> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList);
> ```
>
> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`,
> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and
> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer
> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the
> exact transitions first-party lacks.
**The decision does not change, but its reason does.** First-party remains the Team provider's source
because it is the system the *game* maintains and every server has it; the plugin is an optional
install that not every shard will run, and feeding a provider from something optional makes Teams
conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that
it should not be the one asked. Feeding the Team provider from first-party clans is still
**permanent, not a first step**.
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
@@ -263,6 +291,12 @@ kicked and left — but **no promote or leader-changed event**. Core's provider
transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
fully event-driven — a small correction to that document, recorded here rather than silently.
Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the
**Clans plugin closes it**`OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does
not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven
leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design
phase 9's snapshot so phase 17 can sharpen it rather than replace it.
### R7 — the notifications and engagement set ships in v1
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
@@ -519,7 +553,7 @@ someone goes looking for one that is not there.
| **`GetPlayerZoneIDsNoAlloc(player, List<string>)`** | the allocation-free variant — **the one to use on any sweep** |
| `CreateOrUpdateZone(zoneId, args, position)` | make a zone |
| **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* |
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one, **scoped to the owner** |
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one; owner-scoped **only against another plugin's zone**, not against an unowned one (§12.4) |
| `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source |
And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`,
@@ -543,10 +577,30 @@ expensive and less accurate.
**`rust.zone.open` moves from the optional tier into the base catalogue, and it can be
`reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a
**`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a
first-class notion of a zone belonging to the plugin that made it. That is most of the persisted
ownership registry [kit][kit] ch. 4 demands — we still keep our own map from core's resource
reference to the zone id, but we are not inventing ownership, we are borrowing a concept the plugin
already has. And erasing a zone that is gone is a success, which is what `revert` needs.
first-class notion of a zone belonging to the plugin that made it. And erasing a zone that is gone is
a success, which is what `revert` needs.
> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is
> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all
> for a zone owned by **nobody**.
>
> ```csharp
> // Only compare zone owner if the owner param is provided so users can remove temporary zones
> // without needing to unload the plugin that created them
> if (owner && zoneOwner && owner != zoneOwner)
> return false;
> ```
>
> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So
> `EraseTemporaryZone(us, "<operator's zone>")` **deletes it and returns `true`**, indistinguishable
> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was
> erased by an `EraseTemporaryZone` call from an unrelated plugin.
>
> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We
> still keep our own map from core's resource reference to the zone id, and that map is now
> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not
> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not
> evidence the zone was ours.
**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and
`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of
@@ -635,22 +689,43 @@ online. The persisted pending-grant queue that question was weighing is not need
## 4. The test rig
`D:\rust` on the org lead's workstation. It has been booted, it has a generated world
(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its
Oxide permission store already holds a `default` and an `admin` group with one admin user — which
means R2's mechanism can be exercised on day one.
`D:\rust` on the org lead's workstation. **Brought current in phase 0 (see §12):** build
**25230300**, Oxide **2.0.7716**, a fresh procedural world (seed 1234, size 4000) generated for this
wipe, and all four base plugins loaded. Its Oxide permission store holds a `default` and an `admin`
group with one admin user, so R2's mechanism was exercised on day one and works.
Two traps recorded here because both cost time before they were understood:
A wipe keeps `server/server1/cfg/`. That directory holds `users.cfg`, and `users.cfg` holds the
`ownerid` line — delete the whole identity directory and you silently remove the operator's own
ownership along with the map.
- **`D:\rust\start.bat` updated the wrong directory — fixed 2026-09-15.** It ran
`steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launched
`D:\rust\RustDedicated.exe`. The server that boots had never been updated by its own script, which
is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind.
Now reads `+force_install_dir d:\rust\`; the original is kept at `D:\rust\start.bat.bak`.
`D:\rust\steamapps\appmanifest_258550.acf` was already present at the same buildid, so the first
corrected run is a delta to the current wipe rather than a 5.9 GB re-download.
Three traps recorded here because each cost time before it was understood:
- **`start.bat` never updated anything — the 2026-09-15 diagnosis was wrong, corrected in phase 0.**
The script was read as *"updates `C:\rustserver`, runs `D:\rust`"*, and the fix changed the path to
`d:\rust\`. The path was never the problem. **steamcmd requires `+force_install_dir` before
`+login`**, and the script had it after:
```
steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit
→ Please use force_install_dir before logon!
→ Error! App '258550' state is 0x486 after update job.
```
So the flag was discarded, the update ran against steamcmd's own directory, and the job errored out
every single time. It updated **no** directory, ever — which is the actual reason `D:\rust` fell a
wipe behind, and why `C:\rustserver` sits at the *same* stale buildid rather than a newer one.
Now reads `+force_install_dir d:\rust\ +login anonymous +app_update 258550 +quit`; the pristine
original is kept at `start.bat.bak` and the path-only fix at `start.bat.broken-order-20260915`.
With the ordering right, the run is a delta and takes minutes.
- **Re-extract Oxide after every `app_update`.** Updating the server and re-installing Oxide together
is the standard operator routine, not a discovery — Oxide ships a *patched* `Assembly-CSharp.dll`
and a Steam update restores Facepunch's. Recorded here only for the mechanical detail: the update
does **not** remove `Oxide.Core.dll` and friends, so a half-done install still *looks* Oxided while
loading no plugins and raising no hook. Check the size rather than the directory — on build
25230300 vanilla is 9,758,544 bytes and Oxide 2.0.7716's is 9,953,280.
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,953,280, so copying it
over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it.
**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the
@@ -673,7 +748,7 @@ Each phase ends with its findings written down, as every workstream here does.
| # | Phase | Repos | Done when |
|---|---|---|---|
| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
| 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history |
@@ -925,7 +1000,7 @@ cleanly, reads back cleanly, and does nothing at all, and neither core nor revie
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17**`CreateOrUpdateTemporaryZone` takes a `Plugin owner` and `EraseTemporaryZone` is scoped to it, so the undo is real |
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) |
**Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because
it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —
@@ -1096,4 +1171,181 @@ So R10's capability probe already has the behaviour the app wants: a Rust module
makes the app render a site *without* those screens, rather than one advertising screens that `503`.
The app needs no failure handling for this case because core does not expose the failure.
## 12. Phase 0 as built — the rig, 2026-09-15
The rig is current, the base set runs, and **both acceptance criteria are met**. Six things were
learned that the plan had either wrong or had never asked, and four of them change work in later
phases.
### 12.0 What the rig is now
| | Before | After |
|---|---|---|
| Server build | `24613624` (2026-08-13) | **`25230300`** (2026-09-10) |
| Oxide | `2.0.7585` | **`2.0.7716`** (`OxideMod/Oxide.Rust`, 2026-09-11) |
| World | seed 1234 save v287, previous wipe | regenerated for this wipe; `cfg/` preserved |
| `oxide/plugins/` | empty | Kits 4.4.9 · Clans 0.2.10 · Popup Notifications 0.2.1 · Zone Manager 3.1.14 |
All four compiled and loaded first time on the new build, at exactly the versions R6 and R17 name —
pulled fresh from `https://umod.org/plugins/<Name>.cs`, which still serves those versions and needs
no Cloudflare workaround. Server protocol `2633.288.1`.
The Oxide permission store survived the update untouched (`oxide/` is not a Steam depot directory),
so `76561198038695917` is still in `default` and `admin`.
Two instruments were built and are kept in the phase-0 scratchpad rather than committed: a
dependency-free **WebSocket RCON driver** (Node's global `WebSocket`, no `ws` package), and
**`RGProbe.cs`**, a throwaway Oxide plugin that exposes Oxide's permission API and ZoneManager's
by-name API as console commands. The probe is what made §12.2 and §12.4 observable; phase 1's plugin
skeleton can start from it.
> **One thing the RCON driver had to learn.** Oxide tags its own `Puts()` output and its warnings
> with the **identifier of the command being run**, so a first-match-wins client reads a plugin's log
> line as if it were the reply and discards the real one. It cost two wrong readings before it was
> spotted. Collect every frame in a window; do not correlate one reply per identifier.
### 12.1 The rig's own script was broken in a way the earlier diagnosis missed
Recorded in §4. In short: `start.bat` put `+force_install_dir` **after** `+login`, steamcmd discarded
it, and every update run in the rig's history errored out without updating anything. The 2026-09-15
"fix" changed the path and left the order, so it fixed nothing.
The Oxide re-install in §4 is **not** a finding — pairing a server update with an Oxide re-install is
the routine every Rust host already follows, and saying otherwise would be this plan talking down to
its own audience. One narrow consequence is still worth carrying to **phase 18**: because
`app_update` leaves `Oxide.Core.dll` and the rest in place, a `doctor` check that tests for `oxide/`
or for Oxide's assemblies **passes on a server that is mid-routine**. Compare the
`Assembly-CSharp.dll` against the Oxide build instead, so `doctor` reports the real state rather than
a directory listing.
### 12.2 Four rules the R2 permission push must obey
Verified live against the real store, granting and revoking through both the console command and the
API:
1. **`permission.GrantUserPermission` silently no-ops for an unregistered permission.** `void`, no
throw, no log. The console `oxide.grant` at least answers `Permission 'x' doesn't exist`; the API
path R2 uses says nothing at all. This is the finding with teeth — see R2.
2. **A permission exists only because a loaded plugin registered it.** Kits registers `kits.admin`
and, dynamically, **every kit's `RequiredPermission`** (`Kits.cs:1225`, `:2895`) — which is what
makes R16's entitlement model real. Unload Kits and those names stop existing.
3. **`RegisterPermission` warns about a foreign prefix but registers anyway.**
`Missing plugin name prefix 'rgprobe' for permission 'someplugin.vip'` is a warning, not a
refusal — the permission was created and granted successfully. So the site *can* make a grant
stick for a plugin that is not currently loaded, at the cost of a console warning. Whether it
*should* is a phase 7 decision; the mechanism exists.
4. **A player who has never connected is in no group, but can hold direct grants.** A grant to an
unseen SteamID64 works and reads back immediately. Group membership does not exist for them yet,
so **anything the site expresses as group membership does not reach a player until their first
connection**, while a direct grant does. R16's offline entitlement is safe; a group-shaped
entitlement is not.
Point 4 is the one to carry into phase 7's design: grants and groups have **different reach** for
offline players, and the site's model currently treats them as two spellings of the same thing.
### 12.3 R5's claim about the Clans plugin was a grep artefact
Corrected in R5. The plugin raises nine hooks, not three, and three of them carry full member lists;
the six that were missed are invisible to a literal search because the hook name is a `const` at the
call site. The decision stands on a different reason — first-party is what every server has, the
plugin is optional — and phase 17 gains event-driven leadership as a sharpening rather than a
replacement.
Two smaller things from the same read, both worth having before phase 9 and 17:
- **`Clans` raises the same hook name twice per transition**, once Rust-typed
(`string, ulong, List<ulong>`) and once Universal-typed (`string, string, List<string>`), plus two
deprecated arities. Oxide binds by name **and** arity, and both live forms are arity 3 — so a
loosely typed subscriber catches both and double-counts every join and leave. Type the parameters
precisely and pick one.
- **`Clans` calls `API_RegisterThirdPartyTitle` itself.** R15's BetterChat integration will be the
*second* title provider on any server running both, not the first.
Also confirmed, since the plan rests on it: the first-party set is exactly the **seven** hooks in
`agent/hooks.tsv`, all "no return behavior", with no promote and no leader-changed.
### 12.4 ZoneManager's owner scoping is narrower than R17 assumed
Corrected in R17. `EraseTemporaryZone(owner, id)` refuses only when the zone has a *different*
owner; an **unowned** zone — every permanent zone, including every zone an operator made by hand — is
erased by anyone and returns `true`. Phase 12 must gate erasure on its own id map.
Three more things the source and the live rig agreed on:
- **ZoneManager's entire API is plain private methods**, no `[HookMethod]` anywhere in 3.1.14 — so
`Call()` by name is the only way in, and a typo is silence. Confirmed working live for
`CreateOrUpdateZone`, `CreateOrUpdateTemporaryZone`, `EraseTemporaryZone`, `GetZoneIDs` and
`GetPlayersInZone`. The three-conventions finding holds: Kits declares `[HookMethod]` (23 of them),
ZoneManager declares nothing, BetterChat will use `API_` prefixes.
- **`GetPlayersInZone` cannot distinguish an unknown zone from an empty one** — both return an empty
list, not null. The participation ledger R17 wants to feed therefore cannot use this call alone to
answer "is this zone still there", and must check `GetZoneIDs` separately. This is the same
absence-of-an-answer / answer-of-absence trap earlier phases of other workstreams hit.
- **NPCs never appear in a zone's player list.** `baseEntity is BasePlayer { IsNpc: false }` routes
them to the zone's *entity* list instead. Useful to know before designing a condition that counts
"players at the monument" on a server with scientists.
### 12.5 The criterion is closed, and it revealed two ceilings on the rig
> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it
**Both halves done**, the second with the org lead connected. The zone was created on the player's
own position, and ZoneManager reported both transitions live:
```
[probe] ENTER zone=rgtest player=76561198038695917 (whitlocktech)
[probe] zone=rgtest occupancy=1 [76561198038695917:whitlocktech]
[probe] EXIT zone=rgtest player=76561198038695917 (whitlocktech)
```
The exit was produced by **moving the zone off the player** rather than walking them out —
`CreateOrUpdateZone` on an existing id relocates the trigger volume and fires `OnExitZone` as it
leaves. Useful for testing presence without choreographing a person.
So **R17's "presence transitions as events" is verified**, which is the claim the participation
ledger and the advance conditions both rest on.
Two ceilings surfaced on the way, and both constrain later phases:
**1. No console session can observe a gate.** The standard idiom is
`return !player || permission.UserHasPermission(...)` — a command from RCON has no `BasePlayer`, so
the console is unconditionally allowed. Anything whose acceptance needs a permission to actually
*refuse* somebody needs a client attached.
**2. An admin account cannot see a refusal either — from most plugins.** The bypass is not uniform,
and the difference decides which phases can be demonstrated on the org lead's own account:
| Plugin | Admin bypass | Demonstrable as owner? |
|---|---|---|
| Popup Notifications | `player.IsAdmin \|\|` — hard | **No** |
| Zone Manager | `authLevel > 0 \|\|` — hard | **No** |
| Kits (`RequiredPermission`) | `Configuration.AdminIgnoreRestrictions && IsAdmin(player)`, and Kits' own `IsAdmin` is the `kits.admin` **permission**, not auth level. The shipped default is **`false`** | **Yes** |
So **phase 13 is demonstrable on this rig as it stands** — R16's entitlement gate applies to a server
owner like anyone else. **Phase 7 is not**, if its acceptance is "a grant made on the website gates a
third-party plugin in-game" against Popup Notifications or Zone Manager: that needs a **second,
non-admin Steam account**. Worth arranging before phase 7 rather than discovering there.
### 12.6 R18's trees, as they actually look
The four base plugins wrote their configs on first boot, so R18's two trees can be compared against
something real rather than predicted:
```
oxide/config/ Clans.json Kits.json PopupNotifications.json ZoneManager.json
oxide/data/ clan_data.json Kits/kits_data.json Kits/player_data.json
ZoneManager/zone_data.json
oxide.users.data oxide.groups.data oxide.covalence.data oxide.lang.data
```
R18's inventory of `data/` was exactly right. One nuance worth correcting, though: R18 motivates the
**recursive** walk with *"plugins nest (`config/<Mod>/x.json` and deeper)"*, and on a fresh install of
the base set **`config/` is flat — it is `data/` that nests.** The recursive walk is still correct
(other plugins do nest configs), but the nesting the plan cites as its reason is currently visible
only in the tree it must never walk.
And a reason to hold that boundary harder than R18 states: **`oxide/data/` is where Oxide keeps its
own permission store** (`oxide.users.data`, `oxide.groups.data`). A config editor that strayed one
directory over would be editing R2's mirror underneath itself.
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit