diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..87de939
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,21 @@
+---
+name: Bug report
+about: Report a reproducible component or cross-repository defect
+title: ''
+labels: ''
+assignees: ''
+---
+
+## Behavior and reproduction
+
+Describe the expected and observed behavior, with a minimal reproduction.
+
+## Component and environment
+
+Include the component version/commit, OS/architecture, and ROS distribution if relevant. For a source build, include the vendored SDK UPSTREAM identity and any consumer adaptation. Include units, calibration revision and relevant health/drop counters when reporting acquisition or recording problems.
+
+Use synthetic samples and redact private addresses, credentials and laboratory recordings. Report security vulnerabilities through the component's SECURITY.md process.
+
+## Related components
+
+Link related issues when a shared-core defect affects multiple consumers. Protocol/calibration/recovery fixes start in netft-cpp; each downstream repository owns its snapshot/adaptation and release acceptance.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..c967b22
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## Problem and resulting behavior
+
+Describe the trigger and observable change.
+
+## Cross-repository impact
+
+List affected consumers and the exact SDK source/manifest/patch identity if changed. Explain additive format fields, support or release dependencies; identify unreleased candidate behavior.
+
+## Necessary validation
+
+List the finite checks actually run and their results. Explain platform or external acceptance gaps. Do not rerun unrelated whole suites or claim skipped checks passed.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b2b74cb
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,9 @@
+# Cross-repository contributions
+
+The organization has five runtime components, netft-docs, and this navigation/policy repository. Component-specific contribution and security instructions take precedence for their code. Shared user documentation lives at https://netft.dev.
+
+Protocol/calibration/recovery changes start in netft-cpp. Each consumer maintainer verifies its exact SDK commit, inventory hash, license and private adaptation before release. CLI owns its bias/socket-retention adaptation; Python, ROS and Viewer own delivery, realtime and packaging boundaries respectively. Documentation maintainers verify source versions and candidate/released identities before regenerating references.
+
+Release SDK fixes first, then consumers against the approved release commit, then version-pinned references and organization navigation. Keep repositories independently versioned. A coordinated change description must identify affected repositories, owners by component, source commits, necessary finite validation, compatibility effects and remaining platform acceptance. Use the common issue/PR templates where a repository does not supply its own.
+
+Report source and product defects to the relevant repository as described in profile/README.md. Shared guide/reference issues go to netft-docs; navigation/policy issues go here. Consult the component compatibility table for exact support commitments. Do not infer production signing, deployment permissions or all-platform support from a single local build.
diff --git a/fixtures/protocol/README.md b/fixtures/protocol/README.md
new file mode 100644
index 0000000..607ddd7
--- /dev/null
+++ b/fixtures/protocol/README.md
@@ -0,0 +1,7 @@
+# Synthetic fault corpus
+
+Schema 1 contains synthetic inputs only. The three hex files are exact RDT bytes (36/35/37 bytes), including signed count extremes and sequence rollover. XML fixtures distinguish unsafe finite positive scales from valid small scales. `manifest.json` defines expected transport/calibration semantics and separate consumer-level queue/file failures. No private hardware capture is included.
+
+All four downstream consumers carry the same verified SDK source, so its protocol/discovery regressions define decoding expectations. Existing relevant cases: SDK `Protocol.DecodesTwoComplementAxisBoundaries`, `Protocol.RejectsNullAndMalformedRecords`, `Calibration.RejectsScalesThatOverflowRawRange`, `RdtSequence.HandlesFirstContiguousRolloverGapsAndOrdering`; Python integration stream/recovery and bounded queue tests; CLI OutputFile/Recorder cases; ROS SI-overflow fault; Viewer Recorder pause/overflow/finalization cases. File-failure entries describe injected conditions, not network byte strings.
+
+Use these inputs for reproductions or future consumer regressions. This corpus does not force repositories to download another repository at test time and does not replace their fake sensors. `tools/performance/` probes remain opt-in. Keep additions minimal and map every expected behavior to a component's observable contract.
diff --git a/fixtures/protocol/extreme.hex b/fixtures/protocol/extreme.hex
new file mode 100644
index 0000000..ae219c3
--- /dev/null
+++ b/fixtures/protocol/extreme.hex
@@ -0,0 +1 @@
+ffffffffffffffff00000000800000007fffffff00000000ffffffff0000000100000064
diff --git a/fixtures/protocol/long.hex b/fixtures/protocol/long.hex
new file mode 100644
index 0000000..c84f5db
--- /dev/null
+++ b/fixtures/protocol/long.hex
@@ -0,0 +1 @@
+ffffffffffffffff00000000800000007fffffff00000000ffffffff000000010000006400
diff --git a/fixtures/protocol/manifest.json b/fixtures/protocol/manifest.json
new file mode 100644
index 0000000..01dec61
--- /dev/null
+++ b/fixtures/protocol/manifest.json
@@ -0,0 +1,78 @@
+{
+ "schema_version": 1,
+ "synthetic": true,
+ "rdt": [
+ {
+ "file": "extreme.hex",
+ "accepted": true,
+ "raw_axes": [
+ -2147483648,
+ 2147483647,
+ 0,
+ -1,
+ 1,
+ 100
+ ],
+ "rdt_sequence": 4294967295
+ },
+ {
+ "file": "short.hex",
+ "accepted": false
+ },
+ {
+ "file": "long.hex",
+ "accepted": false
+ }
+ ],
+ "calibration": [
+ {
+ "file": "unsafe-scale.xml",
+ "accepted": false,
+ "fault": "SensorConfiguration/DiscoveryError"
+ },
+ {
+ "file": "safe-scale.xml",
+ "accepted": true
+ }
+ ],
+ "sequences": [
+ {
+ "values": [
+ 4294967294,
+ 4294967295,
+ 0,
+ 1
+ ],
+ "lost": 0,
+ "out_of_order": 0
+ },
+ {
+ "values": [
+ 1,
+ 3,
+ 2,
+ 3
+ ],
+ "lost": 1,
+ "out_of_order": 1,
+ "duplicate": 1
+ }
+ ],
+ "local_failures": [
+ {
+ "case": "queue-full",
+ "cli": "recording exit 8; retain partial",
+ "viewer": "Error; stop acceptance; retain partial"
+ },
+ {
+ "case": "data-final-created-before-promotion",
+ "cli": "I/O exit 6; do not replace destination",
+ "viewer": "Error under Refuse; do not replace destination"
+ },
+ {
+ "case": "metadata-write-failed",
+ "cli": "I/O exit 6; preserve completed data",
+ "viewer": "Error; preserve completed CSV"
+ }
+ ]
+}
diff --git a/fixtures/protocol/safe-scale.xml b/fixtures/protocol/safe-scale.xml
new file mode 100644
index 0000000..cc60bf5
--- /dev/null
+++ b/fixtures/protocol/safe-scale.xml
@@ -0,0 +1 @@
+synthetic1e-2981e-298NN-m
diff --git a/fixtures/protocol/short.hex b/fixtures/protocol/short.hex
new file mode 100644
index 0000000..66db6a8
--- /dev/null
+++ b/fixtures/protocol/short.hex
@@ -0,0 +1 @@
+ffffffffffffffff00000000800000007fffffff00000000ffffffff00000001000000
diff --git a/fixtures/protocol/unsafe-scale.xml b/fixtures/protocol/unsafe-scale.xml
new file mode 100644
index 0000000..6f35ac1
--- /dev/null
+++ b/fixtures/protocol/unsafe-scale.xml
@@ -0,0 +1 @@
+synthetic1e-3001e-300NN-m
diff --git a/profile/README.md b/profile/README.md
index 51ee58e..b77032a 100644
--- a/profile/README.md
+++ b/profile/README.md
@@ -37,6 +37,10 @@ Component repositories own source, releases, version-specific notes, and
contribution instructions. Shared user guides and references live at
[netft.dev](https://netft.dev).
+The organization contains seven repositories: five runtime components above,
+[netft-docs](https://github.com/netft/netft-docs) for shared user documentation,
+and [.github](https://github.com/netft/.github) for organization navigation and policies.
+
## How the projects fit together

@@ -66,7 +70,11 @@ architecture, language, and middleware combinations.
## Support and contributions
Report bugs, request features, or ask project-specific questions in the issue
-tracker of the relevant repository. Contributions are welcome through pull
+tracker of the relevant repository. Protocol, calibration and recovery defects
+belong in `netft-cpp`; binding, CLI, ROS or Viewer behavior belongs in its consumer
+repository. Shared guide and reference corrections belong in `netft-docs`, and
+organization navigation belongs in `.github`. SDK fixes precede verified downstream
+snapshot upgrades; each consumer owns its adaptation and release validation. Contributions are welcome through pull
requests; review that project's contribution guide before submitting one.
---
diff --git a/tools/performance/README.md b/tools/performance/README.md
new file mode 100644
index 0000000..4f91b80
--- /dev/null
+++ b/tools/performance/README.md
@@ -0,0 +1,7 @@
+# Bounded performance probes
+
+These opt-in probes are outside CI. Use the consumer's locked Pixi environment and already built SDK; never point the probes at hardware. Each SDK/Python probe runs exactly three synthetic rates (100/1000/7000 Hz), three one-second repetitions. The highest rate deliberately sleeps in the consumer to expose backlog. Chart uses six axes, a 60 s window, first/min/max/last aggregation per 33 ms (the current producer contract), and three repeats of 120 measured frames after prefill.
+
+Run `pixi run python /path/to/.github/tools/performance/python.py` in pyNetFT and `pixi run node /path/to/.github/tools/performance/chart.mjs` in Viewer. Build `sdk.cpp` with the SDK's existing `test/support/fake_sensor.cpp`, `fake_http_server.cpp`, private protocol source and built static SDK using its locked compiler, include directories and curl/pthread dependencies. The SDK library build type must be reported; this run used Debug SDK with an O2 harness. Output is JSON lines. Use an external 60 s process timeout.
+
+Callback handoff latency starts at the SDK receive timestamp; it excludes time queued in the kernel. Python age includes native queue and conversion. CPU and peak RSS include the fake producer and runtime, and peak RSS is a process high-water mark across repeats. Chart timings cover model append and six series/plot-array materializations, not GPU/canvas rasterization. Heap/RSS are observations, not leak evidence or per-sample allocation counts. No raw allocation count is claimed. These probes provide a finite baseline, not a hardware throughput certification or long-duration stability guarantee.
diff --git a/tools/performance/chart.mjs b/tools/performance/chart.mjs
new file mode 100644
index 0000000..d96f3ca
--- /dev/null
+++ b/tools/performance/chart.mjs
@@ -0,0 +1,21 @@
+// Run from Viewer with its locked Node/Vite. Fixed three scenes, three repeats.
+import {createRequire} from 'node:module';
+import {resolve} from 'node:path';
+import {performance} from 'node:perf_hooks';
+const require=createRequire(resolve('package.json'));
+const {createServer}=await import(require.resolve('vite'));
+const server=await createServer({server:{middlewareMode:true}});
+try {
+ const {ChartModel}=await server.ssrLoadModule('/app/renderer/model/chart-model.ts');
+ const axes=['Fx','Fy','Fz','Tx','Ty','Tz'];
+ // PlotAggregator sends first/min/max/last per 33 ms, not raw sensor samples.
+ for(const rate of [100,1000,7000])for(let repeat=0;repeat<3;repeat++){
+ const model=new ChartModel(60000);const n=Math.min(4,Math.ceil(rate*.033));let tick=0;
+ const batch=()=>({type:'plot_batch',payload:{axes:axes.map(axis=>({axis,points:Array.from({length:n},(_,i)=>({hostTimeNs:String(BigInt(tick*33000000+i*1000000)),value:Math.sin(tick+i)}))}))}});
+ for(;tick<1819;tick++)model.append(batch());
+ const durations=[];const cpu=process.cpuUsage();const start=performance.now();
+ for(let f=0;f<120;f++,tick++){let at=performance.now();model.append(batch());for(const a of axes)model.series(a).map(p=>[p.timeMs,p.value]);durations.push(performance.now()-at);}
+ durations.sort((a,b)=>a-b);const used=process.cpuUsage(cpu);const p=q=>durations[Math.min(durations.length-1,Math.floor(q*durations.length))];
+ console.log(JSON.stringify({rate,repeat,points:model.pointCount,p50_ms:p(.5),p95_ms:p(.95),p99_ms:p(.99),cpu_ms:(used.user+used.system)/1000,elapsed_ms:performance.now()-start,rss_bytes:process.memoryUsage().rss,heap_bytes:process.memoryUsage().heapUsed}));
+ }
+}finally{await server.close();}
diff --git a/tools/performance/corpus.cpp b/tools/performance/corpus.cpp
new file mode 100644
index 0000000..10c5f38
--- /dev/null
+++ b/tools/performance/corpus.cpp
@@ -0,0 +1,44 @@
+#include "detail/protocol.hpp"
+#include "detail/xml_config.hpp"
+#include
+#include
+#include
+#include
+#include
+int main(int argc, char **argv) {
+ if (argc != 2)
+ return 2;
+ std::string root = argv[1];
+ for (auto name : {"extreme", "short", "long"}) {
+ std::ifstream f(root + "/" + name + ".hex");
+ std::string hex;
+ f >> hex;
+ std::vector data;
+ for (size_t i = 0; i < hex.size(); i += 2)
+ data.push_back(static_cast(
+ std::stoul(hex.substr(i, 2), nullptr, 16)));
+ bool accepted = false;
+ try {
+ auto r = netft::detail::decode_record(data.data(), data.size());
+ accepted = true;
+ if (r.fx != INT32_MIN || r.fy != INT32_MAX)
+ return 1;
+ } catch (const netft::detail::ProtocolError &) {
+ }
+ if (accepted != (std::string(name) == "extreme"))
+ return 1;
+ }
+ for (auto name : {"unsafe-scale", "safe-scale"}) {
+ std::ifstream f(root + "/" + name + ".xml");
+ std::string xml{std::istreambuf_iterator(f), {}};
+ bool accepted = false;
+ try {
+ netft::detail::parse_sensor_configuration(xml);
+ accepted = true;
+ } catch (const std::exception &) {
+ }
+ if (accepted != (std::string(name) == "safe-scale"))
+ return 1;
+ }
+ std::cout << "5 corpus decoding/calibration cases passed\n";
+}
diff --git a/tools/performance/python.py b/tools/performance/python.py
new file mode 100644
index 0000000..a5c38c6
--- /dev/null
+++ b/tools/performance/python.py
@@ -0,0 +1,30 @@
+"""Fixed nine-second loopback baseline; only talks to its own UDP fake sensor."""
+import json,resource,socket,struct,threading,time,statistics
+from pynetft import Client,Config,Calibration,ForceUnit,TorqueUnit
+for rate in (100,1000,7000):
+ for repeat in range(3):
+ sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);sock.bind(('127.0.0.1',0));sock.settimeout(.05)
+ done=threading.Event()
+ def producer():
+ peer=None;sequence=0;next_at=time.monotonic()
+ while not done.is_set():
+ if peer is None:
+ try:
+ request,address=sock.recvfrom(64)
+ if request[2:4]==b'\x00\x02':peer=address
+ except socket.timeout:continue
+ sequence+=1;sock.sendto(struct.pack('>III6i',sequence,sequence,0,100,-200,300,10,-20,30),peer)
+ next_at+=1/rate;done.wait(max(0,next_at-time.monotonic()))
+ worker=threading.Thread(target=producer);worker.start()
+ config=Config(sensor_host='127.0.0.1',rdt_port=sock.getsockname()[1],calibration_override=Calibration(1e6,1e6,ForceUnit.NEWTON,TorqueUnit.NEWTON_METER))
+ client=Client(config,queue_size=128);age=[];cpu=time.process_time();start=time.monotonic()
+ try:
+ client.start();samples=client.samples(timeout=.2)
+ while time.monotonic()-start<1:
+ s=next(samples);age.append((time.monotonic_ns()-s.received_at_ns)/1000)
+ if rate==7000:time.sleep(.0005)
+ health=client.health();stop=time.monotonic();client.stop();stop_ms=(time.monotonic()-stop)*1000
+ finally:
+ client.stop();done.set();worker.join(1);sock.close()
+ age.sort();p=lambda q:age[min(len(age)-1,int(q*len(age)))] if age else 0
+ print(json.dumps(dict(rate=rate,repeat=repeat,samples=len(age),p50_us=p(.5),p95_us=p(.95),p99_us=p(.99),cpu_percent=100*(time.process_time()-cpu)/(time.monotonic()-start),rss_peak_kib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,queue_drops=health.python_queue_dropped_count,lost=health.lost_count,stop_ms=stop_ms)))
diff --git a/tools/performance/sdk.cpp b/tools/performance/sdk.cpp
new file mode 100644
index 0000000..e341f47
--- /dev/null
+++ b/tools/performance/sdk.cpp
@@ -0,0 +1,57 @@
+#include "netft/client.hpp"
+#include "support/fake_sensor.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+using Clock = std::chrono::steady_clock;
+int main() {
+ for (int rate : {100, 1000, 7000})
+ for (int repeat = 0; repeat < 3; ++repeat) {
+ netft::test::FakeSensor sensor(rate);
+ netft::Config config;
+ config.sensor_host = sensor.host();
+ config.rdt_port = sensor.rdt_port();
+ config.http_port = sensor.http_port();
+ netft::Client client(config);
+ std::vector age;
+ age.reserve(9000);
+ auto cpu = std::clock();
+ auto start = Clock::now();
+ client.start([&](const netft::Sample &s) {
+ age.push_back(std::chrono::duration(Clock::now() -
+ s.received_at)
+ .count());
+ if (rate == 7000)
+ std::this_thread::sleep_for(std::chrono::microseconds(200));
+ });
+ std::this_thread::sleep_for(std::chrono::seconds(1));
+ auto stop = Clock::now();
+ client.stop();
+ const auto stop_ms =
+ std::chrono::duration(Clock::now() - stop)
+ .count();
+ auto h = client.health();
+ auto cpu_pct =
+ 100.0 * (std::clock() - cpu) / CLOCKS_PER_SEC /
+ std::chrono::duration(Clock::now() - start).count();
+ std::sort(age.begin(), age.end());
+ auto p = [&](double q) {
+ return age.empty()
+ ? 0
+ : age[std::min(age.size() - 1, size_t(q * age.size()))];
+ };
+ struct rusage usage{};
+ getrusage(RUSAGE_SELF, &usage);
+ std::cout << "{\"rate\":" << rate << ",\"repeat\":" << repeat
+ << ",\"samples\":" << age.size() << ",\"p50_us\":" << p(.50)
+ << ",\"p95_us\":" << p(.95) << ",\"p99_us\":" << p(.99)
+ << ",\"cpu_percent\":" << cpu_pct
+ << ",\"rss_peak_kib\":" << usage.ru_maxrss
+ << ",\"lost\":" << h.lost_count << ",\"stop_ms\":" << stop_ms
+ << "}\n";
+ }
+}