Changelog0.2.0

Added

Added in fragcap 0.2.0.

  • Repository foundation: git, licensing, conventions, agent context, contributor workflow, and the plan documents. The Cargo workspace, crates, and continuous integration are deliberately absent and are created by slice S01 through the spec-kit sequence.
  • Constitution 1.0.0, carrying principles P-1 through P-8 from specification section 27.2, plus the licensing obligations and workflow gates.
  • GitHub Spec Kit initialized with four agent surfaces (Claude Code, Codex, Cursor, opencode) over one shared, agent-neutral .specify/ engine.
  • Vendored agent skills under .agents/skills/ with skills-lock.json.

Cargo workspace with the eight crates from the architecture of record, a repository task runner carrying the project's own checks, and the six continuous integration workflows. Contributors go from clone to a built, tested workspace with cargo build --workspace, and run the full local check set with cargo xtask ci.

  • The attribution refresh is driven by the pipeline. Pipeline::run's section 8.6 control thread now drives FlowAttributor::refresh on the section 11.2 cadence, so a connection opened after capture starts is re-read into the published snapshot and becomes attributable, and enters the narrowed filter, rather than the snapshot staying frozen at construction. Resolves issue #19.

  • Phase-two narrowing is restricted to profiled processes. The kernel filter now admits only endpoints owned by a profiled process (specification section 12.2), not every socket on the machine. FlowAttributor::active_endpoints_owned carries the owning identifier the plain endpoint list drops, and the session's RoleStampingAttributor joins it against the stage bindings it already holds to filter the narrowing input. Resolves issue #18.

  • The CLI refresh stopgap is gone. FlowAttributor::refresh now takes &self, so the pipeline shares and refreshes one attributor through the Arc<dyn FlowAttributor> the capture threads resolve against; the CLI RefreshDriver control thread and the read/write PublishedResolver split it depended on are no longer needed on the live path. The resolve path stays lock-free (section 11.6).

  • OwnedEndpoint and glossary entries. fragcap-core::flow gains OwnedEndpoint (an endpoint paired with its owning process identifier), and docs/glossary.md gains OwnedEndpoint and Profiled endpoint set (constitution P-6).

  • The filter manager commits an install only when the capture thread confirms it. FilterManager::poll no longer marks a handle's program as installed optimistically; it records a pending install (one in flight per handle) and a new FilterManager::acknowledge commits the program, and clears the handle's gap set, only on a success acknowledgement. A rejected maintenance set_filter is not treated as installed: the handle keeps its prior program and the install is retried, rate limited, rather than the manager's model silently diverging from the real handle. Resolves issue #20 (the deferred half of the S13 review finding P2).

  • The acknowledgement flows over the reverse of the S13 filter channel. Each capture thread reports the result of its set_filter calls to the control thread over a shared std::sync::mpsc channel tagged with its handle index, mirroring the forward per-source filter-program channel; the control thread applies each acknowledgement to the manager before it polls. No new dependency, and PacketSource gains no bound (constitution P-3). A rejecting handle is retried, never retired: retirement stays reserved for a capture thread that has ended, because correctness never depends on the kernel filter being fresh (section 12.3).

  • The session decision now gates the sink writes, so a volume bound produces an exactly-bounded file. A generic WriteGate seam in fragcap-core is consulted by the pipeline output loop before the per-sink fan-out; a facade SessionGate admits a packet only while the capture session is capturing and the configured --max-packets or --max-bytes bound has not been reached, discarding and counting every other packet by cause. Because the admit-or-discard decision is made synchronously on the write path, the produced pcapng and JSON Lines contain exactly the bound and the completion summary matches what is on disk, rather than the S14 soft bound that could write more than the bound while counting the overflow as discarded. A new retained line on the summary reports the packets on disk. Resolves issue #22 (the deferred half of the PR #21 review, findings C2 and C3).

  • A packet the gate withholds is counted in a new gate_dropped counter, folded into the pipeline conservation identity. CaptureStats gains gate_dropped, and the identity checked in every pipeline test is now, for every sink, received + buffer_dropped + gate_dropped + refusals == packets_captured. The counter is distinct from the two loss counters because a gate drop is an intended discard (outside the capture window or beyond the bound), not loss to be remedied, so it does not reach fragcap_dropped, total_dropped, or lost_anything. A run with no gate attached leaves the term zero and the identity in its prior form.

  • The live driver runs the packet path from arm, so watch-time frames are read and counted. On a live capture the handle is open from arm; the pipeline is now spawned before acquisition and the gate discards and counts the pre-acquisition frames in watching_discarded rather than never observing them. The offline driver keeps its two-phase shape (acquire, then start the pipeline), so its behavior and the committed goldens are byte-identical. The live path is compiled and linked in CI but not executed there (tier 2).

fragcap-core carries the type and trait vocabulary from specification sections 8.4 and 8.5: flow keys and the socket table matching key derived from them, packets before and after attribution, attributions, timestamps, statistics, three error types, and the five seams the rest of the workspace is built against. Nothing captures, attributes, parses, or writes yet; this fixes the shape those slices are written to.

Three constitution principles are now enforced by the types rather than by documentation. A UDP attribution key carrying a remote endpoint is unrepresentable, so the confident-wrong-attribution failure specification section 8.4 warns about cannot be written. Every discard cause has its own named counter and every total is computed, so a counter cannot drift from its parts. An unattributed packet is distinguishable from one nobody tried to attribute.

fragcap-core parses link, network, and transport headers into a flow key and a direction, per specification sections 12.5 and 12.6. Ethernet, raw IP, and BSD loopback encapsulations; IPv4 and IPv6 including the extension header chain; TCP and UDP. Zero-copy, and asserted allocation-free under a counting allocator rather than merely intended.

This is the first behavior in the workspace. Everything before it declared shapes.

The accounting is the half worth reading. Twelve named rejection causes, each with its own counter and each separated from the others exactly where the remedy differs: a short header means raise the snapshot length, a malformed header means a broken sender or a defect here, an unsupported EtherType means unexpected traffic, an unsupported link type means an unexpected capture backend. The enumeration is closed, so adding a way to decline without adding a counter does not compile. No parse outcome is a drop, and a test asserts that every parse counter leaves both drop totals at zero.

Two cases are reported rather than resolved, on purpose. Loopback traffic has a local source and a local destination, so section 12.6's rule returns two answers; fragcap produces the flow key, leaves the direction undetermined, and counts it, because guessing would be right half the time with no indication of which half. A packet with no local endpoint at all produces no flow key, because a flow key's local field is defined as the endpoint on the capturing host and there is not one.

IP fragments are attributed without reassembly, from a 256 entry table of what each datagram's first fragment said. fragcap does not reassemble and will not: doing it during capture would destroy the on-wire fidelity that makes the capture worth taking.

Reads are bounded by the datagram's extent rather than by the captured frame. The two differ in both directions and each needs its own answer. A declared length longer than the capture is truncation, usually a snapshot length, and the capture wins. A declared length shorter than the capture means the frame carries bytes that are not the datagram, because Ethernet pads anything below sixty bytes, and the declared length wins. A declared length of zero is neither and is not an error: large send offload leaves the field for the adapter to fill in after the capture point, which is ordinary for outbound traffic captured on the sending host.

The tier 1 test substrate. fragcap-capture gains a classic pcap reader and a ReplaySource; fragcap-attr gains a ScriptedAttributor driven by a declared script; and fragcap/tests/pipeline.rs puts them together with the S03 parser over a committed corpus of eight fixtures.

That last file is the point of the slice. Specification section 25.1 has claimed since S01 that the whole pipeline runs with no capture driver, no elevated privilege, and no game. It now does, and there is a test that proves it rather than an architecture that permits it.

No new dependency. A pcap file is a twenty-four byte header and a run of sixteen-byte records, and the script format is deliberately trivial so that S05 can choose a parser for the profile schema on the profile's merits rather than inheriting one picked for a test fixture.

Reading is deterministic and says what it skipped. Byte order and timestamp resolution come from the file's magic number in all four combinations, never from the host. Four named counters cover the ways a record is not what the file described: two mean the bytes are absent and stop reading, and two mean the file contradicts itself about bytes that are present, where the record is delivered anyway with both its lengths exactly as recorded. Repairing that contradiction would hide a defect in whatever wrote the file.

The scripted attributor makes port reuse testable, which nothing in the project could express before: one local endpoint, two processes, two windows of time. It matches through the same key derivation and wildcard bind rule the real attributor will use, so it cannot express an attribution the socket table could never supply, and a test written against it is one S10 has to satisfy. The attributor seam is unchanged: the clock is a method on the double, not a new parameter on a trait meant to reach 1.0.0 untouched.

The corpus is generated rather than hand-made, and the generator is the readable record of what each fixture holds. A drift check runs in the ordinary gate and fails if a committed file stops matching it, if a capture has no script, if anything exceeds its size ceiling, or if a fixture stops exercising the condition section 25.3 states for it. Every address is documentation or loopback and every payload byte is filler, which is what turns "contains no session token" from a judgment into an assertion.

Profile schema, parsing, and validation (S05)

fragcap-profile gains specification section 15 in full: the schema version 1 TOML format, the four-step resolution order, and a validation set that reports every problem found rather than stopping at the first. fragcap-core gains the duration grammar three later slices need.

Profile::parse returns either a validated profile or every diagnostic found, and it is the only way to obtain a Profile. There is no public constructor, no public field, and no Default, so section 15.4's requirement that validation run before every capture cannot be forgotten by a later caller.

Every problem in one report. A profile with four mistakes yields four diagnostics from one call. Each carries a code from a closed enumeration, a dotted key path such as stage[1].match.descends_from, the byte offset the parser reported, and the one-based line and column derived from it. The set is sorted so an author reads it in the order they read their file, and so two runs produce identical output. Two things stop accumulation: a TOML syntax fault yields one diagnostic, because a document that did not parse has no tables to check, and an unsupported schema version yields one, because every other fault is then likely a consequence of reading a later format under this one's rules.

Validation is the section 15.4 set plus three checks in the same failure class. Structural: schema version support, required field presence, type correctness, and closed key sets for all five tables. Semantic: role name uniqueness, at most one terminal stage, descends_from resolving within the profile, regular expression compilation, glob well-formedness, duration parsing, and at least one non-service stage. Added: a terminal stage must be a session stage, the descends_from relation must be acyclic, and every role named in capture.roles must be declared.

The ambiguous image match check is exact. For every pair of stages whose exe patterns can match a common image name, the profile is refused unless both stages carry a further predicate. The intersection decision is a reachability walk over the two patterns rather than an approximation, because a false negative admits the failure the check exists to prevent: a stage bound to the wrong process among several sharing an image name produces a capture that exits zero, is well formed, and contains no gameplay. One focal title runs three processes under one image name and only the last holds sockets.

Unknown keys are refused rather than ignored. An author who writes payloads = false intending payload = false is told so, rather than receiving a capture containing contents they meant to exclude. The schema version is what makes that safe: a profile written for a later fragcap says so and is told so.

Resolution takes its search path from the caller. The resolver implements section 15.3's order over directories it is given and a bundled set it is given, and never asks the operating system where a user's configuration lives. A reference used in steps two through four must be a valid identifier and is refused before any path is joined to it, so a traversal-shaped reference cannot reach outside the search directories. A search directory that is absent is skipped; a candidate file that has won its step and cannot be read is an error rather than a fall-through, because falling through would silently substitute a profile the operator did not choose. A successful resolution reports which of the four steps supplied the profile.

Duration literals are one unsigned integer and one required unit from ms, s, m, h. A bare integer, a zero, a fraction, a sign, a compound form such as 1h30m, and an overflowing value are all refused. The grammar lives in fragcap-core because the profile schema, the command line, and ring mode all need the same one.

Nothing here observes a process. A profile describes process topology; this crate reads the description. Predicate evaluation against real process events arrives with S12, which uses the same regular expression engine that validated the patterns.

The pcapng writer. fragcap-sink gains PcapngWriter, implementing the core Sink trait over any std::io::Write, and an Annotation type carrying the attribution grammar of specification section 13.3 with an encoder and a decoder that round-trip.

This is the first slice that produces a file. Everything before it built the vocabulary, the parser, and the substrate to feed them; nothing wrote a byte that outlived the process.

The output is ordinary pcapng. An analyzer that has never heard of fragcap opens the capture, reads every packet, and displays attribution in its comment column with no plugin and no configuration. That is not asserted here: the goldens under fixtures/goldens/ were read with Wireshark 4.6.3, which reported the interface, the microsecond timestamp resolution, fragcap/0.1.0 as the capture application, and one fragcap:pid=4242;proc=game.exe;dir=out; attr=live per packet. The pcapng block structures were verified the same way before the plan committed to them, using a hand-built probe file, so the implementation started from a table known to be correct rather than one recited from a specification.

Four block types, per section 13.2. A Section Header Block declaring the application and the annotation profile version, an Interface Description Block per declared interface carrying link type, snap length, name, and microsecond resolution, an Enhanced Packet Block per packet, and an Interface Statistics Block per interface at capture end.

Every loss is in the file. isb_ifrecv, isb_ifdrop, and isb_osdrop carry what they are defined to carry, and fragcap's own buffer_dropped and sink_dropped, which pcapng has no field for, travel in a declared comment beside them. Writing only the three that fit would have satisfied section 13.2 as written and violated P-4; putting them in isb_osdrop would have reported a fragcap loss as an operating system loss, which P-9 forbids. Both counters are recoverable from the file alone.

Nothing is inferred and nothing is repaired. A packet that could not be attributed is written, marked attr=none, and carries no identity keys rather than empty ones. A capture whose declared lengths contradict each other is written exactly as recorded, because repairing it would hide a defect in whatever produced it. A timestamp that predates the Unix epoch is refused with a named error rather than clamped or wrapped, since pcapng cannot represent it and both workarounds record a time that was not observed. The single lossy conversion in the codebase, nanoseconds to the declared microsecond resolution, lives here and floors rather than truncating, so it cannot reorder two observations.

Output is byte-identical across runs and across architectures. The writer reads no clock, no environment, and no host property: byte order is little-endian unconditionally, annotation keys appear in a fixed order, percent-encoded digits are uppercase, and the Interface Statistics Block timestamp comes from the last packet written rather than from the current time. That last one is the defect that would have been hardest to see, because the obvious implementation passes on the first run and fails on every run after.

A golden per fixture, all eight, generated by a committed generator with a drift check in the ordinary gate. A structural validator walks each file the way a conforming reader would, by declared block lengths, and calls none of the writer's encoding functions, because a writer verified by its own encoder has proven that two functions agree rather than that the file is valid.

No new dependency. The workspace stays at one.

The JSON Lines writer of specification section 13.5. fragcap-sink gains JsonLinesWriter, emitting a header object, one object per packet, and a trailer object, with a payload-free mode for metadata-only streams.

This is the second output format, and the interesting part is not that it exists but that it agrees with the first. Section 13.3's pcapng annotation and section 13.5's JSON object answer the same question about the same packet, and two independent derivations of "which keys are present" would drift silently, because each would be internally consistent. S06 split deriving an annotation from rendering it so there would be one derivation; this slice is the first consumer of that split, and crates/fragcap/tests/agreement.rs checks it over every packet of every fixture. The goldens catch a format that changed; only that test catches two formats that drifted apart.

Three differences from the pcapng profile, all deliberate and all confined to rendering. The interface name appears on every record, because a JSON line is self-contained by design and a consumer that split the stream would otherwise lose it, where a pcapng file holds the interface in its container. Hex is lowercase, following the section 13.5 example, where the annotation percent-encodes in uppercase following that encoding's convention. And endpoints are named for what is known about them.

That last one is the slice's one real disagreement with the specification. Section 13.5's example shows src and dst, but FlowKey normalized endpoint position to local and remote so it would be stable across both directions of a conversation, which means wire order is recoverable only in combination with the direction. When the direction is undetermined, which is every loopback packet, wire order is not merely unavailable but unknown to the whole pipeline, and emitting src and dst anyway would present a coin flip as an observation. A record carries src and dst when direction is known and local and remote when it is not, never both, so the key names themselves say which claim is being made.

Timestamps are exact, and the reasoning was measured rather than assumed. A float path renders whole-microsecond present-era timestamps correctly, so the usual argument for avoiding one does not apply as stated. What does apply is rounding: a capture driver reports nanoseconds, the declared resolution is microseconds, and this writer floors as the pcapng writer does while dividing into an f64 and printing to six places rounds. For 1754500000.123456789 the two disagree by a microsecond, today, on ordinary input, which would have meant the two output formats describing one packet differently. The timestamp is built by integer arithmetic and never passes through a float.

Every counter is in the trailer, present even at zero, so a consumer who never sees the pcapng file can still tell whether the capture is short and where it was lost.

No runtime dependency. serde_json is added as a dev-dependency and parses every line the writer emits, which is a stronger independent check than S06 could have for pcapng, where the structural validator had to be hand-written. The writer itself is hand-rolled because the exact byte shape is the deliverable: fixed key order and an exact decimal number both require non-default serde_json features that change the crate's behavior globally.

Pipeline, buffering, and drop accounting (S08)

fragcap-core gains a pipeline module implementing specification sections 8.6 and 8.6's data flow together with section 12.4's bounded buffer. It is the first thing in the project that runs the whole capture path, and the first producer of fragcap's own loss counters.

Pipeline composes a PacketSource, a FlowAttributor, the S03 header parser, and any number of Sink values, all as trait objects. One pass over a source produces every configured output. Construction validates the configuration and starts nothing; run consumes the pipeline and blocks until the run ends.

The bounded buffer holds 65,536 packets by default, evicts the oldest to admit the newest, and never waits for a sink to make progress. Section 12.4's reason is the one that governs: blocking the acquisition side stalls the kernel buffer behind it and converts a visible fragcap drop into a less visible kernel drop.

Drop accounting is real. buffer_dropped advances once per eviction. sink_dropped advances once per write that did not happen, counted per sink rather than per packet. kernel_dropped and interface_dropped are relayed from the backend unaltered. The parser's own counters are collected into the run. The CaptureStats handed to Sink::finish is the run's own final value, and the same value the report carries.

The property asserted throughout is conservation rather than reachability: for every sink, the packets it received plus the buffer's evictions plus its refusals equal the packets the pipeline accepted. That identity holds under every thread interleaving, and it is checked in every pipeline test. A discard path added later with no counter fails there rather than passing quietly.

Ending is explicit. PipelineReport carries the statistics, an EndReason naming source exhaustion, an operator stop, a terminal source failure, or every sink having retired, and a list of sink failures. It is #[must_use] and carries the accounting on the failure path as well as the clean one, so there is no way to learn the outcome without also being handed the numbers. into_result supplies the ordinary Result shape for callers that want failure to propagate.

A failed sink is retired, not fatal. A sink returning an error that SinkError::is_countable rejects stops receiving packets; every subsequent packet advances sink_dropped for it, exactly as a refusal would. Other sinks keep working, and the run ends only when every sink has retired. Every sink is flushed and finished regardless, so its output is terminated and carries the final accounting.

StopHandle ends a run cooperatively. It is observed between packets, so stop latency is bounded by the configured read timeout rather than being unbounded or hidden.

The whole fixture corpus now runs end to end through the real pipeline with both writers attached, reproducing the committed goldens. cargo xtask ci covers it.

No runtime dependency was added. The workspace still has exactly one.

  • Live packet capture. fragcap-capture gains a PacketSource backed by the platform capture driver, behind a live feature that is off by default. Specification sections 12.1 and 12.2. The feature being off is what keeps cargo xtask ci passing on a machine with neither npcap nor its software development kit installed.

  • Interface enumeration and selection. fragcap-core::interface carries the whole section 12.1 precedence as a pure decision over an inventory value: explicitly named interfaces first, otherwise the default-route interface plus the loopback adapter when requested, otherwise every interface that is up, addressed, and not virtual. It opens nothing and touches no platform surface, so the entire precedence is tested on any machine with no capture driver.

  • Every interface is accounted for. A selection reports each interface it passed over with a named reason, and a test asserts that the chosen and the passed-over together account for the whole inventory. Choosing the wrong interface produces a run that exits zero and captures nothing, which is invisible unless the decision is reported.

  • Multi-interface capture. The pipeline takes several sources and runs each on its own thread, all feeding the single bounded buffer of section 12.4. Every packet carries the identity of the interface it arrived on, from acquisition through to both writers.

  • Both writers record more than one interface. The pcapng writer declares each with its own link type and references the correct one from every packet block; the JSON Lines writer names the interface on every record. A single-interface capture produces byte-identical output to before, checked against the committed goldens.

  • Per-interface loss accounting. CaptureStats holds one backend report per interface, and the capture-wide view is a computed sum. A kernel drop now names the driver buffer that is undersized rather than reporting that one of several is.

  • Interface retirement. A capture thread that fails retires its interface and the run continues on the others, ending when the last has retired. The report names the interface and the reason. It advances no drop counter, because nothing was observed and then discarded.

  • Capture driver detection. Presence and the loopback installation option are detected at runtime and reported with the official download location when absent. fragcap never downloads, installs, or invokes an installer.

  • A mechanical P-1 check. cargo xtask lint fails if any fragcap source names a transmit call, and if any capture driver binary or software development kit file reaches the repository.

  • fragcap attributes flows to processes. fragcap-attr gains SocketTableAttributor, the production FlowAttributor of specification section 11: a socket table snapshot joined against captured flows by 5-tuple, resolving each to the process that owns it. Every attributor before this one answered from a text file a test wrote.

  • The join is total and documented. Competing table entries are ranked by exactness, then by the latest socket creation instant at or before the packet, then by a declared tiebreak that exists only to make the order total. A test resolves the same flow against the same entries in every rotation and reversal and asserts one answer, so an implementation that iterates the platform's rows and takes the first hit fails rather than producing results that change between runs over identical traffic.

  • A socket created after a packet cannot own it. Both socket tables are read by owning module, which carries a creation instant, and an entry that postdates the packet is not a candidate. This is what tells the previous owner of a reused port from the current one.

  • Dual-stack sockets resolve. An IPv6 wildcard bind matches IPv4 traffic on the same port, for UDP, which is the protocol that takes the wildcard allowance at all. AttributionKey::local_matches_bind has named this slice as the owner of that case since S02.

  • The tail of a connection stays attributed. An endpoint that leaves the table remains resolvable for a grace period defaulting to thirty seconds, measured from the instant it was last observed present. Answers resolved that way carry Fidelity::Retained, so a consumer can see which attributions are inference and which are observation. A live entry always beats a retained one.

  • The refresh cadence, with both triggers. A one second interval, an immediate refresh on a process start matching a profile stage, and a refresh on an unattributed packet from a previously unseen endpoint, rate limited to one per two hundred milliseconds. The whole of it is driven by an injected clock, so it is exercised in microseconds and no test in the slice sleeps.

  • Attribution lookup no longer takes a lock. The attributor publishes an immutable index atomically and every capture thread reads it without blocking, which is specification section 11.6. S08 held the attributor behind a mutex taken once per packet and deferred the mechanism to this slice by name.

  • The Windows socket table backend. IpHelperTable reads the extended TCP and UDP tables over both address families, and ToolhelpNamer resolves image names by query-only enumeration. Both are behind a socket-table feature that is off by default, so cargo xtask ci still passes on any machine.

  • The backend has actually run. Unlike the live capture source added in S09, which has linked but never executed, this one was driven end to end on a Windows machine: a real socket opened, found in the machine's real socket table, attributed to the process that opened it, and then closed and observed to survive as a retained attribution. It needs no capture driver and no elevation, which is why it could be.

  • cargo xtask lint refuses process handles. Naming a process is the classic reason to open one, and this slice opens none. The linter now fails on any fragcap source naming OpenProcess, ReadProcessMemory, or WriteProcessMemory, and its matching became case-insensitive so a Pascal case platform call cannot slip past a lowercase list.

  • cargo xtask neutral covers fragcap-attr. For the same reason S09 extended it to fragcap-capture: the crate now has a platform backend, and nothing otherwise checked that it still builds where that backend does not exist.

  • Process observation. fragcap-attr gains a ProcessWatcher backed by an ETW kernel session, behind an etw feature that is off by default. Specification section 10.1. The feature being off is what keeps cargo xtask ci passing on a machine with no elevation and no Windows.

  • The process tree. fragcap-core::process::tree carries the whole of specification section 10.2 as a fold over process events: synthetic session-local identifiers that are never reused, resolution by the pair of operating system identifier and timestamp, exited nodes retained for the session, and ancestry answerable after the entire parent chain has gone. It opens nothing and names no platform type, so all of section 10.2 is tested at tier 1 on any machine.

  • The chains reconnaissance observed, as tests. Both focal titles' launcher chains from Appendix D replay through a scripted watcher. crates/fragcap-attr/tests/chains.rs asserts the ESO chain's five levels and, for The Division 2, that the three processes sharing the image name TheDivision2.exe are three distinct nodes told apart by ancestry. This is the case specification section 15.4 makes a validation error and section 10.3's descends_from exists for, and it now has a test rather than a paragraph.

  • A scripted process watcher. fragcap-attr::proc_script publishes a declared sequence of process events, mirroring the scripted attributor S04 built for the same reason. Not behind any feature, so it works everywhere. Both watchers feed one ProcessTree::apply, so a test that passes against a script states something the ETW watcher must also satisfy.

  • Ancestry provenance. Every node records whether its parent was observed at creation, read from the startup snapshot, or unresolved. Specification section 5.3 says the first is unambiguous and the second may name an unrelated process; carrying the difference is what stops a consumer treating a guess as a measurement.

  • A command line is either observed or declared unavailable. Never an empty string standing in for either. A process the startup snapshot finds cannot yield one without a memory-read right constitution P-1 forbids, so its absence is recorded as an absence.

  • Loss that a packet counter cannot express. A WatcherReport carries the events and buffers the kernel itself reported dropping, separately from CaptureStats, and a tree built while anything was lost reports itself incomplete. A lost start event removes a node and orphans everything beneath it, which a packet's loss never does.

  • The P-1 claim is mechanical. cargo xtask lint now fails if any fragcap source names PROCESS_VM_READ, PROCESS_VM_WRITE, PROCESS_VM_OPERATION, or PROCESS_ALL_ACCESS, alongside the transmit-call check S09 added. The one handle this slice opens asks for PROCESS_QUERY_LIMITED_INFORMATION and names it literally at the call site.

  • cargo xtask neutral builds fragcap-attr. It already built fragcap-core and, since S09, fragcap-capture. The claim that fragcap-attr builds for a target with no process telemetry backend was equally unchecked until now.

  • Stage matching. fragcap-profile::matching evaluates a profile's stage predicates against an observed process tree and binds each process to the first stage, in declaration order, all of whose predicates hold. Specification section 10.3. The five predicates behave as the section defines: exe a case-insensitive file-name glob, path_contains and path_regex over the full image path, cmdline_contains which never matches a command line that was not observed, and descends_from resolved over the synthetic process tree rather than the operating system parent chain. It opens nothing and names no platform type, so section 10.3 is tested at tier 1 against a scripted event stream.

  • A stage binding is recorded on the node. fragcap-core gains ProcessTree::bind_stage, which writes the stage field slice S11 reserved. A node binds to at most one stage. The decision of which stage a node binds to stays in fragcap-profile; only the recording is in core, which keeps the profile schema out of fragcap-core.

  • The capture session lifecycle. The fragcap facade gains a session module carrying the five-state machine of specification section 10.5: it arms before any target exists, discards and counts packets while watching, retains on the first stage match with nothing lost at the boundary because the handle is already open, and drains to a valid capture on any stop condition.

  • Every stop condition, and one shutdown. Specification section 10.6's six conditions (the duration bound, the volume bound, a terminal-stage exit, all matched processes having exited, an operator interrupt, and a sink error) each end capture through the same drain, and an acquisition timeout completes a session that never acquired a target.

  • Packets discarded before acquisition are counted. A SessionStats::watching_discarded counter records every packet dropped while watching, and the session's conservation identity, that observed equals retained plus watching-discards, is asserted in the tests. Constitution P-4.

  • Kernel filter narrowing. fragcap-core::filter gains FilterProgram::narrowed, which compiles a set of endpoints into a libpcap expression admitting only those endpoints, across IPv4 and IPv6. Specification section 12.2, phase two. It is a pure function over core types, so the whole strategy is tested at tier 1 with no capture driver.

  • The maintenance policy. FilterManager runs specification section 12.2's phase three: it debounces recompilation by two seconds and rate limits reinstallation to one per five seconds per handle, coalescing the endpoint churn of connection establishment. It is a pure decision over a wanted endpoint set and a supplied instant, tested against synthetic instants.

  • The control thread. Pipeline::run now spawns the section 8.6 control thread's filter manager: it reads the attribution map's active_endpoints (slice S10), narrows the filter, and hands each capture thread its current program over a private channel, which the capture thread installs on its own handle. Pipeline::set_filter_config overrides the section 12.2 timings for tests without changing any existing caller.

  • Filter gaps are counted and surfaced. CaptureStats::filter_gaps is populated per specification section 12.3: an endpoint briefly excluded by a stale narrowed filter is counted as a gap occurrence, distinct from the three drop counters and outside the pipeline conservation identity, because it counts no packet fragcap observed and discarded.

  • Filter-lifecycle glossary entries. docs/glossary.md gains Narrowing, Maintenance, Filter program, Filter manager, and Filter gap, the last resolving a dangling reference the Bootstrap filter entry already carried (constitution P-6).

  • The command surface. fragcap now exposes seven commands. run captures a game with a profile, tap captures a named running process ad hoc, doctor reports environment readiness, and profile validates, lists, and shows profiles. replay, steam, and extcap are registered as stubs that name the slice delivering them and exit 2, so the help foreshadows the whole tool. Specification section 17, the last of the capture-to-file CLI slices (S01 through S14).

  • The CLI is a library plus a thin binary. fragcap_cli::run is the testable entry, so the whole surface, the exit contract, the structured event stream, and the completion summary included, is driven from tests without spawning a process. The binary is a shim that exits with its code.

  • run captures end to end offline. It resolves a profile, overlays the command-line options onto the profile's [capture] defaults, arms before the target exists, waits for it, captures while it runs, and stops on the first of a duration, packet, or byte bound, a terminal stage exit, all targets exiting, or an operator interrupt. Each attributed packet carries the role and stage of the process that owned its flow. Interrupt handling through ctrlc makes an operator interrupt a clean exit-0 stop rather than a killed process.

  • doctor classifies readiness without ever installing. A pure Inputs to Report classifier over a thin, read-only probe reports platform, capture driver, tracing, interfaces, integration, and profiles, naming the two non-default npcap options (loopback capture support and WinPcap API compatibility mode) individually with their exact remediations, and exits 1 only when a blocking problem exists.

  • profile validate reports every diagnostic in one pass and exits 2 on an invalid profile; list reports the bundled and per-directory counts; show reports the resolved profile and its source, exiting 1 on a well-formed reference that resolves to nothing.

  • The size-literal grammar. fragcap-core::size parses an integer plus a required binary unit (b, kb, mb, gb), rejecting zero and a missing or unknown unit, mirroring the existing duration grammar so --max-bytes and the ring window (slice S16) share one grammar with a profile.

  • The role-stamping bridge. fragcap::session::RoleStampingAttributor, a FlowAttributor decorator holding a published pid to role and stage snapshot, populates the role and stage fields Attribution already carries, joining the session's profile knowledge to the packet path without either the pipeline or the attribution crate learning about profiles.

  • Facade re-exports of ScriptedWatcher, ProcessScript, EtwWatcher (behind etw), and the sink crate's JSON string escaper, and the promotion of write_json_string to pub, so the command line reaches the offline substrate and hand-rolls its event JSON over the one escaper the sinks use.

  • Two new dependencies, both on fragcap-cli only: clap (derive) for the argument grammar and ctrlc for the interrupt hook.

  • CLI glossary entries. docs/glossary.md gains Readiness check, Lifecycle event, Completion summary, and Effective configuration (constitution P-6).

  • The sink model gained its transports: file rotation, a Windows named pipe, a Unix domain socket, and TCP (specification sections 14.1 to 14.4, roadmap slice S15). Format stays orthogonal to transport: a SinkFactory builds a fresh format encoder (pcapng or JSON Lines) over any connection, so any format writes to any transport.

  • A file sink rotates into numbered segments by size or duration, closing each at a clean pcapng section boundary so every segment opens on its own in an unmodified analyzer. A capture with no rotation policy is a single segment, byte identical to before.

  • A streaming sink serves any number of live consumers over the named pipe or TCP. Each consumer receives its own complete, independently valid stream, with its own header preamble replayed on connect, so a Wireshark client that opens \\.\pipe\fragcap mid-capture sees a valid capture from the connection point onward.

  • Per-consumer backpressure isolates a slow or dead reader. Each consumer has an independent bounded queue; a full queue drops packets on that connection only, counted per consumer and surfaced, and never stalls the capture or any other sink. A consumer whose queue stays full past a timeout is disconnected and the disconnection reported. The file sink is unaffected by a stalled network consumer, which is what makes concurrent file-and-stream capture safe.

  • The command surface wires every --sink scheme to its transport (file:, pcapng:, jsonl:, pipe:, unix:, tcp://), parses per-sink options (format=, payload=, rotate-size=, rotate-duration=, queue=, timeout=), and enables --mode stream. A streaming-only run with no capture file is valid. A sink whose format cannot be resolved, whose transport is unavailable on the current platform, or whose options mismatch its transport is a configuration error naming the cause, before capture starts.

  • Ring mode: a rolling in-memory window dumped on trigger (specification section 7.2, FR-8, roadmap slice S16). fragcap run --mode ring --ring <window> --out <file> retains the most recently captured packets, bounded by a duration or a byte size, discarding the oldest as new ones arrive, and writes the retained window to the output file when the capture ends. The worked headline is a rolling ten-minute window dumped on interrupt.

  • The dump fires on every stop condition, by one path. Ring mode reuses the six session stop conditions already in place (operator interrupt, duration bound, terminal-stage exit, all-non-service-processes-exited, source exhaustion, unrecoverable sink error) through the sink's finish seam; it adds no stop condition of its own. The dumped file is a single, independently valid pcapng an unmodified analyzer opens, byte-comparable to a plain file capture when the window is larger than the whole input.

  • A size ring window is measured by captured length, the same quantity the --max-bytes bound sums, so an operator reasons about one notion of capture size across --ring 64mb and --max-bytes 64mb. A window smaller than one packet still retains that one packet, so a capture that saw traffic never dumps an empty file.

  • The command surface wires --mode ring and --ring, replacing the earlier stub refusals. Ring mode requires both --out and --ring; a volume stop bound (--max-bytes, --max-packets) is refused in ring mode because a rolling window does not stop on accumulated volume; and a --ring window given outside ring mode is refused rather than silently ignored. Each is a configuration error naming the cause, before capture starts. --duration remains valid in ring mode.

  • A ring eviction is the sink's own counted accounting, never a capture loss. The ring sink accepts every packet the pipeline delivers, so the pipeline conservation identity (received + buffer_dropped + refusals = captured) is preserved; the evicted count is reported by the sink, the way a streaming sink's per-consumer drops are (constitution P-4, P-9).

  • Steam integration: library discovery, profile scaffolding, and managed launch (specification section 16, roadmap slice S17). The fragcap-steam crate, previously a skeleton, now reads Steam's local installation metadata to enumerate installed titles, scaffolds a profile from one, and starts a title under capture. It contains no capture and no attribution logic, and fragcap-core gains no notion of Steam.

  • fragcap steam profile <app_id> scaffolds a validating profile. It locates the Steam installation through its Windows registry entry, resolves the app_id to an installed title, scans the install directory for executable images, proposes launcher-suggestive images as launcher stages and the largest remaining image as the client, and prints a profile skeleton to standard output. The skeleton is built as TOML and parsed back through the section 15.4 validator before it is emitted, so a scaffold that would fail validation is a bug caught in-process rather than shipped. A header comment states the classification is heuristic and must be verified against an observed session. This replaces the earlier steam stub.

  • fragcap run --profile <ref> --launch starts a title without the acquisition race. The title is started through Steam's protocol handler (steam://run/<app_id>) only after the session is watching and the sinks are open, so every process in the launch chain, including a launcher shorter-lived than any poll interval, produces a start event fragcap observes. Managed launch requires game.platform and game.app_id; absent either, or on a non-Windows build, --launch is refused as a named configuration error before capture starts. This replaces the earlier "not yet supported" refusal.

  • A hand-rolled VDF parser for the Valve key-value text format covers the subset libraryfolders.vdf and appmanifest_*.acf use. A malformed manifest is reported and skipped rather than aborting discovery of the well-formed ones, and a duplicate app_id across libraries keeps the first and reports the collision.

  • The integration opens no process handle. Section 16.5 (environment inheritance), which would require a handle carrying memory-read rights, is deferred; it is a corroborating signal only, and section 10 ancestry already attributes reliably. The OpenProcess/ReadProcessMemory/WriteProcessMemory lint stays green.

  • Extcap analyzer integration: fragcap is a capture source in Wireshark (specification section 14.5, roadmap slice S18). fragcap extcap implements the four-invocation extcap contract: --extcap-interfaces lists the one fragcap interface, --extcap-dlts its link type, --extcap-config the configurable options, and --capture --fifo <path> streams pcapng to the analyzer's FIFO. The analyzer renders a native configuration dialog from the declaration, so a full graphical interface exists with no graphical code in fragcap.

  • The configurable options are profile, roles, direction, and loopback, and they select the capture through the same overlay the run command uses: the option call names are the run flag names, so the analyzer's dialog and the command line select capture identically. A profile that fails to validate is a configuration error reported before any capture starts, not a started-but-empty stream.

  • The extcap stream is the same bytes a file capture produces. The FIFO sink reuses the pcapng writer through the existing sink factory, so an unmodified analyzer reads a process-attributed live capture; a single-interface extcap stream is byte-comparable to a plain --out file capture of the same input, and the pipeline conservation identity (received + buffer_dropped + refusals = captured) holds exactly as for a file capture.

  • fragcap doctor reports the analyzer extcap integration. It names the analyzer's extcap directory and reports whether a fragcap binary is installed there, in both the installed and not-installed states. The probe reads the directory read-only and installs, downloads, and copies nothing; installation is an operator action (copy the binary into the reported directory).

  • A fifo: sink scheme streams pcapng to a FIFO or named-pipe path, opened for writing (a named-pipe client on Windows, an opened FIFO elsewhere). It is the transport the extcap capture uses and is available to --sink as well.

  • Shell wrappers: Invoke-FragCap.ps1 and fragcap.sh (specification section 18, roadmap slice S18). Two thin wrappers handle the environment concerns that belong outside the binary: the PowerShell wrapper verifies elevation and relaunches elevated when needed, detects the capture driver and reports the download location when it is absent (installing nothing), filters virtual adapters from the interface list, and expands an output-path template; the Bash wrapper bridges the WSL2 subsystem boundary, invoking the native Windows binary through interop and translating paths in both directions, and reports capture unavailable and exits 1 on a Linux host with no reachable binary.

  • The wrappers are thin and honest (constitution P-7). They contain no capture logic and never parse fragcap's human-readable output; they react to the section 17.5 structured event stream, which they add (--json) to every invocation, and pass unrecognized options through to fragcap unchanged. A --dry-run (-DryRun) seam prints the assembled invocation and exits without capturing, which previews the expanded template and the pass-through.

  • Both wrappers are held to their ShruggieTech house standards in CI. A new cargo xtask wrappers gate runs the vendored PowerShell compliance checker on Invoke-FragCap.ps1, an authored Bash structural checker on fragcap.sh, a syntax check of each, and each script's help and dry-run, returning the 0/1/2 contract. It is part of cargo xtask ci and the ci.yml workflow, so a wrapper that drifts from its standard fails the build. This is the section 18.4 gate, previously unmet: no shell-lint ran before.

  • The documentation website exists (specification sections 22 and 23, roadmap slice S18). It is a Fumadocs static export served from the domain root at fragcap.com: a landing page held to section 23.1, a first-run getting started guide, guides for writing a profile and choosing a capture mode, a reference set (the command line, the profile schema, the output formats), an architecture overview, a contributing page, and the glossary rendered as browsable pages. Unmodified analyzers were always the compatibility target for the output; this is the same principle for the documentation, which reads as ordinary static HTML with no server behind it.

  • The single-source glossary renders into the site (specification section 22.4). The authored glossary lives once, under docs/glossary/; a prebuild step renders each category page into the site's content tree, turning the kramdown "why it matters here" note into a distinct callout and rewriting each relative cross-link into a site route, so a link that resolves on GitHub also resolves as a page. The rendered tree is generated at build time and never committed, so the two copies cannot drift.

  • Static search over the whole documentation set (specification section 22, FR-009). The search index is exported as a static file the browser downloads and searches with no server, indexed by heading so each glossary term is an independent result. A query splits on whitespace, underscores, and hyphens, so path_regex and 5-tuple find their terms.

  • The brand identity is applied (specification section 23.3): the vendored Space Grotesk, Geist, and Geist Mono faces are served locally with their OFL license texts, the Signal Cyan accent sits on a dark-first neutral ground, favicons and a web manifest and a social preview are wired into the page head, and the footer carries the "A ShruggieTech project" endorsement.

  • cargo xtask docs build produces the real export. The sub-slice S18c-1 command reported the site application absent and exited 2; the application now exists, so docs build builds the static export and asserts it carries the .nojekyll marker and CNAME, and docs starts the site's dev server.

  • The glossary is split into per-category pages with a generated index (specification section 22.4, roadmap slice S18). The interim docs/glossary.md is now one authored page per section-4.4 category under docs/glossary/ (eight pages, 125 terms), and docs/glossary/index.md is a generated alphabetical index of every term linking to its definition on the owning category page.

  • The documentation linter scripts/lint-docs.sh enforces P-6 mechanically (specification sections 4.6 and 22.5). It has three modes: check validates entry completeness (every entry carries a definition body), cross-link resolution (every internal glossary link resolves to an existing term anchor), and index reproducibility (the committed index matches a fresh generation); fix regenerates the index in place; link verifies external reference URLs respond (for the weekly schedule). It is built to the ShruggieTech Bash standard and passes the repository's Bash compliance checker. Before this, constitution P-6 was satisfiable but kept by hand; it is now enforced on every push.

  • cargo xtask docs is a real command (specification section 22.6), replacing the stub: docs check runs the linter, docs build produces the static export and asserts it carries the .nojekyll marker and CNAME, and docs (no argument) starts the site's dev server. Each returns the 0/1/2 exit contract. The documentation check is part of cargo xtask ci and a step in the ci.yml workflow. The docs build and docs subcommands report the site application absent and exit 2 until it lands with sub-slice S18c-2.

  • Specification section 4.4 gains an eighth glossary category, "Command Line and Diagnostics", legitimizing the eight CLI and diagnostics terms the glossary had already accumulated. The generated index and the per-category split follow from it.

  • The approved fragcap brand identity (version 1.0.0) is vendored in brand/. The brand session resolved the two open questions from specification section 29 that gated S18: Q-7 (the monospace face is Geist Mono) and Q-8 (fragcap is an independent ShruggieTech sub-brand carrying an "A ShruggieTech project" endorsement rather than a combined parent-product logo). The directory holds the full brand system (brand/README.md), the logo lockups in SVG and PNG (brand/logos/), favicons, the fonts with their OFL licenses (brand/fonts/), design tokens as CSS and JSON (brand/tokens/), a type and hex-readability specimen (brand/specimens/), and the printable guide. docs/brand/README.md is updated from a placeholder to the resolved record and keeps the repository-specific notes the kit does not carry: the security-posture framing that governs acceptance and the fragcap.com GitHub Pages deployment target for S18. With Q-7 and Q-8 closed, S18 (the documentation site) is unblocked.

Every publishable crate now carries its own LICENSE, NOTICE, and README.md, along with the homepage, keyword, and category metadata a registry listing needs. A crate fetched from crates.io arrives with its license text rather than a bare SPDX identifier, and its listing says plainly that 0.1.0 is a skeleton with no functionality.

docs/glossary.md, seeded with 22 entries covering the vocabulary already in use across the project. Constitution principle P-6 requires a glossary entry in the same change that introduces a term, and until now the glossary was scheduled to arrive with the documentation site in S18, leaving seventeen slices with nowhere to write. Slice S18 splits this into the per-category pages of specification section 22.4. The documentation linter that enforces entry completeness mechanically still arrives with S18, so P-6 is satisfiable now but not yet enforced.

cargo xtask publish publishes the workspace to crates.io in dependency order, asserts that order against the dependency graph before uploading anything, and skips a crate whose version is already on the registry so an interrupted release can be resumed by rerunning it. It prints the plan and changes nothing unless --execute is passed.

cargo xtask notes <version> prints that version's release notes from CHANGELOG.md, falling back to the Unreleased section.

A tagged release now builds a checksummed Windows archive carrying the binary, the license, and the notice, and creates a GitHub release with notes derived from the changelog. release.toml configures the version bump that precedes all of it.

  • A single release-preparation command, scripts/cut-release.sh and its PowerShell twin scripts/New-Release.ps1. It prepares a release/X.Y.Z branch in one step: it bumps the workspace version through cargo release, assembles CHANGELOG.md from the changelog.d/ fragments, corrects the two embedded-version assertions and the golden corpus that the bump moves, and runs the full check set. It performs no tag, push, or publish, so the two authorizations the constitution requires (pushing the tag, approving the crates-io environment) remain manual. A --dry-run previews the plan and the assembled changelog without writing anything. Both scripts are held to the ShruggieTech shell standards by cargo xtask wrappers.
  • cargo xtask changelog, which folds the changelog.d/ fragments into CHANGELOG.md. --check prints the assembled body and changes nothing; --release <version> <date> rewrites the changelog, moving the assembled body into a dated version section, resetting [Unreleased], and removing the consumed fragments. The section order is canonical, existing [Unreleased] content is preserved, and an unknown section name fails loudly rather than dropping the entry.

The shruggie-graph-memory skill is vendored under .agents/skills/, with provenance and an integrity hash in skills-lock.json. It captures durable knowledge from a working session into a ShruggieGraph memory and recalls it in later ones, across agents.

  • The legal disclaimer is published on the site (issue #39). A /disclaimer page carries the vetted "## Disclaimer" section of the root README.md, and a site-wide footer links to it from every page. The site copy is generated from the README at build time and never committed, the same single-source discipline the glossary uses, so the two cannot drift.
  • The site carries its brand identity in the chrome (issue #41, specification section 23.3). The navigation title and the landing masthead use the vendored fragcap wordmark and mark rather than plain text: the white wordmark on the dark ground and the cyan wordmark on light surfaces, swapped by theme with no flash, and the marks are used as vendored without recoloring.
  • The "A ShruggieTech project" endorsement links to the parent site (issue #40). In the footer the endorsement now links to shruggie.tech in a new tab, while staying subordinate: Geist Mono, uppercase, low emphasis, no combined parent-product logo.

cargo xtask license checks that every publishable crate carries the license text a published package needs, comparing each copy against the repository root original byte for byte. It runs as part of cargo xtask ci, so a copy that drifts fails the build rather than reaching a published version that can be yanked but never corrected.