Changelog0.2.0

Decisions

Decisions in fragcap 0.2.0.

  • 2026-08-06 Spec Kit helper scripts generated as POSIX sh rather than PowerShell, deviating from the sibling repositories. Continuous integration runs on both Linux and Windows (specification section 24.3), and sh runs on every contributor platform including Git Bash and WSL2 without requiring a PowerShell 7 install. This does not affect the shell wrapper requirement in specification section 18, which still ships both Bash and PowerShell wrappers.
  • 2026-08-06 Four spec-kit agent surfaces installed rather than one. The .specify/ engine is agent-neutral by construction; the per-agent command directories are conveniences over it. Regenerating four surfaces on a spec-kit upgrade is the accepted cost of not privileging one agent.
  • 2026-08-06 Vendored skill content placed in .agents/skills/ rather than a bespoke directory. Codex reads that path natively, the skills CLI targets it, and spec-kit's own Codex integration writes there, so it is the path with the most tooling agreement.
  • 2026-08-06 The vendored skill set is curated against constitution P-1 rather than mirrored wholesale from a sibling repository. A skill is checked against P-1 before it is vendored.

2026-08-06 The build toolchain is pinned at 1.96.0 while the minimum supported version is declared separately as 1.82 and verified by its own check. Pinning the build channel at the minimum would hold every later slice's dependencies back to a 2024 toolchain in exchange for a claim obtainable more cheaply.

2026-08-06 The repository conventions linter is a task runner subcommand in Rust rather than a shell script. The house shell standard is a known missing gap, the task runner is specified to require nothing beyond the language toolchain, and a Rust check can be unit tested against known-bad input. A linter whose matcher never fires is indistinguishable from a clean repository.

2026-08-06 The facade crate depends on fragcap-core directly. The dependency diagram in specification section 8.3 omits that edge, but a facade that re-exports core types needs core as a direct dependency. The edge violates neither stated rule. To be promoted to section 8.3 at the next revision.

2026-08-06 Platform neutrality is enforced by two checks rather than one. Building fragcap-core for a target with no capture backend proves it compiles portably, but does not fail when a platform crate is added to it, because such crates compile to nothing off-platform. The manifest check in cargo xtask deps asserts the stronger property that data model rule V-4 requires.

2026-08-06 The docs and links workflows are manual-dispatch only until slice S18 implements them. Both were written to run automatically and exit non-zero, on the reasoning that a skeleton must not report success for work it has not done. That reasoning is right and the implementation was wrong: a job that fails on every push reports red permanently, which carries no more information than reporting green and trains readers to ignore the signal. Not reporting at all is the honest option. The release workflow keeps its trigger, because it is tag-gated and will not fire until a release is deliberately cut.

2026-08-06 Workflow triggers scoped after the first push produced six runs for three workflows. An unqualified on: push plus on: pull_request fires both for any branch with an open pull request, doubling the minute burn. push is now scoped to the default branch, and every workflow carries a concurrency group so superseded runs cancel rather than queue.

platform and audit are manual-dispatch only for the same reason docs and links are: neither has anything to verify yet. No crate links the capture library until S09, and the workspace has no external dependencies to audit until S02. Both regain real triggers with the slice that gives them a subject.

2026-08-06 Removed --features platform-tests from the platform workflow. No crate declares that feature, so the flag would have failed resolution. It was never caught locally because the workflow has never run.

2026-08-10. FlowAttributor::refresh changes from &mut self to &self, an architecture-of-record trait change taken through the deviation process and promoted to specification section 29, together with two added trait methods. Recorded while implementing slice 015 (the S13 follow-ups, issues #18 and #19).

  • refresh(&self) is the deviation. Specification section 8.5 declared it &mut self, and section 8.6 places the socket-table refresh on the pipeline control thread; the two could not both hold, because a &mut self method cannot be called through the Arc<dyn FlowAttributor> the capture threads share for lock-free resolve (section 11.6). refresh now takes &self; SocketTableAttributor carries its refresh-mutable state (the table source, the process namer, and the retention map) behind a single Mutex that the resolve path never touches, so section 11.6 is preserved. A concurrency test drives refresh through a shared Arc while several threads resolve.
  • Two trait methods were added, both defaulted. wants_refresh(&self) -> bool (default false) lets the control thread gate the refresh on the section 11.2 cadence without fragcap-core naming the schedule type that lives in fragcap-attr (a P-2 guard). active_endpoints_owned(&self) -> Vec<OwnedEndpoint> (default maps active_endpoints to an unknown owner) carries the owning identifier the section 12.2 narrowing needs. The defaults mean the scripted and stub attributors and the read-only resolver change behavior in no way.
  • Narrowing filters in the role-stamping decorator, and keeps unknown-owner endpoints. The session's RoleStampingAttributor holds the binding snapshot whose keys are the profiled process identifiers, so it is the one seam that can perform the join (P-3: the pipeline and fragcap-attr learn no profiles). It excludes an endpoint only when its owner is known and not profiled; an endpoint with no known owner is kept. On the live socket-table backend every endpoint carries an owner, so this is exactly "admit only profiled"; on the offline scripted substrate no endpoint carries one, so it is a pass-through and the offline goldens are byte-identical.
  • The CLI RefreshDriver and the read/write split were retired on the live path. With refresh(&self) the pipeline shares and refreshes the real attributor directly, so the separate refresh thread and the PublishedResolver it fed are no longer used there. PublishedResolver is retained as a valid read-only view (removing it entirely is a separable cleanup); the CLI inner_attributor component field is removed, and the filter-narrowed event now reports the profiled endpoint count.
  • Numbering. This is spec directory 015-attribution-pipeline-integration, a follow-up to S13, and is not the roadmap's reserved slice S15 (streaming sinks). docs/plans/README.md records that the roadmap slices S15 through S18 take directory ordinals 018 through 021.

2026-08-10. Slice 017 (issue #22) reverses two S14 decisions for the two cases they were wrong for and keeps them for the case they were right for. Recorded while implementing the CLI capture engine's write gating.

  • The observe-only tee becomes a synchronous write gate (reverses D-e for the bound and watch-time cases). S14's TeeCountingSink observed each captured packet after the sinks had written it, so the session could count a bound only after the file already exceeded it. It is replaced by a SessionGate the output loop consults before the fan-out: the session's decision now gates the write. The gate keeps forwarding each admitted packet to the driver over the same channel the tee used, so CaptureSession::on_packet still fires VolumeReached and the duration bound in the session, which stays the single owner of the six stop conditions (section 10.6). For an unbounded offline run the gate is a pass-through and D-e's arrangement is unchanged.

  • The session's decision gates the write, but packets are still not routed through the CaptureSession object (keeps D-c's separation). The gate reads a lock-free published window state (open while capturing, closed while watching or draining) that the driver writes as the session transitions, the same discipline section 11.6 requires of the attribution snapshot. The gate owns the bound counting, so the admit-or-discard decision is made on the sink thread with no per-packet cross-thread call into the session. The session remains a control brain beside the pipeline, not inside its packet path.

  • The gate seam is generic in core; the session-aware policy is in the facade (constitution P-3). fragcap-core gains only a WriteGate trait (Send + Sync, admit(&CapturedPacket) -> bool) that the output loop consults; it learns nothing of capture sessions or profiles. The SessionGate lives in the facade session module beside CaptureSession and RoleStampingAttributor, the crate already above both fragcap-capture and fragcap-attr, and is handed to the pipeline as an Arc<dyn WriteGate>. No new dependency, and core takes no platform dependency.

  • gate_dropped is a term of the conservation identity but not of loss (constitution P-4, P-9). A gate discard is counted so nothing the gate withholds escapes the pipeline accounting, but it is an intended discard (the operator's own bound or the pre-acquisition window), so it is kept out of fragcap_dropped, total_dropped, and lost_anything, which separate a slow sink and an undersized driver buffer from a configuration choice. The writer trailers (pcapng and JSON Lines) are deliberately left unchanged, so the committed goldens stay byte-identical; the gate's discards are surfaced by cause in the completion summary's watching_discarded and discarded_out_of_window lines, whose sum reconciles with gate_dropped.

  • The gate's Sender lives on the gate alone, not on the shared handle. The driver keeps a GateHandle sharing the gate's atomics (to publish the window and read the tallies) but holding no channel sender, so the tee channel closes when the pipeline finishes and the driver's per-packet read loop ends. Sharing the sender would keep the channel open for the whole run and hang the loop after the source exhausts.

These are behavioral reversals confined to this slice; they are promoted to docs/fragcap-specification.md only at release, not per slice.

2026-08-10. Review of PR #26 (four Codex findings) tightened the gate.

  • The gate classifies by the packet's own capture instant, not the window state at write time. The window became a half-open capture interval [admit_from, admit_until) of two single-writer AtomicI64 values, replacing the AtomicU8 window state. Because the bounded buffer sits between capture and the gate, a coarse state read at write time misclassified buffered frames crossing a transition: a pre-acquisition frame still buffered when the window opened was written and omitted from watching_discarded (C2), and a post-stop frame still draining was written and miscounted as retained (C1). Keying on the packet instant fixes both; on the live path the packet instant (pcap header) and the opening or closing event instant (ETW header) are both Unix wall-clock and directly comparable. Offline opens the interval at i64::MIN (all replayed frames in window, goldens unchanged); live opens it at the acquiring event's instant and closes it at a terminal-stage exit's instant. An interrupt or duration stop leaves it open, keeping what was captured before the stop (FR-005).
  • The live acquisition loop observes pipeline termination. Spawning the pipeline from arm means it can end before a target is acquired (the sole interface closes or fails). The loop now polls the tee channel for disconnect, the signal that no source remains, and ends the run instead of waiting forever on a still-running watcher (C3).
  • A zero volume bound stops for VolumeReached. See D-8 in the slice research. CaptureSession::on_volume_reached is called by the driver after acquisition when a zero bound is configured, so --max-packets 0 reports the promised stop reason (P2).

2026-08-08 The dependency direction check no longer requires fragcap-core to have zero dependencies. It now checks against a named allowlist, currently one crate. The empty-set rule was stricter than the principle it enforces: constitution P-2 forbids a platform-specific dependency, an I/O crate, and a capture library, not every dependency. The rule would have blocked a pure-Rust buffer crate on a reading the constitution does not support. The check still fails closed, so anything not on the list is a problem and adding to the list is a deliberate edit a reviewer sees.

2026-08-08 Recorded for promotion to specification section 29: sections 8.4 and 8.5 reference eight types in their signatures and define none of them. They are Timestamp, Bytes, StageId, LinkType, Endpoint, FilterProgram, ProcessEvent, and ProcessRecord. Slice S02 defines all eight, with the three that later slices own documented as provisional: FilterProgram is settled by S13, and ProcessEvent and ProcessRecord by S11. This is a gap in the architecture of record rather than a decision it made, and the constitution requires the divergence be recorded rather than resolved silently.

2026-08-08 The minimum supported toolchain check builds at the declared minimum instead of with the pinned toolchain. It previously ran an ordinary build and reported success, which checked the pinned toolchain and said nothing about the minimum. That was harmless while the dependency graph was empty and every declared minimum passed trivially, and stopped being harmless the moment a real dependency arrived. It now builds through rustup run, into a separate target directory so it does not try to replace the running task runner binary, and exits 2 when the minimum toolchain is not installed.

2026-08-08 Recorded for promotion to specification section 29: section 12.5 requires that subsequent IP fragments be attributed by their fragment identifier and address pair, which presupposes a memory it does not describe. Slice S03 defines one: a fixed 256 entry table, drop-oldest, holding the protocol and ports the first fragment carried, with the eviction counted and the entry removed when the datagram's last fragment is observed. It is bounded by entry count rather than by age because an age bound needs a clock, and a clock in fragcap-core is a platform surface constitution P-2 excludes.

2026-08-08 Recorded for promotion to specification section 29: section 12.6 defines three of the four combinations of endpoint locality and is silent on the fourth, a packet with neither endpoint on the capturing host. S03 makes it a counted rejection producing no flow key, on the reasoning that section 8.4 defines the key's local field as the endpoint on the capturing host, so putting an arbitrary endpoint there would assert something untrue. It would also buy nothing: such a packet has no local socket, so no socket table lookup could ever resolve the key. The practical consequence is that a stale or empty interface address set announces itself once per packet instead of yielding a capture full of keys that resolve to nothing.

2026-08-08 Recorded for promotion to specification section 29: the residual mis-attribution risk from IPv4 fragment identifier reuse. The identifier is sixteen bits, and a host that fragments heavily can reuse one for the same address pair and protocol before the earlier table entry has been removed. Removing an entry when its datagram's last fragment is observed shortens the window and the 256 entry bound caps it, but neither eliminates it, and it is not detectable from the capture so it cannot be counted. It is stated rather than claimed away, which is what constitution P-9 requires when P-4's mechanism does not reach.

2026-08-08 Corrected in fragcap-core: link type code 0 was documented as having no link layer header. That is code 101's property. Code 0 is BSD loopback encapsulation, which prefixes a four byte host-order address family value. The error was harmless while nothing parsed and would have had S03's parser read an IP version nibble out of an address family field, rejecting every loopback frame and attributing the failure to the wrong cause. Recorded because a comment that misdescribes an observation is the kind of small inaccuracy a later parser inherits.

2026-08-08 Recorded for promotion to specification section 29: section 25.3 requires an attribution script per fixture, declaring what the scripted attributor returns for each flow at each point in time, without defining one. Slice S04 defines a line-oriented format with three statements and half-open time windows. TOML was rejected for now: adopting it means adopting a parser and its proc-macro dependencies on behalf of S05, which owns the profile schema and should choose against the profile's requirements rather than inherit a choice made for a test fixture.

2026-08-08 Recorded for promotion to specification section 29: section 25.3's burst.pcap must both exceed a 65,536 packet buffer and be small, and those cannot both hold, since a faithful fixture would run to several megabytes. Backpressure is a relationship between a rate and a capacity rather than a property of a file, so the fixture supplies the rate and S08's test supplies a small capacity. The narrowing is deliberate and is recorded rather than applied silently.

2026-08-08 Recorded for promotion to specification section 29: FlowAttributor::resolve in section 8.5 now takes the instant the packet was observed. S02 transcribed it without one, and this slice initially kept it that way, carrying the clock as an inherent method on the scripted attributor and arguing that a real attributor reads a socket table that is already current.

Review of pull request 7 refuted that twice. Section 11.4 already says capture and socket table observation are not synchronized, and that a closing connection produces final packets processed after the socket has left the table; that is why the retention window exists, and it means a real attributor is also answering about the past. Separately, section 8.6 holds the attributor behind a trait object, so an inherent method is unreachable from the pipeline and core cannot downcast to a backend without the dependency P-2 and P-3 forbid. Every time-windowed fixture would have sat at the epoch resolving nothing, and the slice's own test hid that by holding the concrete type.

The change costs one parameter now, with a single real implementor. After S10, S11, and S12 it would have cost considerably more.

2026-08-08 .gitattributes gains *.script text eol=lf. The existing wildcard already covered it; listing it matches the file's own stated convention of naming every format whose parsing depends on line endings, and keeps the corpus drift check from depending on autodetection.

2026-08-09: Two runtime dependencies, chosen by measurement rather than reputation

The workspace has added one runtime dependency in eight slices, so a second and third are an architectural event and are recorded as one. fragcap-profile takes toml-span 0.7 and regex 1.13 with default features off. Five crates enter the graph: toml-span, smallvec, regex, regex-automata, and regex-syntax. Every license is MIT or Apache-2.0, inside the deny.toml allowlist, and no version specification is a wildcard.

Why not hand-roll TOML, when pcap, pcapng, and JSON Lines were hand-rolled. Those three formats were the deliverable, produced by fragcap or by a tool, and hand-rolling gave verification something independent to judge against. A profile is a file a contributor typed. A hand-rolled subset would refuse legal TOML an author's editor produced, and section 15.1 promises that adding support for a game means writing a TOML file, which a parser that rejects valid files does not survive.

Why not toml. It is unavailable at this workspace's declared minimum toolchain. Version 1.1 declares Rust 1.85 against a floor of 1.82, and pinning to ~1.0 does not fix it: toml_parser resolves to 1.1.3 underneath and declares 1.85 as well. Holding the floor would mean a direct dependency on a crate this slice never calls, purely to constrain it, which one cargo update undoes without anything failing loudly. toml-span declares 1.70, brings one transitive crate rather than four, has no serde in its graph, and carries byte spans on every value, which is what the diagnostics are built on. Verified by building under rustup run 1.82.

A serde-derived deserializer was never on offer, which is worth stating because it is the obvious ergonomic path. Such a deserializer returns the first error and stops, and section 15.4 requires every problem in one report. The requirement rules out the shape, so field extraction is written by hand and the question of serde at runtime does not arise. S07's serde_json remains test-only and that argument is undisturbed.

Why regex and not regex-lite. Section 15.4 requires compiling path_regex, so an engine is unavoidable, and it must be the engine that evaluates the pattern in S12: validating with one and matching with another lets a pattern pass validation and fail during a capture. regex-lite is one crate with no dependencies, which is attractive here, and it was rejected because its Unicode support is reduced. An image path can carry non-ASCII through a user or localized directory name, and matching under quietly different Unicode rules produces a wrong binding rather than an error. Default features are off because aho-corasick and memchr accelerate scanning large haystacks, and a haystack here is one image path matched a few times per session.

The glob matcher stays hand-rolled despite the above, and the pairing only looks inconsistent. Section 15.4 needs to know whether two exe patterns can match a common image name, which is glob intersection; every glob crate answers glob matching, which is the intersection of a pattern with a literal. A dependency would supply half the requirement and leave the harder half to be written anyway, giving two implementations of one syntax to drift apart.

2026-08-09: The duration grammar lives in fragcap-core

Section 25.2 lists duration parsing as a tier 0 concern without placing it, and three consumers are visible: capture.duration in a profile, --duration and --wait on the command line, and the ring window. Core is the crate all three reach, and the grammar adds no dependency there, so the allowlist cargo xtask deps enforces is untouched.

Keeping it in fragcap-profile was the alternative and fails on ring mode: that slice would either depend on a sibling, which section 8.3 forbids, or carry a second grammar. Two implementations of 30m that disagree produce a capture of the wrong length, which is a defect an operator cannot see in the output.

Recorded for promotion to specification section 29, since section 25.2 names the concern without assigning it a crate.

2026-08-09: Three validation checks beyond the section 15.4 list

Section 15.4 enumerates its checks, and three more are implemented. Recorded as additions rather than as readings of the specification, so that a future reader comparing the code against the document finds the difference explained rather than having to decide whether it is a defect.

  • A terminal stage must have lifecycle session. Section 10.4 defines a transient exit as normal and expected, so a terminal transient ends the capture at the moment a launcher hands off, which is the point the launcher chain exists to survive.
  • The descends_from relation must be acyclic. A cycle is unsatisfiable, so every stage in it binds nothing.
  • Every role named in capture.roles must be declared by a stage, and the list must not be empty when present. A role nothing declares captures nothing under it.

Each is in the failure class the two unusual checks section 15.4 already names were added for: a run that succeeds, exits zero, and captures nothing. All three are candidates for promotion into section 15.4 under the deviation process.

2026-08-09: Unknown keys are refused, and the schema version is what makes that safe

The [capture] table accepts exactly the five keys section 15.2 declares, and any key outside a table's accepted set is a diagnostic naming the key and the set. Section 17.2 lists more capture options on the command line, and none is accepted here: a profile key with no consumer is a key whose behavior is untested and whose meaning is set by whoever first reads it. S14 owns the command line and adds the keys it can honor.

Ignoring an unknown key is the silent failure. An author who writes payloads = false intending payload = false gets a capture containing full packet contents they meant to exclude, and nothing in the run says so. That is a P-9 problem rather than a typo: the instrument was told to narrow what it recorded and did not.

Strictness is only safe because schema exists. A profile written for a later format declares it and is refused with one version diagnostic rather than a wall of unknown-key faults.

2026-08-09: Resolution takes its search path from the caller

The resolver implements section 15.3's four steps over directories and a bundled set that it is given, and never consults an environment variable or a platform configuration location. That keeps a platform-directories dependency out of the workspace, keeps the ordering testable against directories a test builds, and leaves the platform question to S14, which is the layer that already has to know it.

The slug rule applies to steps two through four only, and applies before any path is joined. Step one is exempt because an operator who types a path has named a file, and refusing an absolute path there would break the case section 15.3 puts first. The distinction is between naming a file and interpolating a name into a search path, and only the second is a traversal surface. The check runs before the join rather than relying on the open failing, because a check that depends on what happens to be at the target is not a check.

2026-08-09: A known divergence from TOML 1.0, found by the analyze gate

toml-span does not implement TOML datetimes, which its own documentation states. The first draft of this slice's FR-002 required a parser that "implements the language rather than a subset of it", and the analyze gate measured that claim to be false. The requirement was corrected to name the constructs a schema version 1 profile can contain, which is both true and sufficient, rather than the finding being explained away.

The divergence is confined to profiles that are invalid regardless. No key in schema version 1 has a datetime type, so a datetime can appear only as a wrong-typed value or under an unknown key. What changes is the message: a syntax diagnostic rather than a type diagnostic located at the key. That is worse, and it is worth less than the minimum toolchain the alternative would have cost.

The behavior is pinned by a test rather than left in prose, so a future reader finds a recorded decision instead of a surprise, and so that the day the parser gains datetime support the test says so.

2026-08-09: The ambiguity pass is bounded rather than merely measured, reversing an answer in this slice

Recorded as a reversal because the first answer was written into the plan, the research, a success criterion, and a checklist item, and was then shown to be wrong in review rather than merely incomplete.

The claim was that the ambiguity check's cost needed no cap because the one mebibyte profile size limit already bounded it. It does not. The limit bounds each factor and not their product: two exe patterns of half a megabyte each fit inside a one mebibyte profile and ask the intersection decision for a table of roughly 10^12 cells, which aborts the process instead of returning a diagnostic. A profile that has already been refused should not be able to end the run that is refusing it.

The arithmetic is worse than it first looks. With k stages of pattern length L, the pairwise pass costs about (kL)^2 / 2, which depends only on the total bytes spent on patterns. Capping one factor moves the cost between the two rather than removing it, so one limit would not have been enough.

Two limits now exist, and each answers to the domain rather than being a round number:

  • An exe pattern is capped at 255 characters. exe matches one Windows file name component, and Windows caps that component at 255 characters, so a longer pattern is longer than anything it can be compared against.
  • A profile is capped at 64 stages. The focal titles of specification section 5.4 declare two stages and three, so 64 is two orders of magnitude beyond any plausible launcher chain, and it bounds the pass at 2,016 decisions.

Worst case is then about 1.3 times 10^8 cell visits and 64 kibibytes of peak table. Each limit is its own diagnostic naming itself, and each has a test that accepts the limit and refuses one past it. The decision walk carries a debug_assert stating the invariant it relies on, so a future caller that constructs a pattern by some other route fails loudly in a test build.

The general form is worth keeping, because this slice will not be the last to read a file an operator did not write: a quadratic pass over such input is not made safe by bounding the input, only by bounding the factors the quadratic is taken over.

2026-08-09: Three corrections from pull request 11 review

Smaller than the above and recorded because each was a promise the code did not keep.

The schema version gate now runs before the top level key check. A profile declaring a later schema and a key this build does not know returned two diagnostics, where FR-012 promises one. A new key is the most likely thing a later schema adds, so reporting it beside the version fault reports a consequence of that fault as though it were a second problem. The test that was meant to cover this had placed its unknown key after [game], where TOML puts it inside that table rather than at the top level, so it passed without exercising the path. Both the ordering and the test are fixed.

A wrongly typed entry in capture.roles no longer suppresses its siblings. The first implementation discarded the whole list when any element failed to parse, so ["ghost", 1] reported the type fault and silently dropped the fact that ghost is a role no stage declares. Two independent faults, one reported. Emptiness is now judged on the number of entries the author declared rather than the number that survived parsing, so a list with one bad element is not also reported as empty, which would have been a wrong diagnostic rather than an extra one.

A resolution failure now names a supplied directory that does not exist. Skipping an absent search directory is right, and leaving it out of the failure report was not: a search consisting only of a missing directory failed with an empty list and the message "no profile directories were given", which is false when one was given. The candidate path is now recorded before the directory is tested, because the answer an operator needs on this failure is where to put the file.

2026-08-08 Recorded for promotion to specification section 29: section 13.3 marks dir as always present and enumerates three values, in, out, and local, but Direction in fragcap-core has two variants and CapturedPacket::direction is optional, leaving a fourth state with no value in the table. Slice S06 adds unknown for it. Omitting the key would break the guarantee that lets a consumer parse without a presence check. Writing local would be worse: section 12.6 leaves loopback direction undetermined until it can be resolved from the attributed process's endpoint, so local and "not determined" are different facts, and asserting the first from the second is the substitution P-9 exists to block. The distinction is not hypothetical. Every packet in loopback.fcapng is attributed and carries dir=unknown, which is the honest record of what the pipeline knows today.

2026-08-08 Recorded for promotion to specification section 29: section 13.3 presents role and stage as a pair, both marked "when stage-bound", but Attribution carries them as independent options and its builder sets them separately, so a role without a stage is representable and will occur. S06 decides each independently. Treating them as a pair would either drop an observed role or fabricate a stage.

2026-08-08 Recorded for promotion to specification section 29: section 13.3 names three characters requiring percent-encoding, the semicolon, the equals sign, and the percent sign, which are the three that break the grammar. S06 also encodes every code point below 0x20 and the code point 0x7F, which break the containing format: pcapng defines a comment as UTF-8 text, and a reader meeting a NUL or a newline mid-comment behaves unpredictably. Percent-encoding is lossless and reversible, so the widening preserves the observation rather than altering it, which is why it does not conflict with P-9. The alternative for a process name containing a newline would be stripping or replacing it.

2026-08-08 Recorded for promotion to specification section 29: section 13.2 populates the Interface Statistics Block from the section 12.4 counters, but pcapng's standard fields describe losses upstream of the capturing application and section 12.4 has two counters, buffer_dropped and sink_dropped, that no standard field expresses. S06 writes them in an opt_comment on that block under the fragcap: sentinel. Writing only the three that fit would satisfy section 13.2 as written and violate P-4, which makes an uncounted, unsurfaced discard a defect; overloading isb_osdrop would report a fragcap loss as an operating system loss, which P-9 forbids and which a reader could not detect. Between a specification sentence and a constitution principle the constitution wins, and here both can be satisfied at once.

2026-08-08 Recorded as a known gap rather than resolved: section 12.7 says the session anchor is written into the capture file, and section 13.2 does not list it among the blocks. There is no session in S06 and therefore no anchor to record, and inventing a placement now would fix a format decision on behalf of the slice that has the data. S08 owns capture start and supplies it.

2026-08-08 The corpus-driven tests for the writer live in the fragcap facade rather than in fragcap-sink. Producing a written capture from a fixture needs a replay source and a scripted attributor, which are siblings of fragcap-sink, and reaching them from its tests/ directory would mean a dev-dependency on a sibling: the edge P-3 exists to prevent. This is recorded rather than quietly done because it would not have been caught. cargo xtask deps ignores [dev-dependencies] by design, and has a test asserting that it does, so the violation would have passed the mechanical gate and been visible only to a reviewer who went looking. S04 placed its end-to-end test the same way for the same reason; the blind spot is worth stating once in a durable place rather than rediscovering per slice.

2026-08-08 The Interface Statistics Block timestamp is derived from the last packet written on that interface, or zero when none was, and the writer reads no clock anywhere. The block header carries a timestamp field that has to hold something and the obvious something is the current time. That choice would have made output differ between runs, so every golden would pass once and fail afterward, and the natural response to a golden that always fails is to delete it, which removes the only check in this slice that reaches outside its own assumptions. Recorded because the defect is invisible in review and expensive in consequence.

2026-08-08 pcapng's epb_flags option carries a direction field, and S06 does not write it. Section 13.3 places direction in the annotation, and writing it in both would put the same fact in two places that can disagree. Recorded because the option is discoverable and the duplication is tempting.

2026-08-08 Verification against an unmodified analyzer is a documented manual step in the slice's quickstart, not a gate. Wireshark 4.6.3 on the development machine reads the goldens and displays the annotations, which is the actual claim of section 13.1 and P-5 tested on the population it concerns. It is not wired into continuous integration because the runners are not guaranteed to have Wireshark, and the constitution is explicit that a check which did not run must never look like one that passed. Adding Wireshark to the runner image is left as an option for S18, which owns analyzer integration and has other reasons to want it. The mandatory check is the structural validator, which is independent of the writer's encoding code and runs everywhere.

2026-08-08 Recorded for promotion to specification section 29, from review of pull request 8: attribution fidelity moves onto Attribution in fragcap-core, and Attribution::new takes it as a required argument. Section 8.4 fixed the type without it, and S06 initially derived attr=live in the pcapng writer from AttributionState::Resolved. That is an inference, which section 13.4 forbids, and it was wrong the day it was written rather than wrong-in-future: the scripted attributor resolves from a declared script, so every committed golden asserted that an endpoint was present in a socket table at a moment when no socket table existed. P-9 covers exactly this. Fidelity is now a statement by the party that knows, and the writer records it. Required rather than defaulted, because a default is the same inference with a different author. The change costs one argument at sixteen call sites now, with one real implementor; after S10, S11, and S12 it would have cost considerably more, and the retained path would have been silently mislabelled as live in the interim.

2026-08-08 Narrowing recorded from review of pull request 8: the S06 writer records one interface per capture and refuses a second declaration with a named error. The slice claimed support for any number. Two defects followed and neither is repairable at the moment the second declaration arrives. The annotation iface key was decided per packet from the interface count at write time, so a second interface declared after packets had been written left those blocks without a key that section 13.3 then required of them, in blocks pcapng cannot revise in place. And CaptureStats carries no per-interface breakdown, so the same capture-wide counters were written into every Interface Statistics Block, reporting each received packet once per interface to anyone summing them.

Both are fixable, and neither is fixable here. Consistent iface needs all interfaces known before the first packet, which a live capture cannot promise; correct per-interface statistics need per-interface source counters, which is a core type change belonging to the slice that creates the second interface. S09 owns live capture and interface enumeration and will have both. Refusing is the only option in this slice that puts no false statement in the file, and the packet path still carries an interface identifier rather than a constant zero, so lifting the restriction does not mean rewriting it.

The annotation grammar keeps the iface key, encoded and decoded and tested. An unreachable value in a grammar the later slice can populate is cheaper than a grammar that has to be widened once there is data for it.

2026-08-08 Recorded for promotion to specification section 29: section 13.5's example record shows src and dst, but FlowKey in section 8.4 carries local and remote. The normalization is deliberate and load-bearing, since it is what makes the key stable across both directions of a conversation and therefore usable as an attribution lookup, but it means wire order is not stored and is recoverable only in combination with the direction. Slice S07 emits src and dst when the direction is known and local and remote when it is not, never both. When direction is undetermined, wire order is not merely unavailable to the writer, it is unknown to the whole pipeline, and choosing an ordering would present a coin flip as an observation, which P-9 forbids. This is the same finding as the dir=unknown decision in S06, in a different field, and it is concrete rather than theoretical: every packet in loopback.pcap has a flow key and no direction.

2026-08-08 Recorded for promotion to specification section 29: the JSON record carries the interface name unconditionally, where the pcapng annotation carries it only in a multi-interface capture. Section 13.5's example shows it unconditionally and section 13.3 marks it conditional, so the two are already inconsistent in the specification; S07 follows each. The reason is structural rather than stylistic. A pcapng file holds exactly one Interface Description Block in the single-interface case, so the key would repeat what the container states, while a JSON line is self-contained by design and a consumer who split the stream would lose the interface entirely. Both writers read the same derivation and differ only in rendering, which is where a format difference belongs.

2026-08-08 Timestamps are rendered by integer arithmetic and never pass through a floating point value. The reasoning was revised twice under measurement and is worth recording accurately, because two plausible versions of it are wrong. It is not true that an f64 renders present-era microsecond timestamps incorrectly; it does not, and a test built on that claim passed against both paths. It is also nearly irrelevant that an f64 loses exactness above a 53-bit significand around the year 2255, since an i64 nanosecond timestamp overflows around 2262 regardless. The actual defect is rounding: a capture driver reports nanoseconds, the declared resolution is microseconds, and something must discard the remainder. This writer floors, as the pcapng writer does, so a timestamp orders the same way in both outputs. Dividing into an f64 and printing to six places rounds. For 1754500000.123456789 they differ by a microsecond, which would be the two output formats of one capture disagreeing about one packet, today, on ordinary input.

2026-08-08 serde_json is added as a dev-dependency of fragcap-sink and fragcap, and the writer is hand-rolled. The workspace claim becomes "one runtime dependency, one dev-dependency" rather than "one external dependency", which is stated plainly rather than elided. The writer is hand-rolled because the exact byte shape is this slice's deliverable and two of its requirements are non-default serde_json features that change the crate's behavior globally: preserve_order for the section 13.5 key order, since Value sorts keys through a BTreeMap, and arbitrary_precision for an exact decimal, since Number constructs from f64 for any non-integer. The dev-dependency is taken for the opposite reason: verification is worth more the less it shares with what it verifies, and a third-party parser reading every emitted line is a stronger check than S06 could obtain for pcapng, where the structural validator had to be written here. The BTreeMap behavior was not taken on faith; a key order test written against parsed values passed regardless of what the writer emitted, which is how it was confirmed.

2026-08-08 Carried forward from S06 rather than resolved: section 13.5 specifies the header object as declaring the fragcap version, the session anchor, and the interface set. The anchor is absent for the same reason it is absent from the pcapng output. There is no session in this slice, and giving it a placeholder would leave a consumer unable to distinguish an absent anchor from a null one that meant something. S08 owns capture start and supplies it to both formats.

2026-08-08 Narrowing recorded from review of pull request 9: the JSON Lines writer records one interface per stream and refuses a second, matching the pcapng writer. The slice's data model claimed this format escaped that restriction, on the reasoning that a JSON record names its interface explicitly where a pcapng packet block cannot. That reasoning was wrong, and wrong in an instructive way: naming the interface is not the difficulty, choosing it is. CapturedPacket carries no interface identifier and Sink::write has nowhere to pass one, so every packet routes to index 0 no matter how many were declared. A stream constructed with two interfaces would name both in its header and then label every record with the first, which is a false statement repeated on every line rather than a field left out, and arguably worse than the pcapng case because each record asserts it individually.

The two writers are now blocked on the same thing rather than on different things: an interface identifier on the packet, which S09 brings with live capture. Only one of S06's two reasons applied here; that this format genuinely escaped the per-interface statistics problem is what made the wrong claim look right.

2026-08-08 Hex encoding appends digits directly rather than formatting each byte into a temporary String. Recorded because the fix is small and the reasoning is not: this runs once per payload byte, so an ordinary 1500 byte frame made 1500 short-lived heap allocations. That is not a throughput question, which this slice sets no target for. It is a P-4 question, because the sink thread drains the bounded buffer of section 12.4, and a sink slowed by allocator pressure is what fills that buffer and makes the pipeline drop packets. A test guards the property by asserting a preallocated output buffer does not grow.

2026-08-08 AGENTS.md gains a dependency inventory table distinguishing the runtime dependency from the dev-dependency, replacing the claim that the workspace has one external dependency. That file is what later agents are directed to read, so leaving it stale would have meant the next slice reasoning from a false inventory. The entry also states that a test-only serde_json is not a runtime precedent for S05, and notes that cargo xtask deps ignores dev-dependencies by design.

2026-08-08: The calling thread acquires, and the sink thread is spawned

Specification section 8.6 draws a capture thread and a sink thread without saying which one is the caller's. PacketSource carries no Send bound, while FlowAttributor, ProcessWatcher, and Sink all do, which is the seam's own record of which components were expected to cross a thread boundary. Moving the source to a spawned thread would require adding Send to a trait fragcap-core::traits documents as intended to reach 1.0.0 unchanged.

Acquiring on the caller's thread needs no such change, and the data flow is identical.

This is a deferral with an owner. Section 12.1 requires one capture handle and one capture thread per interface, which this arrangement cannot express. S09 will need PacketSource: Send and should carry the trait change with the slice that first requires it, through the deviation process, for promotion to specification section 29. The limitation is visible today: a Pipeline cannot be moved to another thread, which the slice's own tests had to work around.

2026-08-08: A failed sink is retired rather than fatal, reversing this slice's first answer

Recorded as a reversal because the first answer was written into the spec and then found wrong during planning, and the reasoning is worth keeping.

The first answer read SinkError::is_countable, documented in S02 as "whether the pipeline should count this and carry on rather than stop", as meaning a non-countable error stops the whole run. Retiring the failed sink and continuing was rejected on the grounds that the retired sink's missing writes would be recorded nowhere, CaptureStats having no counter for a retired sink.

That does not survive being followed through. Stopping the run does not remove the packets already buffered or still arriving; it leaves them with nowhere to go and no counter, which constitution P-4 calls a defect. Both options need the same counter, and section 12.4 already supplies it: sink_dropped is "dropped by a sink that could not accept", and a failed sink is a sink that cannot accept.

Retirement therefore needs no new counter, conserves exactly, and keeps a capture running when one of several outputs dies. is_countable still draws the line; what it decides is whether the sink survives the packet, not whether the run survives the sink. S15 may revisit when a streaming sink whose failure is routine exists.

2026-08-08: No runtime dependency for the bounded buffer

Section 12.4 requires bounded, drop-oldest, and a producer that never waits, together. std::sync::mpsc::channel is unbounded and cannot drop. sync_channel blocks the producer, which section 12.4 forbids by name, and its try_send fails rather than evicting, which is drop-newest and the wrong policy. A third-party bounded channel offers the same two shapes and would still leave the eviction to be written by hand.

The buffer is a VecDeque behind a Mutex and a Condvar. The workspace's one runtime dependency stays one.

The property claimed is that the producer never waits for the consumer to make progress, not that it never blocks. The second is false of any shared structure and would be a claim the code could not honor. The producer's wait is bounded by a critical section that pushes and at most pops, held by a consumer that is itself never waiting on anything outside the buffer, so sink slowness is not expressible as producer latency. A lock-free ring would remove even that wait and was rejected as materially harder to prove correct for a property the specification does not ask for.

2026-08-08: The terminal item is exempt from the capacity bound

The acquisition side ends by pushing one item carrying its final counters. Subjecting it to eviction would discard an observed packet to make room for fragcap's own bookkeeping, and P-4 would then require counting a loss caused by the tool's shutdown rather than by a slow sink. The queue holds at most capacity plus one, and the extra item is never a packet.

2026-08-08: The eviction count lives in the buffer, not in the producer

So that the consumer can read it however the producer terminated, including an unwinding panic. A producer-side counter would lose the count in precisely the case where an operator most needs to know that packets went missing.

2026-08-08: A panic is re-raised, never reported as an end reason

The buffer closes when its producer handle drops, which unwinding does, so the output side observes an ending rather than waiting for a terminal item that will never arrive. A guard owning both the producer and the output thread's join handle closes the buffer and then joins it, on every path out of run, so the sinks are drained, flushed, and finished before a panic escapes.

Holding the producer and the join handle separately deadlocks, and did: locals drop in reverse declaration order, so the guard joined a thread still waiting on a buffer the producer had not yet closed. The test suite hung until the two were folded into one guard. Recorded because the ordering is not obvious from reading either piece alone.

A panic is never converted into an EndReason. It is a defect, and filing it under an accounting category would describe a program that was not running correctly as though it were. The acquisition side's counters are lost with its stack, which is a real gap and is documented rather than hidden; the eviction count survives because it is not kept there.

The obligation is symmetric, and the first version of this only went one way. Review found that a panicking sink unwound the output thread without telling the acquisition side, which then kept reading until the source closed on its own. A replay source closes; a live source does not, so run would have acquired forever and never reached the join that re-raises the panic. The original test used a finite source and could not have caught it. The output thread now holds a guard that requests the stop however it terminates, and the test uses a source that never closes, so a regression hangs rather than passing.

2026-08-08: An ending acquisition reached on its own outranks a later retirement

Also from review. The end reason was replaced with every-sink-retired whenever every sink had retired, regardless of what had already ended acquisition. A source that failed with a DeviceLost and a last sink that failed afterwards, while the output side was still draining, therefore reported the retirement and buried the device loss, which is the diagnostic an operator most needs.

Retirement now replaces only the stop it requested. An ending acquisition reached on its own happened first and is the reason. The retirements are reported either way, in sink_failures, so nothing is lost by the narrowing.

2026-08-09: A timing-dependent test slipped in with the review fixes, and continuous integration caught it

Worth recording because the discipline that should have prevented it is written down in this slice's own research: no test depends on a sleep or on a particular thread interleaving, and the tool for ordering two threads is a gate the test controls.

The test added for the end-reason fix used a roomy buffer and assumed the acquisition side would finish before the output side popped anything. That is a race, not an ordering. It passed on the development machine and failed on the Windows runner, where the output side won, retired the sink first, and set the stop that acquisition then reported, which is the one case where reporting AllSinksRetired is correct.

The fix under test was never in question: reverting it still fails the test. What was wrong was that the test only sometimes produced the scenario it named. It now gates the sink on the source running dry, so the retirement is strictly after acquisition has chosen its ending, by construction rather than by luck.

The general lesson is that a concurrency test which passes locally has demonstrated one schedule. Prefer an ordering the test imposes, and prefer asserting an invariant that holds under every schedule, which is why the conservation identity is the assertion the rest of this slice leans on.

2026-08-08: The bounded buffer refuses a zero capacity rather than documenting against it

Pipeline::new rejects a zero capacity with a named error, and review pointed out that the crate-private constructor beneath it did not. A zero capacity there is worse than useless: every push finds the queue full, pops nothing, and still advances the eviction count, so the buffer grows without bound while reporting losses that never happened. A counter that lies is the one failure the module exists to prevent, so the precondition is asserted.

2026-08-08: The malformed JSON golden was wrong, and driving the writers from the pipeline found it

fixtures/goldens/malformed.jsonl claimed "unattributed":5 for five packets that produced no flow key. Attribution was never attempted on any of them. AttributionState has distinguished never-attempted from attempted-and-unresolved since S02, precisely because the two mean different things to an operator, and stats.rs defines packets_unattributed as "retained and marked because attribution did not resolve".

The cause was the S07 corpus helper, which counted with attribution.is_some() and folded the two states together. The writer was faithful; what it was handed was not. The helper now matches on attribution_state(), and the golden's trailer line is corrected. One field on one line of one golden changed; the other fifteen goldens reproduce byte for byte through the pipeline, which is what makes this a finding rather than a format change.

This is the class of defect the end-to-end phase exists to catch, and it is worth noting that no test caught it for a whole slice: the S07 goldens were self-consistent, and the wrong number was wrong only against a definition that lived in another crate.

2026-08-09: three deviations from the architecture of record, for promotion to specification section 29.

  • PacketSource requires Send. Section 8.5 declares it without the bound, and slice S08 relied on its absence: it acquired on the calling thread and spawned only the sink thread, so a trait meant to reach 1.0.0 unchanged did not have to change for one slice. Section 12.1's one thread per interface ends that. There is no arrangement of a single thread reading several handles that does not need either this bound or a second buffer, and section 12.4 specifies exactly one buffer.
  • CapturedPacket carries a non-optional interface identifier. Section 8.4's packet vocabulary predates any capture with more than one interface. Non-optional because every packet arrived somewhere; an Option would let a real capture ship with the question unanswered. RawPacket is unchanged: a source knows only its own interface, so the identifier is attached by the pipeline at the lift.
  • CaptureStats::source becomes per-interface, with the total computed. Each handle has its own driver buffer, so a kernel drop was always a per-interface quantity; there was simply never a second interface to reveal it. Folding them would tell an operator a driver buffer is undersized without saying which. Found during planning rather than before it.

2026-08-09: the pcap crate binds the capture driver. Measured rather than assumed: MIT or Apache-2.0 across its whole transitive graph, a declared Rust 1.64 against this workspace's 1.82 floor, and a released-2025 line still maintained. Its Stat maps one to one onto SourceStats, and its counts are cumulative from the start of the run, so relaying them unaltered is a copy with no arithmetic in which an alteration could hide.

The alternative to a dependency here is not arithmetic over a byte slice, as it was in S03 and S06, but a C ABI whose struct layouts must be transcribed by hand with nothing checking them against the header. A wrong offset in the packet header yields plausible timestamps that are wrong, which is the constitution P-9 failure that no test over synthetic data catches.

Note for anyone adding to the graph later: libloading is pinned to the 0.8 line by pcap, and libloading 0.9 declares Rust 1.88. Taking it directly at "0.9" would break cargo xtask msrv, in a check most contributors cannot run locally.

2026-08-09: the transmit capability is answered with a lint, not an argument. pcap exposes packet transmission on an active capture, and the constitution says a dependency providing a prohibited capability fails the dependency audit. Transmission is not on the section 19.3 denylist, which names interception drivers, code injection, function hooking, process handles carrying memory rights, layered service providers, and image modification; npcap's NDIS capture driver is explicitly permitted by section 19.2. That argument is correct, and it is also the kind of argument that decays, so cargo xtask lint now fails if any fragcap source names a transmit call. The check was verified by introducing a call and watching it fire.

2026-08-09: the feature is named live, not platform-tests. The platform workflow anticipated the latter name in a comment. The feature gates a capability rather than a test suite, and a capability named for its tests invites someone to enable it in order to run tests and be surprised that the library changed.

2026-08-09: .github/workflows/platform.yml gains real triggers. A pinned artifact, changed because this slice is the first to give it a subject: until now no crate linked against the capture library, so its software development kit acquisition step had never run. It now triggers on changes to the capture crates and builds the live source, because cargo check does not link and a missing wpcap.lib appears only at the link step. Its first run was watched to completion on pull request 12; what it found is the entry dated 2026-08-10 below.

2026-08-09: the default route is determined with std::net. A UDP socket bound and connected to a documentation-range address reports the source address the routing table chose; connect on UDP transmits nothing. The alternative was GetBestRoute2 through windows-sys, which would add a platform dependency and a second major version of windows-sys to the graph, since pcap pins the 0.36 line.

2026-08-09: device loss is determined by observation, not by string matching. pcap::Error has thirteen variants and none names a device that has gone away; a removed adapter arrives as the general PcapError(String). On a terminal error the live source re-enumerates and asks whether its interface is still present. Matching the message text would work until a driver update or a non-English locale changed it, and would then downgrade a lost device to an unmodelled failure silently.

2026-08-09: the virtual-interface rule is a heuristic and is presented as one. No platform reports a "this is a hypervisor adapter" bit, so fragcap matches the adapter description against a documented pattern list. The verdict only ever excludes from automatic selection, never from explicit selection, and it is recorded with the pattern that matched, so a misclassification is visible rather than surfacing as an empty capture.

2026-08-09: two facts the binding cannot supply are reported as unknown. The pcap crate exposes no libpcap version string, and WinPcap API compatibility mode is indistinguishable from an ordinary npcap installation through libpcap. Both are reported as None rather than guessed or inferred, because "not determined" and "absent" are different statements. Slice S14's doctor command can query the installed service and is where that capability belongs.

2026-08-09: the attributor is shared behind a mutex, as an interim. Several capture threads ask one attributor, and FlowAttributor is Send without being Sync. Section 8.6's control thread publishes a snapshot the capture threads read without blocking, which is the arrangement that removes the lock; it arrives with S11 and S13. Adding Sync to the trait would have been a fourth deviation to buy something the control thread makes moot.

2026-08-09: the pcapng writer's blanket refusal of a second interface is replaced by a narrower rule. S06 refused every second interface because CapturedPacket carried no identifier, so every packet would have routed to the first declaration. S09 supplies the identifier, and what remains necessary is only that all interfaces be declared before the first packet: section 13.3 settles the annotation iface key from the interface count, and a written block cannot be revised.

2026-08-10: the software development kit is enough to build and not enough to run, and this slice learned it the hard way. The platform workflow's first ever run acquired the kit and built the live source successfully, then failed running the test suite with STATUS_DLL_NOT_FOUND. A binary linked against wpcap.lib needs wpcap.dll at load time, and that DLL ships with the npcap driver installation rather than with the kit.

The consequence falsifies a claim this slice's plan made. Tier 2 tests were designed to detect a missing driver at runtime and print a reason rather than failing; on Windows the process never starts, so that design gets no chance to run. The workflow now checks for the driver before choosing which test command to issue, and says plainly when live capture was not exercised.

Installing npcap on a runner would make tier 2 tests real and is a licensing decision rather than a technical one, so it is left to the operator. Until it is taken, the platform workflow proves that the live source compiles and links, and proves nothing about whether it captures.

2026-08-09: cargo xtask neutral now builds fragcap-capture as well as fragcap-core. It only ever built core, while the specification claimed both build for a target with no capture backend. The claim was true and nothing checked it. Found by this slice's analyze gate.

2026-08-10: three defects found by automated review of pull request 12, all real.

  • The interface address set moved from PipelineConfig onto SourceBinding. Specification section 12.6 determines direction by matching against "the address set of the capturing interface", and section 8.4 places the flow key's local endpoint by the same test. One run-wide set cannot say that on a multi-homed machine, and both ways of faking it put a false statement in the output: one interface's addresses reject every other's traffic as no_local_endpoint, and their union labels a packet with a local endpoint the capturing adapter does not hold. The field was removed from the configuration rather than kept as a fallback, so the ambiguous form is unwritable. A test gives the two interfaces each other's address sets and asserts the counters change, which a shared union could not do.
  • A panicking capture thread now winds the others down. Every capture thread holds a producer, so resuming the unwind while another source was live left the buffer open, the output thread waiting on it, and the run hung instead of reporting the defect. The first attempt at this fix stopped the others at the join and did not work, because join order is arbitrary and the survivor was joined first; the regression test caught that. The stop now fires inside the panicking thread through a guard that checks std::thread::panicking, which is deliberately distinct from the existing unconditional one: a source that ends normally must leave the other interfaces running.
  • The live source documents the timeout it cannot honour. libpcap fixes the read timeout when a handle is activated, so next_packet's argument is not applicable and silently substituting the handle's value would let stop latency follow a number the pipeline did not choose. LiveOptions::for_pipeline makes the two agree by construction, LiveSource::configured_timeout exposes the one that governs, and a test pins the default to the pipeline's own so that changing either alone fails.

2026-08-09: four deviations from the architecture of record, for promotion to specification section 29.

  • FlowAttributor requires Sync. Section 8.5 declares it with neither bound. Section 11.6 requires several capture threads to read one published attribution snapshot without locking, and there is no arrangement of a Send-only trait that they share without a lock somewhere. S09 changed PacketSource by the same route and for the same kind of reason, and the size of the change is the same: a bound that every existing implementor already satisfies, rather than a method on a surface intended to reach 1.0.0. Both implementors in the workspace were already Sync and neither changed.
  • A socket creation instant on the socket table entry. Section 11 describes a snapshot as a map from endpoint to owning process identifier and says nothing about creation time. Appendix D found the platform exposes it and states that it narrows the section 11.3 race window; carrying it is what makes that true rather than merely available.
  • The UDP table also reports a socket creation instant. Appendix D D.1 records the timestamp as a property of the TCP table. Both tables carry it, when each is requested by owning module rather than by owning process identifier, which is the class distinction the reconnaissance session did not have reason to draw. This matters more for UDP than for TCP: section 8.4 keys UDP attribution on the local endpoint alone, because the table reports no remote for a datagram socket, so it is the weaker join and the one where a reused port is least distinguishable. For promotion to Appendix D as well as section 29.
  • An injected clock on the attributor. Section 11.2 states a cadence without saying where time comes from. A one second interval, a two hundred millisecond rate limit, and a thirty second retention window are otherwise untestable at tier 1, which section 25.1 requires. Scoped to fragcap-attr rather than introduced as a workspace-wide abstraction.

2026-08-09: arc-swap is taken as a runtime dependency, for lock-free publication. Section 11.6 requires that the capture thread read the current snapshot without locking while the control thread replaces it. The tempting alternative, RwLock<Arc<Index>>, is a lock: a reader can block behind a writer, and the reader here is the acquisition path that section 11.6 exists to keep unblocked. It would satisfy a test and not the requirement, which is worse than failing both, because it looks like the requirement was met. A hand-rolled AtomicPtr is correct and needs a reclamation scheme written in unsafe, in a workspace that has none outside a platform binding.

MIT or Apache-2.0, edition 2018 with no declared minimum toolchain, so it cannot move cargo xtask msrv. It adds two packages to Cargo.lock, not one: it has a build dependency on rustversion, a proc macro that contributes nothing to the built artifact and is also MIT or Apache-2.0. The planning research predicted one package, from reading an empty [dependencies] table and not looking at [build-dependencies]. Recorded because a dependency audit that makes that mistake under-reports every proc macro in the graph.

Anyone proposing to remove this dependency should answer whether a reader may be blocked by a writer at all, which section 11.6 answers no. Whether a read lock is fast enough is a different question and not the one being asked.

2026-08-09: windows-sys is pinned to the 0.36 line, which pcap already resolves. Taking the current line would put a second complete windows-sys tree in the graph for declarations that have not changed. Matching the resolved version adds no package to Cargo.lock at all, so cargo deny has no new subject and the licence position is unchanged. If pcap later requires a newer line the graph gains a second copy, which is Cargo working correctly; the alternative is guaranteeing the duplicate today.

The alternative to a binding crate here is the same one S09 rejected: a C ABI whose struct layouts must be transcribed by hand with nothing checking them against the header. A wrong offset in MIB_TCPROW_OWNER_MODULE yields a plausible process identifier that is wrong, which is the P-9 failure no test over synthetic data catches.

2026-08-09: the feature is socket-table and not live. The analyze gate caught the collision. fragcap-capture's live feature means "links against the npcap import library"; this backend links against nothing of the sort, because the IP Helper API and the toolhelp snapshot ship with the operating system. Folding them into one feature would have made attribution unavailable to anyone without a capture driver software development kit it never calls, and would have made the workflow step that builds it fail for a reason that has nothing to do with it. S09's own rule gives the answer: a feature is named for the capability it gates, and these are two capabilities.

2026-08-09: image names come from toolhelp enumeration, which opens no process handle. Constitution P-1 requires any process handle to state its access rights explicitly at the call site. OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION followed by QueryFullProcessImageNameW would comply: those rights carry no memory access. But P-1's requirement exists because a handle request is a thing a reviewer has to check, and CreateToolhelp32Snapshot removes the thing to check rather than documenting it. The image name is already in the enumeration result. cargo xtask lint now asserts that no fragcap source names a process-opening call at all, which is a stronger and cheaper guarantee than asserting that every one it does name requests the right rights.

2026-08-09: .github/workflows/platform.yml gains a step and a path filter. A pinned artifact, changed because nothing would otherwise ever compile the new backend: that workflow's filters named only fragcap-capture/** and fragcap-core/**, and its only build step was the capture crate. The new step is placed before the npcap software development kit is acquired and is not gated on the capture driver being present, because this backend needs neither. It is therefore the first step in that workflow which can go green on a bare Windows runner, and the first that does not depend on an external download succeeding.

2026-08-09: the cadence configuration is not a profile key. fragcap-profile accepts a closed set of five capture keys and refuses unknown ones, and S05 refused them deliberately: a key with no consumer is a key whose behavior is untested and whose meaning is set by whoever first reads it. S14 owns adding keys when it owns a command line that can set them. The interval, the retention period, and the rate limit are plain values on AttributorConfig until then.

2026-08-09: resolve reads the injected clock, and only on the path that records a refresh request. Found unspecified by the analyze gate. The rate limit bounds how often fragcap reads the platform's table, which is a wall-clock cost, so it cannot be measured in capture time: replaying an hour of traffic in one second would otherwise request thousands of refreshes and a quiet interface would request none. The clock is injected, so this costs no determinism in a test. It does mean resolve is not a pure function of the index and the packet, which is the honest reading of section 11.2 rather than a compromise of it.

2026-08-09, in review of pull request 14. Five findings, all fixed. An automated review raised two correctness bugs and three lower ones, and each was a real defect rather than a false positive.

The tree closed an exit against the newest live node sharing an identifier rather than the node whose lifetime contained the exit's time. When an identifier is reused and the old process's exit arrives after the new process's start, that gave the new process an exit before its own start and left the old one open forever. Exit matching now selects by lifetime; live_node_covering replaces live_node_for on that path, and join_pending_exit is keyed to the specific node a fold just created rather than to whichever node is newest.

The ETW watcher began consuming when the trace opened, inside start, but a caller can only subscribe after start returns, so every event in that window, the whole startup burst, was published to nobody. That is the gap the subscribe-before-snapshot ordering exists to close, reopened at the delivery layer. The consumer now holds a backlog from the moment it opens and delivers it to the first subscriber, under the same lock a publish takes so nothing slips between the two.

Snapshot reconciliation could collapse two processes into one node: a start event for an identifier a snapshot process held would overwrite the snapshot node in place, even when the start was a different process that reused the identifier. The tree now takes the instant the snapshot reflects (apply_snapshot_at), and a start after that instant is a distinct node, because a process alive at the snapshot instant started at or before it. The ETW watcher stamps that instant from the system clock and exposes it. The untimestamped apply_snapshot keeps merging, which is correct for the offline tests that never reuse an identifier against a snapshot, and its limitation is documented.

Session::lost returned a sentinel on a failed query and the report treated it as zero losses, so a transient query failure could make an incomplete trace look lossless, the silent loss P-4 and P-9 both forbid. The session now caches the last figures a query did read and returns those on failure; zero appears only before any successful read, meaning none observed rather than none suffered.

The ETW properties buffer was a Vec<u8> cast to EVENT_TRACE_PROPERTIES, which is undefined behaviour because a Vec<u8> guarantees only byte alignment and the structure needs more. It is now a #[repr(C)] type carrying the structure and its trailing name bytes, so the compiler provides the alignment, across all three call sites.

2026-08-09. windows-sys supplies the platform binding, not ferrisetw. Specification Appendix A names ferrisetw and sysinfo. Neither is taken, and the measurements are in the slice's research document. windows-sys 0.61.2 resolves to two crates against ferrisetw's thirty, where the thirty include a bignum stack and a random number generator that a passive observer of process events has no use for. It is generated by Microsoft from the Windows metadata rather than wrapping that generation a version line behind, which is what S09's argument for taking pcap actually points at: the value is in who guarantees the struct layouts. And it declares Rust 1.71 against this workspace's 1.82, where windows declares exactly 1.82 and leaves no headroom. A probe naming every symbol the slice needs was compiled under rustup run 1.82 before the dependency was added.

What ferrisetw would have supplied is schema parsing over manifest-based providers, and the kernel process provider is not one: its events are fixed MOF layouts. The parse this slice needs is field offsets into one structure, and it is written out in etw/record.rs with a bounds check on every read and a test per field, including the variable-length security identifier that moves the two strings after it.

Recorded as a divergence from Appendix A and promoted to specification section 29 at the next version.

2026-08-09. No polling fallback, at any privilege level. Specification section 10.1 refuses polling categorically and Appendix D.1 records that an unprivileged process telemetry source exists on the platform. Appendix D.4 records what it cost the reconnaissance harness that used one: a chain member living under a second could have gone unobserved. Offering that as a degraded mode when elevation is missing would produce a run that exits zero, writes a well-formed capture file, and contains no gameplay, under a name that sounds like success. WatcherError::NotElevated says so in as many words, and there is no code path to fall back to. A proposal to add one should explain how the resulting capture would be distinguishable from a correct one.

2026-08-09. The process tree lives in fragcap-core, the watcher in fragcap-attr. The tree is a fold over values with no platform surface, so the whole of section 10.2 is a tier 1 test on any machine, and S12's stage matching, which is a decision over a tree, becomes testable at all. Keeping the two together would have gated every test of ancestry, retention, and identifier recycling behind an elevated Windows session. interface::select established the shape in S09 for the same reason.

2026-08-09. Subscribe before snapshotting, never the reverse. The two orders fail differently and only one failure is recoverable. Subscribing first can report one process twice, once as an event and once in the snapshot, and the tree reconciles that into a single node in either arrival order. Snapshotting first leaves a window in which a process created in between is reported by neither source, and nothing downstream can detect that it is missing. A visible duplicate beats an invisible gap, and an invisible gap in a launcher chain is the failure this slice exists to prevent.

2026-08-09. The event channel is unbounded, and P-4 is satisfied by that rather than despite it. Section 12.4's bounded drop-oldest buffer is the right shape for packets, which arrive faster than they can be written and whose individual loss costs one packet. Process events arrive in the thousands over a session and the loss of one start event costs a subtree. There is therefore no discard path here to count. A future reviewer who wants to bound this should note that the counter they would add would be counting the loss the bound introduced.

2026-08-09. A record too short to name its process yields nothing; one truncated only in its command line still names it. The asymmetry is deliberate. Refusing the second would discard a process, and with it every descendant's ancestry, to avoid losing one field the type already permits to be absent.

2026-08-09. Rundown events are ignored and counted. The kernel emits these at session start to describe processes already running. They are not published, because the startup snapshot already covers those processes and because a rundown record carries the same stale parent identifier a running process does, so treating one as a creation event would claim creation-time ancestry it does not have. Counted rather than silently dropped. Using them in place of the Toolhelp snapshot is a real option that would remove the only process handle this slice opens, and it is left for a later slice because it makes snapshot() asynchronous.

2026-08-09. The platform workflow gains an elevation gate. A pinned artifact, changed for this slice. Three changes: crates/fragcap-attr/** joins the path triggers, a step builds fragcap-attr --features etw so the binding is proven to link, and the tier 2 process tests run only when a runtime check finds the runner elevated, reporting plainly which case it took. S09's lesson about STATUS_DLL_NOT_FOUND is that a workflow assuming its precondition goes red for a reason that has nothing to do with the change under test.

2026-08-09. The polling prohibition is not mechanized, and the asymmetry with the memory-rights lint is considered. A lint could forbid the names a poller would use, and those names are Duration, interval, and loop, all of which appear legitimately throughout the workspace. A check with that false-positive rate gets suppressed, and a suppressed check is worse than an honest inspection because it looks like a guarantee. The memory-rights check is mechanized because its forbidden names are four constants with exactly one meaning each.

2026-08-09, at integration. Three S11 decisions were withdrawn in S10's favour, and the slice is better for it. S10 merged while this branch was open, into the same crate, and three of its choices were stronger than the ones made here independently.

The windows-sys line moves from 0.61 to 0.36, which is what pcap already resolves and what S10 pinned to so its backend added no package to Cargo.lock. Every symbol the watcher names was checked against 0.36 first. Two differences are handled in the code: that line predates the handle newtypes, so a trace handle is a plain u64, and it has no GUID::from_u128, so both provider identifiers are written out field by field.

The startup snapshot stops calling OpenProcess. S11 used it with PROCESS_QUERY_LIMITED_INFORMATION to read a start time, which complies with P-1: the right carries no memory access and it was named at the call site. S10 had already made the stronger argument in its own enumeration module and backed it with a lint entry forbidding openprocess anywhere, on the ground that P-1's rule exists because a handle request is a thing a reviewer has to check, and opening nothing removes the thing to check. S10 invited a later slice to delete that entry and argue for it. This slice declined and gave up the start time, which FR-009 now records as unknown and FR-024 already gave a defined meaning.

cargo xtask neutral needed no change at all: S10 had already added fragcap-attr to it, for its own backend, which is exactly the outcome that check exists to produce.

One S11 decision survives alongside S10's rather than replacing it. The four memory-rights constants stay in the lint as a complement to S10's three calls: a right can be named where the call is not, and they are what stops a future slice that does delete the openprocess entry from quietly asking for memory.

S10 also left a note in platform/toolhelp.rs addressed to S11, warning that PROCESSENTRY32W's parent identifier says who a process's parent is now rather than who created it. The two designs agree: that is why a node built from the snapshot carries Ancestry::Snapshot and one built from a start event carries Ancestry::Observed, and why they are not interchangeable.

Deviations recorded by this slice

Each is promoted to specification section 29 at the next version.

  • A command line on ProcessEvent::Started. Sections 10.1 and 10.2 require it. The enum is #[non_exhaustive], which permits new variants but not new fields on an existing one, so this is a breaking change to the variant rather than an additive one. S02 anticipated it in the module's own documentation.
  • An availability state for a command line. Section 10.2 lists the field without qualification. A process the startup snapshot finds cannot yield one without PROCESS_VM_READ, which P-1 forbids, so the field admits an unavailable state. Recording that a value is unavailable is not withholding it; substituting an empty string would be.
  • Ancestry provenance on the node. Section 10.2's field list does not include it, because it does not address the startup snapshot's weaker ancestry. Section 5.3 establishes that the two kinds differ in reliability.
  • The observed parent identifier is kept even when it resolves to nothing. Not in section 10.2's field list either. It is an observation, and P-9 does not permit discarding one merely because nothing downstream could use it.
  • image is settled as the full path. S02 left it ambiguous and its tests used bare file names. Section 10.3 matches the file name with one predicate and the path with two others, so the path is recorded and the name derived.
  • A watcher-owned report beside CaptureStats. Section 26.2 lists what runtime statistics carry and names only packet quantities, because it was written before there was anything else to count. Section 12.4's conservation identity is asserted over CaptureStats in every pipeline test, and a quantity that is not a packet must not enter it.
  • Specification section 5.4 says the Division 2 chain is six levels; it is seven. Section 5.4's own diagram lists seven processes, and Appendix D.3's topology lists the same seven. Only the prose sentence between them says six. The tests follow the two that agree. Found by writing the chain out as a test, which is the argument for writing it out as a test.

Slice narrative, for AGENTS.md

Not written into AGENTS.md by this branch. S10 is in development in parallel and that file's "Current state" section is the one both slices would rewrite, so this is folded in by whichever pull request merges second.

fragcap can see processes, and the tree that holds them is a value. S11 adds a ProcessWatcher over an ETW session fragcap starts for itself, and a ProcessTree in fragcap-core that folds its events into the ancestry relation of section 10.2. The split is load-bearing: the tree opens nothing, so all of section 10.2 is tier 1, and S12's stage matching is testable before it is written.

The Division 2 case is now a test rather than a paragraph. Three processes share one image name and only the last holds sockets. chains.rs asserts that matching on name alone finds the shim, that ancestry finds the client, and that the anti-cheat launcher is the distinguishing ancestor. A regression here would mean descends_from cannot do the job section 15.4 requires it for.

Nothing polls, and there is no fallback that does. Read the decisions above before proposing one.

The ETW watcher has never observed a live process. The unelevated refusal path is checked, the Toolhelp snapshot and the FILETIME conversion are checked against this machine, and the record parser has a test per field. Everything requiring an elevated trace session is tier 2 and, as with live capture since S09, has not run. The platform workflow now has a step that could run it and reports plainly when it cannot.

2026-08-10. Six decisions taken while implementing S12, recorded for promotion to specification section 29.

  • The stage binding is written onto the process node rather than held in a side-map. fragcap-core gains ProcessTree::bind_stage, writing the field S11 reserved for exactly this. A side-map would split the node's state across two owners and thread the map through everywhere the tree already goes.
  • descends_from is evaluated once, on the start event, over current bindings. S11 guarantees causal creation order, so a stage that matches an ancestor binds before its descendant is evaluated. No deferred re-evaluation queue is introduced for a reordering the event source does not produce.
  • A process matching more than one stage binds the first in declaration order. Section 15.4 already makes an ambiguous image match within a chain an error; a total order over declaration position makes the residual case deterministic rather than dependent on iteration order.
  • The watching-discard counter is the session's own, not CaptureStats. The discard happens upstream of the pipeline whose conservation identity CaptureStats carries, so a field there would break that identity or sit unused until S13 and S14 wire the session in. WatcherReport and SourceStats set the precedent that a component's own accounting is a separate value the run assembles.
  • The acquisition timeout and the duration bound are measured from arm. A single clock origin for both, and a session that never acquires still ends: by the acquisition timeout when set, or by the duration bound or an operator interrupt otherwise.
  • A live service does not keep the all-exited stop condition from firing. Section 10.4 says a service is never awaited, because waiting on something already running deadlocks; a platform service that outlives the session must not keep it from recognizing that its gameplay processes have all exited.

2026-08-10, in review of pull request 16. Three findings, all fixed. An automated review raised three real correctness defects in the session, each a consequence of the same simplification.

  • Only a non-service match acquires the target. The Watching to Capturing transition fired on any first match, so a persistent service appearing while Watching began capturing and disabled the acquisition timeout, retaining service noise before any target existed. Section 10.4 says a service is never awaited; the transition is now gated on a non-service binding. A service still binds for attribution.
  • A process bound already exited is honored as exited. When ETW delivers an exit before its start, the tree joins the held exit on the start event, so the node is not live. The binding was nonetheless recorded live, which let a terminal that had already gone enter Capturing without ever producing TerminalStageExited and left a stale live count blocking AllProcessesExited indefinitely. Binding now reads the node's liveness and routes an already-exited bind through the same exit handling.
  • Packets discarded outside the capture window are counted. A packet reaching the session while Draining, after a stop condition, was discarded without a counter, which P-4 forbids. SessionStats::discarded_out_of_window now counts every such packet, and the conservation identity holds for every call to on_packet regardless of state.

2026-08-10. Six decisions taken while implementing S13, recorded for promotion to specification section 29. Two carry deviation candidates, noted below.

  • Narrowing reads the attribution map, not a process-tree flow set. The endpoint set comes from FlowAttributor::active_endpoints, the seam slice S10 built for this. Specification section 12.2 names the attribution map as the only reliable source; the section 8.6 diagram draws a "flow set" from the process tree, and the two denote the same set. Deviation candidate: the diagram and the prose should be reconciled.
  • filter_gaps counts occurrences, not packets. A packet the kernel filter excludes is never delivered to fragcap, so a literal packet count would be fabricated, which constitution P-9 forbids. The counter counts endpoints briefly excluded by a stale narrowed filter, a set difference computed at each reinstall. Deviation candidate: section 12.3's prose says "packets," and the unit is occurrences.
  • Per-source delivery is a std::sync::mpsc channel, not arc-swap. Adding arc-swap to fragcap-core would widen its dependency allowlist from the single entry bytes, which the dependency check treats as a P-2 guard. The filter slot is read between reads, off the per-packet path, so section 11.6's lock-free mandate does not extend to it, and a std channel needs no dependency and no lock.
  • The maintenance timings are injectable through a setter. FilterConfig carries the section 12.2 constants; Pipeline::set_filter_config overrides them for tests without changing Pipeline::new or PipelineConfig, so no existing caller or struct-literal construction breaks. The policy takes the current instant as a parameter, so it needs no clock abstraction in core.
  • Gap counting is accumulated on the control thread and absorbed by the run. The control thread holds the per-handle installed history the count is computed from, so it counts there and returns a CaptureStats the run folds in with the existing absorb, which already sums filter_gaps.
  • A maintenance reinstall failure is non-fatal. A set_filter rejection during phase three keeps the prior program and continues capturing, because correctness never depends on filter freshness and retiring the interface would lose all its later traffic to spare a failed optimization. It advances no drop counter. The program is generated from a fixed grammar, so this path is defensive; a bootstrap rejection at open still retires, which is existing S09 behavior.

2026-08-10, in review of pull request 17. Four code findings fixed, two recorded as required follow-up. An automated review raised six findings against the first commit.

  • A wildcard bind drops the host constraint. A UDP socket reported bound to 0.0.0.0 or :: was compiled as host 0.0.0.0, which matches no real packet, so the first narrowing would silently exclude that socket's whole traffic while recording no gap. Such a bind now admits by protocol and port alone.
  • A filter gap is counted when it begins. Gap accounting ran only at a reinstall, so an endpoint excluded during the debounce or rate-limit window that then closed, or that was still excluded when capture ended, went uncounted. Gaps are now counted the first poll an endpoint is excluded by the installed program, once per episode, independent of any later reinstall.
  • A retired handle stops accruing gaps and installs. With begin-time gap counting, a handle whose capture thread ended would otherwise fabricate a gap for every new endpoint against its frozen program. FilterManager::retire, called when the control thread can no longer reach a capture thread, stops both.
  • A control-thread panic propagates. The control thread's join swallowed a panic, which could present a defect as a completed capture. It is now carried to the caller after orderly shutdown, the same contract the acquisition threads have.
  • Follow-up, not fixed here: narrowing is not yet restricted to profiled processes. FlowAttributor::active_endpoints returns every socket-table endpoint, not only those owned by profiled processes, because the pipeline has no access to the S11/S12 process-tree stage bindings and active_endpoints has dropped the owning process identifier. Restricting the narrowing input to profiled endpoints is the session-to-pipeline integration that S12 deferred to S13 and S14; it is required before the live backend narrows correctly and is recorded as a section 29 open item. The filter-management machinery and its tier-1 verification do not depend on it, because the scripted attributor supplies a controlled endpoint set.
  • Follow-up, not fixed here: the attribution snapshot is not refreshed in the pipeline. FlowAttributor::refresh takes &mut self and cannot be called through the Arc<dyn FlowAttributor> that section 11.6 requires for lock-free resolve, so no thread refreshes the socket table during a run. Driving the periodic refresh from the control thread (section 8.6) needs a refresh(&self) trait signature, which is a section 29 deviation to be taken with its own change rather than rushed here; every FlowAttributor implementor changes with it. Pre-existing (the pipeline never refreshed), surfaced by S13's control thread, which is the natural owner of the driven refresh once the signature allows it.

2026-08-10. Decisions taken while implementing S14, recorded for promotion to specification section 29.

  • clap and ctrlc land on fragcap-cli alone. The argument grammar of section 17.2 is a fixed set of flags, defaults, subcommands, and help text, and clap derive produces exactly that from typed structs rather than a hand-rolled parser that would drift from the help the specification prints. ctrlc supplies the portable console-interrupt hook the standard library lacks, so an operator interrupt becomes StopReason::Interrupt and an exit-0 success. Both sit at the top of the dependency graph where nothing reaches them, which cargo xtask deps enforces, so a large graph on the binary crate never touches core.
  • clap is pinned exactly to 4.5.32 for the 1.82 minimum. clap 4.6 declares edition 2024 and rust-version 1.85, and later 4.5 patches (4.5.61) pull clap_lex 1.0, which declares the same, both above the workspace's 1.82 minimum. Either resolves under a caret or tilde range and breaks cargo xtask msrv, a check most contributors cannot run locally, exactly as libloading 0.9 did in S09. Because the incompatibility is in a transitive patch a version range cannot exclude, the pin is exact; 4.5.32 is edition 2021, rust-version 1.74, on clap_lex 0.7. Raise it only alongside a workspace MSRV bump. ctrlc and the dev-only tempfile build at 1.82 unpinned.
  • The size grammar lives in fragcap-core::size, base 1024. It mirrors the duration grammar (integer plus a required unit, zero rejected) so the two literal grammars are consistent, and living in core lets the ring slice (S16) reuse it beside duration rather than reimplement a size parser in the CLI. Binary units match how buffer and file sizes are reasoned about.
  • The role and stage bridge is a FlowAttributor decorator in the facade session module. Attribution already carries role and stage with builder methods, so RoleStampingAttributor populates existing fields rather than changing a type, and a decorator is still just a FlowAttributor with no packet acquisition, so P-3 holds. The facade session module is its home because that is the one place already above both fragcap-capture and fragcap-attr; arc-swap is not pulled into the facade for it, because the binding snapshot is published on a rare write (a process start or exit) and a short-held lock around an Arc swap suffices off the per-packet path.
  • The session and the pipeline run side by side, joined by a tee. The pipeline owns the packet threads and never surfaces individual packets; a session driver owns the CaptureSession and connects through a StopHandle and the published binding snapshot. A TeeCountingSink prepended to the sink list forwards each retained packet's length and instant to the driver, so the session stays the single authority for the volume bound and its retained counters while it never sees the packet path, and the tee's receipts stay inside the pipeline's conservation identity.
  • Events are hand-rolled NDJSON over the sink escaper; every diagnostic stream is standard error. serde_json stays test-only, so the small fixed event set is serialized by hand over the one escaper the sinks already use, keeping serde out of the runtime graph. Command results (doctor, profile) go to standard output and a capture's progress, summary, and events to standard error, so a sink writing capture data to standard output is never contaminated. Timestamps are RFC3339 Z formatted by hand with a civil-date conversion, no date crate.
  • doctor is a pure Inputs to Report classifier over a thin probe. Every classification and the exit decision are testable with hand-built inputs and goldens on any target, which is the only way to cover the section 26.3 matrix without the environment. The thin cfg(windows) probe reads the machine read-only and installs nothing. The two npcap options are separate checks, each naming its own remediation when absent. A missing process-event session is a blocking fail only when the session is elevated and cannot open, and a non-blocking skip when the tracing capability is not built in.
  • run and tap are driven offline through hidden flags. A recorded capture replayed as the source, a scripted attributor, and a scripted process timeline are selected by hidden flags on run and tap, so the whole capture path is exercised from run() in a tier-1 test with no capture driver, no elevation, and no game. The flags are hidden rather than removed because the same assembly seam is where the feature-gated live path attaches. In the offline shape acquisition is resolved before the pipeline starts, so the published bindings are visible when every packet is attributed, which is what makes the stamped output a stable golden rather than a race between the publish and the resolve.
  • Live, socket-table, and ETW assembly is now wired behind their features. When no offline replay source is given, assemble::components assembles the real backends: interface enumeration and the section 12.1 selection precedence behind live, one LiveSource binding per selected interface, the SocketTableAttributor (IP Helper table plus toolhelp namer) behind socket-table, and the EtwWatcher process event stream behind etw. A live build without socket-table falls back to an empty scripted attributor so packets are retained unattributed rather than having an owner fabricated (P-4 permits the first, P-9 forbids the second); a live build without etw fails naming the missing feature, because with no live process event source no target could ever be acquired. The offline path is unchanged: the same replay source, scripted attributor, and scripted watcher, and the same usable-backend-absent failure when neither offline nor live is present.
  • The live driver is a streaming merged channel, distinct from the offline two-phase path. The offline path folds a pre-collected timeline in two phases and stays byte-identical to its committed goldens. A live capture has no pre-collected timeline and its packets and process events arrive on separate channels, so the live driver merges the counting tee's packets and the ETW watcher's events into one totally ordered channel and folds them in arrival order. That merge is what lets the run stop on a terminal-stage exit even while no further packets arrive: the exit reaches the driver as a merged-channel message independent of the packet path, the session leaves its active state, and the pipeline is stopped. The pipeline build is factored into one helper both drivers call, so the two construct the output path identically.
  • The live path is compiled but has never executed, consistent with the project's standing position. It is compiled under the --all-features clippy gate and covered only by #[ignore]d tier-2 tests, because it needs npcap and an elevated ETW session, which continuous integration has neither. Live capture has still never run in CI. Recorded for promotion to specification section 29.
  • The socket-table refresh loop is now built through the read/write split the S10 design anticipated. FlowAttributor::refresh takes &mut self, so an attributor shared across the capture threads cannot be refreshed through the pointer they hold. The split resolves that: a new platform-neutral PublishedResolver in fragcap-attr is the read side of section 11.6, holding the shared published index, the shared refresh schedule, and the clock, and answering resolve and active_endpoints with the exact atomic-load and rate-limited-request semantics of SocketTableAttributor's own read path. SocketTableAttributor::resolver() clones one from an attributor. The CLI's live socket-table branch builds the mutable attributor, hands the pipeline Arc::new(attributor.resolver()) as the inner attributor, and moves the mutable attributor onto a RefreshDriver control thread that does one initial refresh and then refreshes on the section 11.2 cadence (wants_refresh-driven, honoring the resolver's triggered requests), so a refresh on the control thread is visible to every resolving thread and an unseen-endpoint lookup records a request the control thread acts on. The driver is stopped and joined at teardown, after the pipeline ends and before the watcher is dropped; it reads only the socket table and touches neither the pipeline nor the forwarders, so it deadlocks against nothing. This is still compiled-only: it needs npcap and the IP Helper socket table on a real machine, so it is exercised solely by #[ignore]d tier-2 tests and has never run in CI. The one tier-1 addition that does run is the fragcap-attr unit test proving a resolver answers from the index its attributor publishes and that the resolver's own refresh is a harmless no-op.
  • Review pass (2026-08-10): scope enforcement, mode honoring, JSON completeness, live declarations, and a real elevation probe. --roles is now enforced rather than merely printed: CaptureSession::new_scoped treats a stage outside the scoped set as absent from the profile, so it never becomes pending, never binds or stamps, and never influences the stop conditions; new delegates to it with no restriction, so existing callers are unchanged, and run scopes to the resolved roles while tap imposes none. A profile-declared [capture] mode is honored through the same command-line-over-profile overlay as the other defaults, so a profile asking for stream or ring with no --mode override is refused naming its slice rather than silently captured as a file. The session.complete JSON event now carries watching_discarded and discarded_out_of_window, so a --json consumer, which never sees the human summary, still reads every discard counter FR-021 requires; and --json warnings and errors are emitted as warning/error NDJSON records instead of plain lines, so a diagnostic stream a consumer reads line by line stays valid NDJSON. The live path declares every selected interface with its own name and link type in selection order, matching the pipeline's per-position InterfaceId assignment, so a packet from an interface past the first is no longer refused as undeclared; the offline path still declares one interface named "capture" and is byte-identical to its goldens. The live acquisition loop now also ends on an operator interrupt (a clean exit-zero stop) and when a --duration bound moves the session out of an active state while still watching, rather than only on acquisition timeout or watcher disconnect. The doctor probe detects real elevation by reading the current process token's elevation flag through the documented current-process token pseudo handle, so no handle is opened against any process (P-1) and the blocking elevated-and-tracing-unavailable branch can actually be reached; this adds a windows-only windows-sys dependency pinned to the same 0.36 line fragcap-attr already resolves, so no second copy enters Cargo.lock.

2026-08-10: transports and streaming sinks (slice S15), decisions worth recording for promotion to specification section 29.

  • A consumer's encoder is an ordinary Sink over its connection. The streaming sink constructs one per connection through a SinkFactory that replays the header, so a mid-capture joiner and a fresh rotation segment each begin with their own valid header. Format stays orthogonal to transport with no new abstraction and the S06/S07 writers unchanged. The alternative, a single shared encoder fanning bytes to a broadcast writer, cannot give a mid-capture joiner a valid pcapng header and was rejected (P-5).
  • The streaming sink's write always returns success and never advances the capture-wide sink_dropped. Per-consumer drops are the sink's own, separately reported accounting. This preserves the pipeline conservation identity (the sink received every packet) and keeps a slow downstream reader from retiring the sink, which folding per-consumer loss into sink_dropped would not (P-4).
  • A stalled consumer is unblocked by a stop flag polled through a short socket write timeout, not by shutdown(). TcpStream::shutdown does not portably unblock a blocked send (notably on Windows), so a PollingWriter gives the socket a fixed short write timeout and rechecks a stop flag between attempts. The disconnect decision itself lives in the streaming sink (queue full past the timeout), so the disconnect reason is deterministic and finish is bounded regardless of the configured timeout. The stop-flag write aborts with ConnectionAborted, not Interrupted, because write_all retries the latter.
  • The Windows named pipe unblocks a stalled writer with CancelIoEx, not DisconnectNamedPipe. DisconnectNamedPipe discards bytes already in the pipe buffer, truncating a consumer that kept up; CancelIoEx cancels only the in-flight write, and CloseHandle on drop then lets the client drain the remaining buffered bytes before end of stream.
  • No new third-party dependency. The file, TCP, and Unix transports are the standard library; the named pipe reuses windows-sys, already pinned at 0.36 for the attribution socket table, taken under [target.'cfg(windows)'] so it adds no package to Cargo.lock. Additive windows-sys features (Win32_System_Pipes, Win32_Storage_FileSystem, Win32_System_IO, Win32_Security) were enabled; they change no resolved version.
  • The Unix domain socket transport is cfg(unix) and is not compiled or exercised by the gate on the Windows development machine or the Windows platform workflow. It is present for parity and future platforms per specification 14.2; on the primary platform it is unexercised, recorded rather than hidden. The named-pipe tier-2 tests do run on the Windows dev machine and their result is reported.

2026-08-10: ring mode and triggers (slice S16), decisions worth recording for promotion to specification section 29.

  • The ring dump is the Sink::finish seam, not a new trigger path. The pipeline already calls finish(self, stats) on every sink exactly once at drain, and drain is reached by all six session stop conditions. Ring mode therefore adds no code to the capture session, the pipeline, or the write gate; it swaps the sink built for --out. A dedicated ring-flush trigger observed by the orchestrator was rejected: it would re-implement drain and the stop conditions and risk the two disagreeing.
  • The retained window is a VecDeque<CapturedPacket> with evict-from-front, and no new dependency. CapturedPacket owns its payload by reference-counted Bytes, so retaining a packet is a pointer clone, not a byte copy. The standard library deque is exactly the bounded-tail structure needed, the same reasoning S08 used to keep the pipeline buffer off a concurrency crate.
  • A size ring window is measured by captured length, matching --max-bytes, not by encoded pcapng block size. An operator reasons about one notion of capture size across --ring and --max-bytes, and the retained set does not depend on the on-disk encoding. The dumped file is slightly larger than the window because it adds block framing and the mandatory header blocks, the same relationship a --max-bytes file has to its bound.
  • A duration window is measured back from the greatest capture instant observed, not from the last-arrived packet. Using the last-arrived packet as the reference would let a late out-of-order packet carrying an old instant shrink the window and evict a genuinely recent packet, the dangerous (under-retention) direction. The running-max reference prevents that; a rare out-of-order old packet not at the front is over-retained (safe) rather than allowed to redefine "newest." A full VecDeque::retain scan per write that would evict every out-of-order old packet exactly was rejected as O(n-squared) over a capture, and the over-retention it avoids is harmless.
  • A ring eviction returns success and never advances sink_dropped. Per the same argument S15 used for a streaming sink's per-consumer drops: the sink received every packet (conservation holds), and what it evicts from its window is the operator's declared retention scope, counted in the sink's own evicted accounting rather than the capture-wide loss counter (P-4, P-9).
  • Ring vocabulary is kept distinct from the section 12.4 bounded buffer. The FR-8 capability is named ring mode, and the internal drop-oldest backpressure buffer of 12.4 stays the bounded buffer. Both are bounded, drop-oldest rings; conflating them would confuse a user-facing output mode with an internal mechanism. The glossary carries a ring-mode entry that names the distinction (constitution P-6).
  • The end-to-end ring run is proven through the CLI integration harness (crates/fragcap-cli/tests/cli_run.rs) rather than a separate facade test: that harness already drives the whole offline pipeline through the real command entrypoint, including profile resolution and the write gate, so it subsumes what a facade-level test would assert. Both an interrupt trigger and a non-interrupt (terminal-stage-exit) trigger are exercised, and the whole-input window is shown equal in packet count to a plain file capture of the same input.

2026-08-10: PR #30 review (Codex), three findings addressed.

  • The eviction count is surfaced, not merely counted (P1). The ring sink's evicted counter is now an Arc<AtomicU64> published through RingSink::evicted_handle; build_sinks keeps the handle and the orchestrator reads it after the run to emit a ring.evicted structured event and a summary progress line. Counting without surfacing was the P-4 gap: a run that rolled its window would otherwise report zero loss. This mirrors how a streaming sink's per-consumer drops reach the summary.
  • The dump file is opened at construction, not at finish (P1). RingSink::create now opens the --out file eagerly (returning Result, like RotatingFileSink::create), so an unwritable destination fails before capture starts rather than discarding the whole captured window at drain.
  • The duration window compares in i128 (P2). window.as_nanos() as i64 wrapped negative for a window beyond about 292 years, making a huge --ring retain only the newest packet; the comparison is now done in a non-wrapping representation.

2026-08-10: Steam integration and managed launch (slice S17), decisions worth recording for promotion to specification section 29.

  • The registry read and the protocol handler go through the workspace's already-resolved windows-sys 0.36, not winreg (a deviation from the specification crate table, which names winreg). The additive features Win32_System_Registry (the Steam install-path read) and Win32_UI_Shell (ShellExecuteW for the steam:// handler) add no package to Cargo.lock and change no resolved version, whereas winreg would add a second Windows-binding package tree. This mirrors the recorded S10 decision to reuse windows-sys rather than take a second binding, and fragcap-attr already carries the unsafe FFI pattern this follows. No new runtime dependency is added.
  • fragcap-steam compiles on every target; only its Windows internals are cfg-gated. The public API (discover, scaffold, launch request) is cfg-independent, so the facade and CLI build on the neutral non-Windows target (P-2, FR-014). The registry read and ShellExecuteW are #[cfg(windows)], with the non-Windows arm returning an "only supported on Windows" error. The VDF parser, the scaffolding classifier, and the launch-URL and launch-config decisions are portable and unit-tested on the CI host whatever its OS. Gating the whole crate on cfg(windows) was rejected: it would break the neutral facade build and hide the portable logic from non-Windows CI.
  • A scaffold proves its own validity by round-trip. The renderer builds TOML text and parses it back through fragcap_profile::Profile::parse before emitting, so FR-008 (the scaffold passes section 15.4 unedited) holds by construction rather than by a separate assertion. Emitting untested text was rejected as a P-9 risk.
  • Scaffolded stage rules are exe image-name predicates and never inferred descends_from. Runtime process topology, including the observed case where three processes share the image name TheDivision2.exe (Q-4), is invisible to a static install-directory scan, so ancestry cannot be inferred at scaffold time. The heuristic header comment and the existing section 15.4 runtime warning cover that case. Where two proposed stages would share a basename, the renderer adds a path_contains predicate so the output passes the ambiguous-image-match check.
  • Managed launch uses steam://run/<app_id> (steam://rungameid/<app_id> is the noted alternative; a mutable detail). The launch is issued after the session reaches its watching state and the sinks are open, which is what removes the acquisition race. Because live capture is never executed in CI, the tests assert the launch decision (URL, ordering, refusals); the actual ShellExecuteW call is Windows-and-live-gated and tier-2/manual, and is not asserted as run in CI.
  • Section 16.5 (environment inheritance) is deferred, not implemented. Reading another process's environment block requires a process handle carrying memory-read rights, which the constitution's technique denylist and the OpenProcess lint forbid. It is a corroborating signal only, and section 10 ancestry already attributes reliably, so deferring it costs no capability.

2026-08-11: extcap analyzer integration (slice S18 sub-slice A), decisions worth recording for promotion to specification section 29.

  • One logical extcap interface named fragcap, not one per host adapter. fragcap's capture subject is the profile and role selection, not a network adapter, so it presents a single interface the configurable options parameterize. Its declared link type is Ethernet (DLT 1); heterogeneous per-packet link types (a loopback conversation) are carried by the stream's own interface blocks, which the analyzer reads, so the top-level DLT is a default rather than a constraint. One interface per adapter was rejected: it would push adapter selection into the analyzer and duplicate the section 12.1 selection precedence.
  • The extcap capture reuses the run back half through a second config builder. effective_config_for_extcap mirrors the existing effective_config_for_tap: it overlays the extcap options on the profile exactly as run does and carries the FIFO as its single sink, then the same components, build_sinks, and orchestrator::capture run unchanged. Synthesizing a RunArgs was rejected as coupling extcap to the whole run grammar shape; the _for_tap precedent is the project's pattern for a second entry point.
  • The FIFO is a new transport built through the existing sink machinery, not a streaming sink. A SinkTransport::Fifo and a fifo: scheme are opened by a small fragcap_sink::open_fifo and a pcapng encoder is built over the writer, reusing build_sinks. The S15 StreamSink (a multi-consumer server with per-consumer queues and a backpressure timeout) was rejected as the wrong shape: the analyzer hands fragcap one already-open FIFO, and the pipeline's own bounded drop-oldest buffer already absorbs a slow reader and counts the drops (P-4).
  • open_fifo is platform-correct and tier-1 testable. A Windows \\.\pipe\ path is opened as a named-pipe client (write, no create, a bounded retry on a busy pipe); any other path is opened for writing, created and truncated. That keeps production correct (connect to the analyzer's pipe on Windows, open the analyzer's FIFO on Unix) and lets a tier-1 test point --fifo at a regular temp file on any platform. The live named-pipe connect is tier 2, the same boundary live capture has had since S09.
  • doctor detection is read-only. A new paths::extcap_dir() computes the analyzer's personal extcap directory (%APPDATA%\Wireshark\extcap on Windows, an XDG or HOME location elsewhere, with a FRAGCAP_EXTCAP_DIR override for tests). The probe reports the directory and whether a fragcap binary is present; it installs, downloads, and copies nothing, which is the Licensing rule and P-1 made mechanical.
  • No new dependency. The declaration emitters are string formatting, the FIFO open is std::fs, the capture reuses the existing pipeline, and the doctor probe is std::fs, so the slice adds nothing to Cargo.lock.
  • An analyzer discovers the binary and invokes it directly, with no subcommand. Wireshark runs <binary> --extcap-interfaces and <binary> --capture --fifo <path> ..., not <binary> extcap .... The command surface is otherwise subcommand-first, so a raw extcap invocation would be rejected by the parser before the command ran and no interface would be discovered. The library entry now routes an invocation that leads with an extcap protocol flag to the extcap subcommand, and a tier-1 test exercises the no-subcommand form so the fix cannot regress. (Codex review of PR #34.)
  • The analyzer closing its FIFO is a clean stop, not a failure. A retired sink normally ends a run at exit 1 (specification FR-005a). For an extcap capture the single sink is the analyzer's FIFO, and the analyst closing it is the defined clean stop, so that end is a success while the summary still carries the loss accounting (P-4). The FIFO is opened at assembly, so a mid-capture failure is a consumer disconnect rather than a broken destination. The exit decision is a pure function flagged by the caller (false for run/tap, true for extcap) and unit-tested. (Codex review of PR #34.)