# #302 STEP 1 field-driver contract — v1

Owner: hertz. Instrumentation producer: todlando, after W2b and #309. Ruling: [5691417538](https://github.com/BigscreenVR/spt-bs-releases/issues/302#issuecomment-5691417538). Authorization: Doyle BPO6OCD5. This is a diagnostic contract, not the conditional remedy.

## Execution and safety

One-command capture after the authorized instrumented build is staged:

```text
python .spt/preserved/302/step1-field.py --host decid@enlyzeam --seconds 180 --out .spt/preserved/302/field-STEP1-<unique-run>
```

This wrapper performs steps1–3 below, preserves before/after log bytes and their hashes, verifies append-only continuity, and runs the analyzer locally. Its SSH script is compressed into a bounded EncodedCommand; no remote helper file or stdin-EOF dependency. Optional `--home`, `--binary` and `--pty` override explicit inputs. It does not stage the build. Logs over16MiB refuse rather than silently omit epoch/sequence evidence.

1. Run `step1-collect.ps1` on ENLYZEAM via the already-authorized SSH route. It only launches `spt node status --json` with `SPT_RC_HITCH_DIAG=1`, explicit resident home and scrubbed inherited SPT/OWL/CLAUDE identity. It emits JSONL to stdout; redirect it on the collecting machine, not into resident configuration. Default 180 seconds, 1 Hz, 5-second per-query limit. Kill only the retained owned query-client handle on timeout. Record skipped schedules rather than issuing catch-up bursts.
2. Read a bounded byte snapshot of `logs/daemon.stderr.log` before and after the window. Preserve source path, byte range, capture UTC and SHA256. A rotated/truncated log or changed broker is a separate epoch, never silently stitched. Do not restart, update, configure, attach a debugger to, or stop a resident. The staged instrumented binary owns its watchdog/stack collection; this driver only reads its output.
3. Run `step1-analyze.py --samples <jsonl> --log <snapshot> --out <new-directory>`. Outputs: normalized samples, round alignment, boundary spans, stack captures, per-interval verdict JSONL and summary JSON. Existing output is refused.
4. Keep the original 25ms canary and dial gauge unchanged. Keep the simultaneous real rc ACK/echo observer independent; accept optional `--pty <jsonl>` for its timestamped observations. Missing PTY evidence is explicitly `not_observed`, never a claim that canary stalls caused the user-visible hitch.

Completion: legacy dry-run emits honest insufficient-evidence rows; instrumented evidence can discriminate the candidate using both workers, with the same thresholds. No permission escalation is part of this lane.

## Thresholds fixed before the run

| Quantity | Contract |
|---|---|
| Default window | 180s at 1 Hz; valid range 10–900s |
| Status query | owned client deadline 5000ms; kill confirmation 2000ms |
| Usable diagnostic sample | exit 0, no timeout/probe error, net enabled, nonnegative numeric age/dials, same broker epoch; outer query <=250ms and gauge query <=250ms for precise joins |
| Sample cadence | no inter-sample gap >1500ms within one observed stale episode; gaps split episodes and invalidate complete-run coverage |
| Stale canary | age >=1000ms (40 nominal ticks); severe >=2000ms |
| Healthy recovery | next valid sample below1000ms; observation gives a recovery bound, not an exact recovery time |
| Long synchronous boundary | >=250ms; short overlaps are reported but not sufficient mechanism evidence |
| Meet association | round before a peak by 0–15000ms; report the actual bounds, never force the historical 5–10s expectation |
| Clock alignment | wall-minus-monotonic offset spread <=100ms within an epoch; otherwise temporal verdict is insufficient |
| Instrumentation heartbeat | independent of the net runtime, at least every1000ms; interval coverage rejects >1500ms gaps or dropped/missing sequence records |
| Stack trigger | independent watchdog when original canary age >=1000ms; paired both-worker capture every500ms while stale, max capture span100ms (marked raw-copy-through-confirmed-resume window; unmarked legacy total span); serialized through post-resume processing, so cadence slips rather than overlaps |
| Repeated stack evidence | two complete paired captures >=250ms apart in the stale support interval; no one-worker substitution |
| Run coverage PASS | >=95% usable samples, duration >=170s for default180s, no cadence gap >1500ms, one broker epoch; all six boundary capabilities, complete sequence/heartbeat coverage and both-worker evidence for every stale interval |
| Mechanism CONFIRMED | a long synchronous candidate boundary on a named net worker overlaps a stale interval, and repeated paired stacks account for BOTH workers in candidate synchronous boundaries (or both tied to the same such operation). Mere async bind overlap is not enough |
| Mechanism FALSIFIED for interval | complete candidate-boundary coverage places all boundaries outside the stale support; OR repeated complete paired stacks show both workers idle; OR both show unrelated progress using increasing genuine per-worker counters |
| Otherwise | INSUFFICIENT_EVIDENCE; overlaps may still be reported. Missing diagnostics, no stale episodes, unknown worker, async wait only, clock discontinuity, or open/unmatched spans never become a healthy/falsified result |

A run may be `MIXED`: some episodes confirm while others falsify. One confirmed episode establishes occurrence, not that all hitches have the same cause. Legacy data is not subject to an invented completeness PASS.

## Canary intervals and clocks

Preserve the existing fields `sampled_at_ms`, `net_canary_age_ms`, `active_dial_tasks`, `query_elapsed_ms`, `broker_pid`, `broker_version`. `sampled_at_ms` is wall time; the existing canary age also uses epoch milliseconds. Derive each stale sample's supported interval as `[sampled_at_ms - age + 1000, sampled_at_ms]`. Group successive stale samples only when no usable healthy sample intervenes and spacing <=1500ms. Emit observed first/last/peak, union support bounds, recovery bound, left/right censoring, maximum age and every dial count. Invalid samples break rather than bridge episodes.

Clock-join uncertainty includes the status query duration. Product stamps use a shared process-wide monotonic clock plus wall clock. Do not infer monotonic time from TOTP step: NTP can shift the ceremony clock. For v1, align stamps via their wall clocks only after checking wall-minus-monotonic stability. Carry original monotonic stamps and operation durations in every row. Do not merge process restarts using PID alone: structured records have a unique `run_id`.

Conservative join: stack captures must fit inside the stale support after shrinking both ends by sample-query uncertainty. Boundary exclusion uses the support expanded by that uncertainty. Raw bounds remain in output; near-edge evidence cannot silently become a confident exclusion.

## Structured stamp schema — producer contract

One atomic log line per record:

```text
NET_DIAG_V1: {"v":1,"event":"NET_IF_ENUM_BEGIN","seq":12,"run_id":"unique-broker-start","broker_pid":48932,"wall_ms":1789526670000,"mono_ms":123456,"runtime":"spt-broker-net","tid":111,"op_id":"17","parent_op_id":"9","execution":"sync","subnet":"SPT_DEV","step":59650889}
```

Common REQUIRED fields: `v=1`, `event`, contiguous process-wide `seq` (START is seq0), unique `run_id`, `broker_pid`, `wall_ms`, `mono_ms`. Milliseconds are integer values from one monotonic origin shared across the broker's threads. `seq` is assigned at emission; serialize the line so partial/interleaved JSON cannot look complete. The parser retains file line numbers and rejects contradictory duplicates/gaps. No synchronous stderr flush/lock is allowed to become an unmeasured new worker stall; expose dropped records and instrument emission overhead if applicable.

### Epoch and coverage

- `NET_DIAG_START`: adds `worker_tids` (exact two actual OS TIDs for the unchanged `spt-broker-net` runtime), `capabilities` (the six base names below), `canary_period_ms:25`, `dropped_records:0`. Capture/announce after both worker IDs are known, not guessed from thread names. An incomplete startup census is unsupported.
- `NET_DIAG_HEARTBEAT`: adds `dropped_records` (cumulative, must remain0). Emit on an independent observer, not a stalled runtime task.
- `NET_DIAG_END`: normal epoch end if available; includes `dropped_records`. Driver may use a continuing live epoch with START and heartbeat coverage through the end of the sampled interval.

### Boundary names and scope

Every BEGIN/END pair carries the same `op_id`, `runtime`, `execution`, `parent_op_id` (null if no parent), and the actual `tid` at each stamp. END also carries `outcome` (`ok`, `error`, `cancelled`). IDs are unique within run_id. Nesting must retain parent identity. Async tasks may migrate TIDs; synchronous boundaries may not. `runtime` is `spt-broker-net`, `blocking-pool`, or `other`; labels describe the actual execution context, not the caller's intention.

| Base name, suffixed `_BEGIN` / `_END` | Exact boundary | execution |
|---|---|---|
| `NET_IF_ENUM` | actual synchronous interface enumeration / GetAdaptersAddresses, including retry loop | sync |
| `NET_GATEWAY_LOOKUP` | portmapper ip_and_gateway/HomeRouter::new synchronous work before protocol futures | sync |
| `NET_CANCEL_MIB` | actual CancelMibChangeNotify2 call, per registration; not merely wrapper Drop entry | sync |
| `NET_MEET_BIND` | whole per-subnet bind_rendezvous / endpoint builder bind; include subnet, step | async |
| `NET_MEET_RETIRE` | actual endpoint/task retirement lifetime; include subnet, step, and `stage` on both stamps (`abort-request`, `task-drop`, or `endpoint-drop`), label sync/async truthfully | sync or async |
| `NET_NTP_REFRESH` | actual cache-miss NTP refresh, including mutex/DNS/socket work, NOT every cache-hit now_secs call | sync |

Abort requested is not retirement complete. Instrument the actual destructor/OS call separately. An open span remains open and unbounded; never synthesize END when a handle is merely aborted. Boundary END duration is computed from mono_ms, not wall time. Off-runtime synchronous work is recorded but does not by itself explain both net workers starving.

### Meet/family alignment

Keep existing human lines. Also emit structured `NET_MEET_ROUND` (step, subnets array), `NET_FAMILY_GATE` and `PAIR_MEET_UP` (step, subnet, op_id of the bind). These use the common timestamp/epoch fields. Family gates are emitted AFTER family probing, just as today. Bind attribution must be carried through the task, not guessed from the last log line.

Legacy 0.70.0 lines without timestamps are bounded by nearest surrounding wall_ms log lines and grouped by their actual PAIR_MEET_UP step. Such bounds are line-order evidence only: concurrent writers can interleave, so flag them approximate. Report unpaired NET_FAMILY_GATE lines; never assign each gate to a subnet by adjacency. Three subnets are an admission observation, not a parser constant.

### Both-worker stack capture

`NET_WORKERS_SNAPSHOT` common fields plus:

```json
{"capture_id":"s3","capture_timing":"raw-copy-resume-v1","capture_begin_mono_ms":123000,"capture_end_mono_ms":123030,"processing_begin_mono_ms":123030,"processing_end_mono_ms":123300,"processing_duration_ms":270,"total_duration_ms":300,"trigger_canary_age_ms":2030,"workers":[{"tid":111,"activity":"boundary","op_id":"17","frames":["ntdll!NtWaitForSingleObject","iphlpapi!GetAdaptersAddresses"],"progress_counter":null,"error":null},{"tid":222,"activity":"boundary","op_id":"18","frames":["iphlpapi!CancelMibChangeNotify2"],"progress_counter":null,"error":null}]}
```

**Timing amendment, doyle ruling FB4BCXZD (2026-09-16).** Only records explicitly
marked `capture_timing="raw-copy-resume-v1"` use the new split: capture begin/end
bracket suspension, raw register/stack copies and CONFIRMED resume of BOTH workers.
That raw window retains the <=100ms gate and the conservative in-peak check.
`processing_begin_mono_ms`, `processing_end_mono_ms`, `processing_duration_ms`,
and the producer-agreed `total_duration_ms` are required. Processing begins at
capture end; its end follows backend unwind/symbolization. Durations are exact
differences of those same-clock endpoints: processing=end-begin,
total=processing_end-capture_begin. Missing, reversed or inconsistent times
invalidate the pair; an unknown timing marker is never interpreted as this schema.

Post-resume processing cost is a MEASUREMENT, not another 100ms gate. Raw records
retain it in `stacks.jsonl`; `summary.json.processing_duration_ms` reports count,
missing/invalid count, minimum, maximum and sum. Accepted/rejected interval evidence
also carries both costs. Capture is serialized through prior processing completion:
a new raw window before the prior processing end invalidates epoch coverage.
Unmarked historical records retain their ORIGINAL total-span meaning and <=100ms
gate, even if stray processing fields are present. Old receipts are not rewritten.

`workers` must cover both START TIDs exactly, with no error, real symbolized frames, capture duration<=100ms and trigger age>=1000ms. Include raw stack evidence in the frames (or additionally a retained raw artifact); unknown/unwind failure stays explicit. `activity` is `boundary`, `idle`, `unrelated`, or `unknown`:
- boundary: op_id must match a synchronous active span on that worker; frames must contain the relevant boundary symbol, not just a generic wait. Async builder/probe frames alone are not blocked-worker proof.
- idle: real Tokio worker/park or reactor-park frames; a generic Windows wait alone does not prove executor idle.
- unrelated: nonempty real frames plus a genuine completed-work counter increasing between paired captures. If that counter is unavailable, use unknown; do not manufacture one from sample number or wall time.

The independent watchdog captures while the original canary is stale; worker-cooperative callbacks that only run after recovery cannot establish an in-peak stack. No driver-triggered external debugger attach is authorized. Any worker error, including failed resume, invalidates the pair; the producer's fail-stop path is unchanged. The next capture waits until prior processing ends, slipping cadence instead of overlapping. Capture and processing costs remain visible separately.

## Optional PTY observation input

JSONL rows: `wall_ms`, `route` (`local` or `remote`), `tag`, `sent_ms`, `ack_ms` (null if not yet observed), `state_confirmed` boolean. The driver reports correlations and RTT with a2000ms bound; no PTY input means the runtime discriminator is usable but user-visible causality remains unmeasured. Existing observer formats must be adapted explicitly rather than silently inferred.

## Artifacts and command forms
- `step1-field.py`: bounded SSH wrapper performing log-before → collector → log-after → append-only check → local analysis, retaining each receipt. Exit codes follow the analyzer unless capture fails.

- `step1-collect.ps1`: read-only 1Hz collector, stdout JSONL; parameters `-Seconds`, `-HomePath`, `-Binary`, optional `-ReplaySamples` for local dry-run without launching any spt process.
- `step1_rounds.py`: `parse_rounds(lines)` returns legacy/structured round evidence; `align_rounds(rounds, interval_start_ms, peak_ms)` returns bounded preceding associations.
- `step1-analyze.py`: command above. Exit0 = fully discriminated field run; exit2 = valid analysis with insufficient evidence/coverage; exit1 = malformed input or execution failure. A supported mechanism result is not a product-fix PASS.

## Verified dry-run receipts

- `step1-dry-run-070-final/`: historical ENLYZEAM180 samples, all180 usable; 17 stale, 7 severe, maximum4912ms, dials0 throughout. Eleven threshold-defined stale intervals. All11 correctly emit `INSUFFICIENT_EVIDENCE` because0.70.0 supplies no boundary stamps or worker stacks; analyzer exit2.
- Actual0.70.0 log snapshot: `enlyzeam-070-daemon-snapshot.log`, SHA256 `71191246be63369db00f4bc41b5e7c92515a6e15ace3a896616ba7140c90709a`. Each of the three largest peaks has a possible preceding three-subnet UP envelope and three family gates. Timing brackets are broad; two include slightly post-peak UP times. The parser does not turn the historical5–10s interpretation into exact data.
- `step1-replay.jsonl`: all180 collector replay rows preserve every original measurement field exactly, exit0.
- `step1-live-smoke-070.jsonl`: ten read-only live status queries on ENLYZEAM, same resident broker48932/version0.70.0; all clients exit0 with termination confirmed. No daemon lifecycle action.
- `step1-field-smoke-070-v2/field-receipt.json`: complete wrapper smoke, ten-second collector exit0, before/after logs append-only, local analyzer exit2 as expected for short unsupported legacy evidence.
- `step1-contract-verification.json`: synthetic protocol checks, NOT field mechanism evidence. Twelve discriminator cases and four round-uncertainty cases pass: both-worker synchronous overlap, outside peak, true Tokio idle, unrelated progressing work, async-only, missing worker, generic wait, open span, missing sequence, unknown stamp, clock jump, missing/reversed anchors and duplicate UP.

Transport-attempt history: an uncompressed encoded script exceeded Windows argv length; a stdin-based wrapper later timed out. The final compressed-argv transport completed the full smoke. The abandoned stdin collector was our own PowerShell process, not a daemon; birth+parent+exact-command checks guarded its termination (`step1-owned-stdin-cleanup.json`). No resident daemon, config, adapter or lifecycle state was changed. Earlier failure artifacts remain preserved.

Field verdict awaits the staged instrumented build. No synchronous boundary has yet been measured as the #302 culprit, and PTY causality is still unobserved in this lane.
