This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Bootstrapped on 2026-10-09; the Cargo workspace and toolchain pins landed on 2026-10-10. tools/repo-check enforces repository invariants as part of cargo test. Pin every new runtime or dev tool in .prototools and add its commands to this file.
Architecture decisions, rebuild scope (items A1–M8), service split, repository layout, and milestones (MS0–MS6) live in docs/; start at docs/README.md. docs/ is the source of truth: when a decision changes, update docs/ in the same change. In docs/, a bare #N refers to the legacy repo Laysi/trustiot; always write legacy references as Laysi/trustiot#N in issues and PRs here.
The repository has been public since 2026-10-12: commits, PR and issue text, test reports, and CI logs are readable by anyone. Never put secrets, internal hostnames or LAN addresses, customer names, or personal data in them (cluster pod and service addresses are fine); the rule is in docs/architecture/secrets.md, section 「公開 repo 的資訊規則」.
The gateway hardware is the TrustIoT gateway: it is developed on the Raspberry Pi platform and runs Raspberry Pi OS, but it is not a Raspberry Pi. Never call it a Pi in code, docs, commits, issues or PRs (no "Pi", "lab Pi", <pi> or PI_HOST): write "TrustIoT gateway" or "gateway" (zh-TW 閘道器), and "lab gateway" (lab 的閘道器) for the unit used in tests. Upstream software names stay as they are, such as Raspberry Pi OS and vcgencmd.
.prototools pins Rust, Bun, Go, actionlint, cargo-deny, gitleaks, Helm, k3d, kubeconform, kubectl, the GitOps tools (flux, sops), and the release tools (zig, cargo-zigbuild, cargo-auditable, syft, cosign); tools without a built-in proto plugin get a schema plugin in .proto/plugins/ that verifies the release checksum. proto installs Rust through rustup and does not shim cargo, so rustup picks the toolchain from rust-toolchain.toml: change rust in .prototools and channel in rust-toolchain.toml together (cargo test -p repo-check fails when they differ). Run every command from the repository root.
- Install the pinned tools:
proto install - Build:
cargo build --workspace - Format:
cargo fmt --all(check only:cargo fmt --all --check) - Lint:
cargo clippy --workspace --all-targets -- -D warnings - Test:
cargo test --workspace - Single test:
cargo test -p <crate> <test-name-filter>, for examplecargo test -p repo-check toolchain_pins_agree - Local dev environment (podman, no Kubernetes; details in
scripts/README.md):bun scripts/dev.ts up|seed|status|env|down|purge. Its Postgres serves TLS from a CA thatupgenerates (kept under$XDG_STATE_HOME/trustiot-v2/dev/<instance>/tls/, never in the repo), so services connect withverify-full;envprintsTRUSTIOT_POSTGRES_{HOST,PORT,DATABASE,SSLMODE,CA_FILE}andPGSSLMODE/PGSSLROOTCERT, soset -a; eval "$(bun scripts/dev.ts env)"; set +ais all a service orpsqlon the host needs.upalso runs the migration Job (tools/migrate, built with cargo and run unchanged against a loopback stand-in for the Kubernetes Secret API that fillsidentity_app's password) and generates the identity service's Ed25519 signing key and writes the signing keys and public keys files from it (kept bydown, removed bypurge, mode 0600 under the same state directory);seedadds a dev user, a tenant membership and a dev session and prints its cookie;envalso printsTRUSTIOT_INTERNAL_IDENTITY_KEYS_FILEandTRUSTIOT_IDENTITY_SIGNING_KEYS_FILE(paths),TRUSTIOT_INGRESS_ROUTES,TRUSTIOT_DEV_IDENTITY_DATABASE_{USERNAME,PASSWORD}andTRUSTIOT_DEV_IDENTITY_PLATFORM_DATABASE_{USERNAME,PASSWORD}, so the identity service starts on the host withcargo run -p identity(steps and which variable is for which service:scripts/README.md, section 「身分服務」). Script tests:bun test scripts, afterbun install --cwd scripts --frozen-lockfile(addTRUSTIOT_DEV_E2E=1and the filescripts/tests/e2e.test.tsto run the podman end-to-end test, which buildsmigrate,identityandexample-service; useCARGO_BUILD_JOBS=2on a small machine). - Helm chart (
deploy/chart):bun test deploy/chartrunshelm lint, kubeconform on both values files, the chart's own rules, and the README field check; a single check isbun test deploy/chart -t "<test-name-filter>". The individualhelm lint/helm templatecommands are indeploy/chart/README.md. - GitOps (
deploy/flux, Flux on the SaaS cluster and the demo cluster; details indeploy/flux/README.md):bun test deploy/fluxruns kustomize build, kubeconform against the pinned Flux CRDs, and the checks on the chart source, values and.sops.yaml(needsproto install kubectl kubeconform helm flux). After changing thefluxpin, regenerate Flux's manifests withbun scripts/flux/components.ts. age has no checksum-verified release binary, so it is built from source with the pinned Go:bun scripts/flux/age.ts age-keygen -o <file>(or... age <args>). The SOPS-encrypted GHCR pull Secret is written by<token on stdout> | bun scripts/flux/ghcr-pull-secret.ts --username <account> [--cluster demo], any other SOPS-encrypted Secret by<value on stdout> | bun scripts/flux/sops-secret.ts --cluster <saas|demo> --name <secret> --key <key>, the demo cluster's private values by<four key=value lines on stdout> | bun scripts/flux/demo-values.ts, and one cluster's chart pin bybun scripts/flux/pin-chart.ts --cluster <saas|demo> --version <version> --digest <chart digest>. The lab end-to-end test (Flux install, deploy, revert, signature refusal; needs the lab lock with the owner lineLaysi/trustiot-v2#<issue> <time>for the issue ofE2E_NAMESPACE, which it checks, and refuses to run on a cluster that already has Flux, such as the demo cluster):E2E_SSH_HOST=trustiot-mac-m1 E2E_NAMESPACE=e2e-<issue> E2E_OUT=<dir> bun deploy/flux/tests/e2e/lab.e2e.ts. - SaaS host installer (
deploy/host):bun install --cwd deploy/host --frozen-lockfile, thenbun run --cwd deploy/host typecheck,bun run --cwd deploy/host test(simulated host; a single test is-t "<test-name-filter>") andbun run --cwd deploy/host build(the x86_64 and aarch64 executables indeploy/host/dist/). Running it on a host is indeploy/host/README.md. - Frontend (
frontend/, Vite + React single-page app; details infrontend/README.md):bun install --cwd frontend --frozen-lockfile, thenbun run --cwd frontend api:check(the committed API client matchesapi/openapi.json;api:generaterewrites it after the spec changes),typecheck,lint(Biome;formatfixes formatting),test(a single test isbun run --cwd frontend test -t "<test-name-filter>"),build(intofrontend/dist/) andcheck:dist(the build output's layout and CSP rules).bun run --cwd frontend devserves it with hot reload;TRUSTIOT_DEV_API_ORIGIN=<origin>proxies/apiand/authto a running backend. A new message key in Rust (MessageKey::newormessage_key!undercrates/contract/srcorservices/*/src) needs its zh-TW and en translations infrontend/src/i18n/locales/in the same change, orbun run --cwd frontend testfails. - Nightly workflow (
.github/workflows/nightly.yml,scripts/nightly/): CI deploys the chart with both values files to a k3d cluster and smoke-tests it; it runs only in CI, because workstations do not run Kubernetes. A scheduled run skips the deployment when main's tree equals the tree of the newest completed scheduled or manual run and that run passed (a failed or missing run, an API error and every manual run deploy);bun scripts/nightly/unchanged.ts --dry-run(withGITHUB_TOKENandGITHUB_REPOSITORY) prints the decision read-only. On a pull request it deploys only while the PR carries thesmokelabel, which the person who merges the pull request adds before merging. Its unit tests need no cluster:bun test scripts/nightly. Renovate (renovate.json5) updates dependencies; a tool pinned in.prototoolswith a schema plugin needs a# renovate:comment above its pin (bun test scripts/nightlychecks it). Details:docs/architecture/ci-release.md. - Ingress e2e (needs a cluster with Istio ambient; never run Kubernetes locally):
E2E_NAMESPACE=<ns> E2E_IDENTITY_IMAGE=<image> E2E_MIGRATE_IMAGE=<image> bun test --timeout 600000 ./deploy/chart/tests/e2e/ingress.e2e.ts; prerequisites and the other variables are indeploy/chart/tests/e2e/README.md. Its tunnel recovery (raw-client.ts,tunnel-recovery.ts) has cluster-free unit tests thatbun test deploy/chartruns. - Log pipeline e2e (OpenTelemetry collector to ClickHouse and a stand-in SIEM; needs a cluster, run on the lab k3s):
E2E_NAMESPACE=<ns> E2E_SERVICE_IMAGE=<example-service image> bun test --timeout 900000 ./deploy/chart/tests/e2e/otel.e2e.ts; building the arm64 example-service image is indeploy/chart/tests/e2e/README.md. - Bundled Postgres e2e (CloudNativePG Cluster with AGE; needs a cluster with the CloudNativePG operator installed as
deploy/cloudnative-pg/README.mddescribes, run on the lab k3s):E2E_NAMESPACE=<ns> E2E_SHAPE=<saas|onprem> E2E_POSTGRES_IMAGE=<image> E2E_SERVICE_IMAGE=<example-service image> bun test --timeout 900000 ./deploy/chart/tests/e2e/postgres.e2e.ts; building both arm64 images and the other variables are indeploy/chart/tests/e2e/README.md. - Container-backed tests:
crates/service-kitstarts throwaway Postgres and PID 1 containers with podman or docker (or usesTEST_DATABASE_URLfor Postgres),crates/testkitstarts a TLS Postgres for the cross-tenant suite's self-test, andedge/agent'sbrokertest starts Mosquitto (testkit::mqtt). Without them those tests skip locally; withCIset they fail instead. A test container must not leave volumes behind: the images declareVOLUMEs, so remove containers withrm --force --volumes(cargo test -p repo-checkrejects a Rustrm --forcewithout it); leaked anonymous volumes use up podman's locks and then every container test fails. - Cross-tenant negative tests (decision 29): every service declares its tables and endpoints with
crates/testkitintests/; how to hook a service in is incrates/testkit/README.md.cargo test -p testkit -- --nocaptureprints every probe. - Test time limits: workstations and CI runners are often loaded (load average 25–35 on 8 cores was seen), so no test may depend on a fixed short time. Wait for the condition with a generous bound that fails with a message naming what was awaited. Do not widen a number without asking what it waits for; assert ordering rather than elapsed time, and bound a duration only against what the test configured (a drain delay, a 60 s timeout it must not reach). Chart tests (
deploy/chart/tests): calluseProcessTestTimeouts()fromdeploy/chart/tests/timing.tsat the top of the file (bun appliessetDefaultTimeoutper file and ignores a bunfig test timeout), passSLOW_TEST_MSto a test that runs many helm or kubeconform commands, and run commands through a helper withtimeout: COMMAND_TIMEOUT_MSandthrowIfTimedOut. Rust tests: use the named constants and helpers in the test file orcrates/service-kit/tests/support, for examplewait_until_readyandrefusal_through_poolintenant_db.rs. crates/service-kitwithout its defaultcontractfeature, which the--workspacecommands never build:cargo clippy -p service-kit --no-default-features --all-targets -- -D warningsandcargo test -p service-kit --no-default-features- API spec (
api/openapi.json, generated from the code, never edited by hand): regenerate withcargo run -p openapiafter changing any API type and commit the result;cargo run -p openapi -- --checkonly verifies it.cargo test --workspaceand CI fail when the committed file differs from what the code generates. Each service exposespub fn openapi()and registers it intools/openapi(tools/openapi/README.md). - Dependency licenses, advisories, and sources (rules in
deny.toml):cargo deny --locked check - Secret scan, as CI runs it:
gitleaks git --redact --log-opts="--full-history --diff-filter=tuxdb --diff-merges=first-parent HEAD" .. Before every push (the first and each later one), also scan the branch and the tree it leaves behind:gitleaks git --redact --log-opts="origin/main..HEAD" ., thengit archive HEAD | tar -x -C <dir>andgitleaks dir --redact <dir>; both must find nothing (reasons:docs/architecture/secrets.md, section 「公開 repo 的資訊規則」) - Workflow policy (job
permissions, SHA-pinned actions):bun scripts/ci/check-workflows.ts - Which ci.yml jobs a change runs:
scripts/ci/changes.tsdecides from the changed paths whetherrust,cargo-deny,helm,host,postgres-image, andfrontendrun;gitleaksandworkflow-policyalways run. When a Rust test, a chart test, the host installer, or the frontend starts reading a file outside its own directory, add the path to the rules there (bun test scripts/ci/changes.test.tsfails until you do). Theifand concurrency conditions of ci.yml and nightly.yml are tested bybun test scripts/ci/workflow-conditions.test.ts. On a push to main, a job that already ran and passed on the identical tree in the merged pull request's CI is skipped too (scripts/ci/tested-tree.ts);GITHUB_TOKEN=<token> bun scripts/ci/changes.ts --explain <main commit>prints that decision and its evidence without writing anything (rungit fetch origin mainfirst). Details:docs/architecture/ci-release.md. - Secret handling (every Dockerfile/Containerfile, container build commands, build args, where workflows read secrets, SOPS-encrypted Secret manifests; rules in
docs/architecture/secrets.md):bun scripts/ci/check-secret-handling.ts - Workflow syntax and expression check with the pinned actionlint, which rejects what GitHub cannot parse (for example
runner.tempin job-levelenv):bun scripts/ci/lint-workflows.ts(runproto install actionlintfirst; the shellcheck integration is deliberately off, seedocs/architecture/ci-release.md) - Bun scripts in
scripts/(the dev environment and the CI checks):bun install --cwd scripts --frozen-lockfileinstalls the type checker and the test dependencies (bun test scriptsneeds them too), thenbun run --cwd scripts typecheckchecks every.tsfile there and underdeploy/chart/tests/,deploy/flux/tests/anddeploy/images/(the ingress e2e included, which needs a cluster to run but not to type-check);bun run --cwd scripts testruns the same test files asbun test scripts, and a single test isbun run --cwd scripts test -t "<test-name-filter>" - Production workstation tool (
factory/workstation, binarytrustiot-workstation; operator steps in its README):cargo build --release --locked -p workstation; its tests (cargo test -p workstation) use loopback stand-ins only. - Lab datasets (
datasets/lab/, checklist M7; the index format, artificial anomalies, storage, recording, the synthetic fixture and how a Rust test loads a dataset are indatasets/lab/README.md):cargo run -p lab-dataset -- check datasets/lab/<name>/index.tomlchecks an index and its segment files;cargo run -p lab-dataset -- load <index> --tenant <uuid> --gateway <uuid> [--end-before now]writes a dataset into the ClickHouse named byCLICKHOUSE_URL,CLICKHOUSE_USERandCLICKHOUSE_PASSWORD(plusCLICKHOUSE_CA_FILEfor https);cargo run --release -p lab-dataset -- record <config> --out <dir> [--duration-s <seconds>]records devices with the agent's Modbus module (the config names converter addresses, so it and<dir>stay outside the repository).cargo test -p lab-datasetchecks the fixture's content, the anomaly insertion, records from testkit's fake RS485 converter, and loads into a throwaway ClickHouse (same container rules as the ingest tests). The fixture is fixed files: there is no generator to rerun; change the files and bump the index'sversion. - Gateway crates (
crates/protocoland every member underedge/) must build for aarch64:rustup target add aarch64-unknown-linux-gnuonce, thencargo build --locked --target aarch64-unknown-linux-gnu -p <crate>for a pure-Rust library such asprotocol.edge/agent(thetrustiot-agentexecutable, with ring's C code) needs Zig as the target's C compiler and linker:proto install zig && proto install cargo-zigbuild, thenCARGO_ZIGBUILD_ZIG_PATH="$(proto bin zig --reporter text | tail -n 1)" PATH="$(dirname "$(proto bin cargo-zigbuild --reporter text | tail -n 1)"):$PATH" cargo zigbuild --locked --release --target aarch64-unknown-linux-gnu -p agent(glibc, not musl; details inedge/agent/README.md). Agent tests:cargo test -p agent(single files with--test link|transport|broker|binary|systemd_unit|dependencies;systemd_unitalso runssystemd-analyzewhen present) - Release scripts (
scripts/release, run by.github/workflows/release.yml):bun test scripts/release; a single test isbun test scripts/release -t "<test-name-filter>". Building the arm64 images locally and running the whole publish and verify flow against a throwaway registry are described inscripts/README.md. - Images: each directory under
deploy/images/is a published arm64 image (image.tomlfor a Rust binary, or its ownContainerfile); seedeploy/images/README.md. Animage.tomlwith a[static]table also packs a Bun project's checked build output at/srv/www, forservices/web(scripts/release/static-build.ts). Base images must be pinned by digest. - Postgres image with Apache AGE (
deploy/images/postgres):bun test deploy/images/postgresbuilds it for arm64 with podman and checks it; the tests that start Postgres need an arm64 host or a binfmt arm64 emulator and skip without one (fail withCIset).POSTGRES_IMAGE=<image>tests an existing image instead. Upgrade steps are in its README.
.github/workflows/ci.yml runs these checks on every pull request and every push to main; its jobs and the required-checks list are in docs/architecture/ci-release.md. Every job must list its permissions, every action must be pinned to a full commit SHA with the tag in a trailing comment, and local actions belong under .github/actions/.
Every directory under crates/, services/, edge/, factory/, and tools/ is a workspace member, so it needs a Cargo.toml that sets edition.workspace = true and [lints] workspace = true; list non-Rust directories in the root exclude. Declare third-party dependencies once in the root [workspace.dependencies]. Clippy fails on every lint in [workspace.lints]; suppress one only with #[allow(lint, reason = "...")]. Save test evidence under docs/test-reports/ as its README describes.
Work is tracked as real issues in Laysi/trustiot-v2 on the org Project TrustIoT v2 (Laysi, project #12). Follow the github-loop-engineering skill for every issue and board transition. Resolve field and option IDs at runtime; never hard-code them.
- Status:
Triage,Todo,In Progress,Blocked,Done. Agents triage by best judgment, without waiting for a human: accepted work moves toTodo, and rejected work is closed as not planned.Todoincludes accepted work that still waits on other issues in this repo. Those waits are native blocked-by dependencies, and the board'sReadyview (filterstatus:Todo -is:blocked) lists the work that can start now. - Priority (board field):
P0incident or blocker,P1current focus (the milestone in progress, or the demo slice indocs/roadmap/milestones.md),P2backlog. Set it when the issue is created, by best judgment; agents set it too and do not wait for human triage. The board field is the source of truth, and the issue carries exactly one matchingpriority:P0|P1|P2label. - Labels: every issue carries at least one
area:*label (ordocumentationfor docs-only work). Add anarea:<component>label, whose description names the component path, when a new top-level component is created. Other workflow labels:blocked,tech-debt,security, anddemofor the issues in the demo slice (docs/roadmap/milestones.md, section 「展示切片」). - Issue types:
Featurefor new capabilities,Bugfor incorrect behavior or vulnerabilities,Taskfor everything else. - Issue bodies are written in Traditional Chinese and must be cold-start readable. Use the headings from
.github/ISSUE_TEMPLATE/: 背景 (Why), 範圍 (What), 現狀 (State), 驗收條件 (Acceptance Criteria), 指標 (Pointers); bugs add 重現 (Reproduction). Use absolute dates only. Don't copy board fields such as Status or Priority into the body. The templates come from thegithub-loop-engineeringskill (zh-TW); update them through the skill, not by hand. - Parallel work: each milestone is a root issue whose sub-issues are epics and then leaves, and blocked-by dependencies connect leaves only. To work through a parent issue, follow the skill's fan-out: the board filter
parent-issue:Laysi/trustiot-v2#N status:Todo -is:blockedlists its children that can start now. - Blocked work: a blocker outside the work graph, such as missing hardware, a pending decision, or a customer, gets the
blockedlabel, aBlocked bysection in the body, and StatusBlocked. A wait on another issue in this repo is a native blocked-by dependency instead. - CLI fallback: use the GitHub MCP server for every GitHub read and write. The user has authorized the skill's
gh apifallback for two things the server can't do yet, and nothing else: native blocked-by dependencies (list, add, remove) and creating milestones. - Assignment is decided when work starts, not at creation.
- Closing: if the Project's auto-close workflow is enabled, setting Status to
Donecloses the issue, so close the issue through the completion ritual instead of moving the card first.