Interface control document
1. Identification
| Item | Value |
|---|---|
| Document | ICD-CMFOLDER-001 rev 2 |
| Product | Folder upload module (FolderUpload in the module registry, package HEAT.FolderUploadModule) |
| Product versions covered | 5debe79 |
| Peer | A directory on a filesystem reachable by the capture workstation |
| Host | HEAT Capture, HEAT.Capture.ModuleSDK 2.0.5, net10.0 |
| Settings section | HEAT.FolderUploadModule |
| Data model | HEAT.Proto.Data.FolderUploadData |
| Date | 2026-09-02 |
| Owner | VRAI Simulation |
2. Scope
This document defines the three interfaces the module actually has: the change notification it receives from the operating system for the watched directory, the file read it performs on the paths that notification gave it, and the packet it publishes to HEAT Capture.
There is no network listener, no vendor SDK, no handshake and no acknowledgement. The module never writes to the watched folder and never modifies, moves or deletes a file it has read.
The module is declared ModuleType.Data with StartupType = ModuleStartupType.BeforeSession. The
stop type is left at the SDK default, ModuleStopType.BeforeSessionEnd, which is what makes the
module’s publications land inside the session: the host stops BeforeSessionEnd modules before it
ends the session.
3. Interface inventory
| ID | Name | Direction | Transport | Peer |
|---|---|---|---|---|
| IF-CMFOLDER-01 | Directory change notification | Filesystem to module | .NET FileSystemWatcher over the operating system’s directory change notification | Watched directory |
| IF-CMFOLDER-02 | File content read | Filesystem to module | File.ReadAllBytesAsync on the notified path | Watched directory |
| IF-CMFOLDER-03 | FolderUploadData publication | Module to host | Module SDK publication path | HEAT Capture |
4. IF-CMFOLDER-01 Directory change notification
4.1 Transport and addressing
The address is the Folder setting, default C:\FolderToUpload. It is passed straight to the
FileSystemWatcher constructor with no normalisation, no existence check at that point and no
restriction to a local volume. A UNC path or a mapped drive is accepted; whether notification works
over it is a property of the remote filesystem, not of this module.
If the directory does not exist when the module initialises, the module logs
Folder Upload folder does not exist: {Folder}. Creating it. and creates it. Creation happens
inside InitialiseModule, so it precedes any session.
4.2 Watcher configuration
| Property | Value in this revision | Consequence |
|---|---|---|
Path | The Folder setting | See 4.1 |
NotifyFilter | NotifyFilters.FileName | NotifyFilters.CreationTime | FileName is the filter that makes Created fire. CreationTime affects only Changed, which has no handler, so it has no observable effect here |
Filter | Not set, left at the .NET default | Every name matches. There is no extension or name-pattern filtering |
IncludeSubdirectories | Not set, so false | Only the top level of the watched folder is observed. A file created in a subdirectory is never notified |
InternalBufferSize | Not set, so the .NET default of 8192 bytes | See the overflow row in section 8 |
EnableRaisingEvents | true, set in the object initialiser | See 4.4 |
| Handlers attached | Created only | Changed, Deleted, Renamed and Error are not subscribed |
The property defaults the module does not set are defined by the .NET FileSystemWatcher
documentation, not by this module.
4.3 Connection lifecycle and observation window
There is no connection to establish or re-establish. The watcher is created once per capture start and destroyed once per capture stop; there is no reconnect logic and none is meaningful.
StartCapture clears the accumulated path list, then constructs the watcher. If construction throws
(the folder was removed after initialisation, a network path stopped resolving) the module logs
Error setting up file watcher and returns false, so the capture loop never starts. On success it
logs File watcher started for folder: {Folder}.
EndCapture does not stop the watcher first. It awaits DelayBeforeUpload, default 30 seconds, and
only then detaches the handler, disables events and disposes the watcher. The observation window is
therefore module start through the end of that delay, which extends past session end:
[module start]───watching───[session stop requested]───still watching for DelayBeforeUpload───[watcher torn down, files read]A file created before the module starts, or after the delay elapses, produces no notification and is
never read. CleanupAsync calls the same teardown a second time; it is a no-op because teardown
nulls the field.
4.4 Startup gap, FOLDER-02
EnableRaisingEvents = true is set inside the object initialiser, and the Created handler is
attached on the following statement. Notifications raised between those two points have no
subscriber and are discarded with no log entry. A file created in that window is silently absent
from the session. This is audit finding FOLDER-02 and it is a property of this revision, not of the
operating system’s notification mechanism. The window is short but it is not empty, and it cannot be
closed from configuration.
4.5 Authentication
None, on either direction. The module presents no credential to the filesystem beyond the HEAT Capture process token, and it applies no test to the origin of a file. Any process that can create a file in the watched directory has its bytes published into the session.
4.6 File eligibility
There are no eligibility rules. Every Created notification appends its FullPath to an in-memory
list under a lock, and every entry in that list is read at teardown. Specifically:
- No filtering by extension, name, size, age or content.
- The module never enumerates the directory. A file already present when the module starts is not read, because eligibility comes from a notification and not from a directory scan.
- The list is not de-duplicated. A name created, deleted and created again inside the window appears twice and is published twice.
- Directories are not excluded. A subdirectory created directly in the watched folder is notified as
Createdand is appended to the list like a file. Its read fails; see section 8. - A writer that stages content as one name and renames it to its final name inside the watched
folder is only notified for the staging name. The rename raises
Renamed, which has no handler. The staging path no longer exists at read time, so the read fails and the final file is never published. Writers must create the file at its final path, or stage it outside the watched folder and move it in.
4.7 What happens to a detected file
Nothing, on the filesystem. The module reads it and leaves it exactly where it is, with its name, timestamps and content untouched. There is no move to a processed folder, no deletion, no marker file and no lock. Because the path list is cleared at teardown and rebuilt from notifications on the next start, a file left behind is not re-published by a later session, but nothing on disk records that it was already read.
5. IF-CMFOLDER-02 File content read
Reads happen only during teardown, after the DelayBeforeUpload wait, sequentially, in notification
order, one File.ReadAllBytesAsync per recorded path with the host’s stop cancellation token. The
module logs Processing file: {FilePath} before each read and
File read successfully. Size: {Size} bytes after it.
The read is a single whole-file load. There is no streaming, no chunking, no size cap and no
configurable maximum. The bytes are then copied a second time into a protobuf ByteString, so peak
working set is on the order of twice the file size for the file currently being processed.
5.1 A file still being written, or locked
The module has no completeness test. It does not check for a sentinel file, a size that has stopped
changing, an exclusive-open probe or a retry. DelayBeforeUpload is the only mechanism the module
offers for letting a writer finish, and it is a fixed wait, not a readiness check.
Two outcomes follow, and a consumer cannot distinguish them from the payload:
- The writer has closed the file but the module read it while it was still incomplete only if the writer left it readable mid-write. In that case the read succeeds and a truncated payload is published as though it were the whole file. Nothing in the payload or the log flags it.
- The writer still holds an exclusive lock. The read throws an
IOException, the module logsError reading file: {FilePath}, and the file is skipped for that session with no retry and no later attempt.
6. IF-CMFOLDER-03 FolderUploadData publication and data model reference
6.1 Wire format
HEAT.Proto.Data.FolderUploadData has exactly one field:
| Field | Number | Type | Content |
|---|---|---|---|
content | 1 | bytes | The complete file, byte for byte, with no transformation |
No encoding is assumed or declared, no compression is applied, and no text conversion happens. See HEAT ProtoLib’s message reference for the message itself.
6.2 Published data and naming
There are no component or reading names to document, because this module publishes no
SimulationFrame, no entity and no component. It publishes a root-level opaque payload. Consumers
identify it by the packet’s Any type URL,
type.googleapis.com/HEAT.Proto.Data.FolderUploadData.
| Item | Value |
|---|---|
| Packets per file | Exactly one. There is no batching and no manifest |
| Envelope | DataPacket with TimestampUnixMs set to UTC Unix milliseconds at publish time and Payload set to Any.Pack(FolderUploadData) |
| Node attribution | NodeId when OverrideNodeId is true, otherwise -1 |
OriginUuid | Left empty by the module. The host fills it from the authenticated user when it is empty |
6.3 What is not published
The file name, extension, full path, size, creation and modification times, and any hash or ordering
marker are all absent from the payload. The only per-file identifier that exists anywhere is the
Processing file: {FilePath} log line, which is not part of the captured data.
A consumer therefore cannot tell which file a payload came from, cannot tell two payloads apart when their content is identical, and cannot reconstruct the folder layout. If provenance matters downstream it has to be encoded inside the file’s own content by whatever writes it.
The packet timestamp is not a proxy for when the file appeared. Every file recorded in a window is published in one burst at teardown, so all packets from a session carry near-identical timestamps regardless of how far apart the files were created.
6.4 Cadence
HandleCapture unconditionally returns null, so the SDK capture loop publishes nothing on any
cycle. The loop still runs at Interval, default 1000 ms, doing no work. All publication for a
session happens in one burst inside EndCapture.
Interval therefore has no effect on this module’s output. Interval is marked [HideSetting] in
the settings type and is not exposed in the settings UI.
7. Stop timing budget
This module is unusual among capture modules in that all of its work happens during shutdown, so the host’s stop budget is part of its interface rather than an operational detail.
The module overrides the inherited Timeout setting to a computed value,
max(DelayBeforeUpload.TotalSeconds × 1.1, 5) seconds, with a no-op setter and a [HideSetting]
[ReadOnly] declaration. The intent is that the host’s stop budget outlasts the wait inside
EndCapture with roughly ten percent of headroom for the reads. At the default 30 second delay,
Timeout is 33 seconds, leaving about 3 seconds for reading and publishing every file recorded
during the session. A large file set, a slow volume or a network path can exhaust that headroom.
The host applies the value in ModuleManager.SafeStopTask: it starts from a fixed 30 second budget
and substitutes the module’s Timeout only when settings.Timeout.Seconds is 5 or greater.
TimeSpan.Seconds is the seconds component of the interval, not its total, so the guard misreads any
computed Timeout whose total exceeds a minute while its seconds component is 0 to 4. When that
happens the host silently uses 30 seconds, which is shorter than the delay the module is about to
wait out, the wait is cancelled, and no file from that session is published.
DelayBeforeUpload | Computed Timeout | Seconds component | Budget the host uses | Outcome |
|---|---|---|---|---|
| 30 s (default) | 33 s | 33 | 33 s | Delay completes, about 3 s left for all reads |
| 54 s | 59.4 s | 59 | 59.4 s | Delay completes |
| 55 s to 59 s | 60.5 s to 64.9 s | 0 to 4 | 30 s | Delay is cancelled. Nothing is published |
| 1 min | 1 min 6 s | 6 | 1 min 6 s | Delay completes |
| 1 min 50 s to 1 min 53 s | 2 min 0.3 s to 2 min 4.3 s | 0 to 4 | 30 s | Delay is cancelled. Nothing is published |
The fault recurs wherever the computed Timeout lands in the first five seconds of a minute. It is
not in the repository audit as a finding. Until it is fixed, choose DelayBeforeUpload so that the
value multiplied by 1.1 does not fall in the first five seconds of a minute, and verify a real
session end rather than assuming the delay was honoured.
8. Error behaviour
| Condition | Where | Observable | Effect on the interface |
|---|---|---|---|
| Watched folder absent at initialisation | InitialiseModule | Warning Folder Upload folder does not exist: {Folder}. Creating it. | Folder created, module continues. Nothing at a previous location is picked up |
| Folder cannot be created | InitialiseModule | SDK logs {Module} InitialiseModule failed. | Initialisation returns false. Module does not start |
| Watcher construction fails | StartCapture | Error Error setting up file watcher | StartCapture returns false. No file is captured for that session |
| File created between enabling events and attaching the handler | StartCapture | No log | Notification discarded. FOLDER-02 |
| Notification burst exceeds the watcher’s internal buffer | Watcher | No log. No Error handler is attached, so the overflow is never surfaced | Affected notifications lost silently |
| File created in a subdirectory | Watcher | No log | Not notified. IncludeSubdirectories is false |
| Recorded path no longer exists at read time | ProcessFile | Error Error reading file: {FilePath} | File skipped. No retry. Covers a deleted file and a rename staging path |
| Recorded path locked by its writer | ProcessFile | Error Error reading file: {FilePath} | File skipped. No retry |
Recorded path exceeds the .NET whole-file read limit of int.MaxValue bytes | ProcessFile | Error Error reading file: {FilePath} | File skipped. The module imposes no size limit of its own |
| Recorded path is a directory | ProcessFile | Error Error publishing Folder Upload data from file: {FilePath} | Skipped. File.ReadAllBytesAsync on a directory throws UnauthorizedAccessException, which is not an IOException, so it is reported by the second handler |
| Stop budget exhausted during the delay | EndCapture | Host logs Stop timed out for module {ModuleName}, SDK logs Error ending capture in module {Module} | No file published. See section 7 |
| Stop budget exhausted partway through the reads | ProcessFile | Error Error publishing Folder Upload data from file: {FilePath} once per remaining file | Remaining files dropped. Cancellation is not an IOException, so it lands in the second handler |
| Capture paused when the module stops | Host | Module logs Folder Upload data published successfully. The host discards the packet with no log | Nothing is stored. The module’s success log is not evidence that a payload was recorded |
| Watcher teardown throws | CleanupFileWatcher | Warning Error cleaning up file watcher | Swallowed. Teardown continues |
No condition in this table causes a retry, and no condition is reported back to the writing process. A failed file is indistinguishable, from the captured data alone, from a file that was never created.
9. Units and conventions
| Concept | Convention |
|---|---|
content | Raw file bytes. No character encoding, framing or content type is declared or inferred |
| Packet time base | Host wall clock, UTC Unix milliseconds, taken at publish time during teardown. Not the file’s creation or modification time |
DelayBeforeUpload and Timeout | .NET TimeSpan. In JSON configuration, the "hh:mm:ss" string form, for example "00:01:00" |
| Paths | FileSystemEventArgs.FullPath as the watcher reports it, absolute, used verbatim for the read and never published |
| Node attribution | -1 means no override, so the host applies its own routing |
10. Configuration affecting the interface
| Setting | Type | Default | Interface effect |
|---|---|---|---|
Folder | string | C:\FolderToUpload | The watched address. Created at initialisation if absent |
DelayBeforeUpload | TimeSpan | 30 seconds | Extends the observation window past session stop, delays every read and publication by that amount, and determines the computed Timeout. See section 7 |
Enabled | boolean | true | Whether the module runs at all |
OverrideNodeId | boolean | false | Whether NodeId is applied instead of -1 |
NodeId | integer | 0 | Node the packets are attributed to when OverrideNodeId is true |
Interval | integer, [HideSetting] | 1000 ms | None. HandleCapture always returns null |
Timeout | TimeSpan, computed, [HideSetting] [ReadOnly] | max(DelayBeforeUpload × 1.1, 5) seconds | The host’s stop budget, subject to section 7. Cannot be set: the setter is a no-op |
The watcher’s filter, subdirectory recursion, buffer size and event subscriptions are not settings. There is no way from configuration to restrict which files are picked up, to observe subdirectories, to enlarge the notification buffer, or to change the retry behaviour, because there is none.
FolderUploadSettings implements IDataModuleSettings directly rather than deriving from
BaseDataModuleSettings, which is why it redeclares the generic members. Their meanings are
unchanged; see Manage capture modules and the
Module SDK ICD.
11. Compatibility and versioning
There is no version negotiation on any of the three interfaces. The filesystem side has no protocol
to version, and FolderUploadData carries a single bytes field at number 1, so it is stable under
protobuf’s own compatibility rules.
The module targets net10.0 and references HEAT.Capture.ModuleSDK 2.0.5. It declares no
[ModuleCapability], so HEAT Capture applies no capability mediation to its filesystem access.
Identifying which artifact a deployment is running requires care. The release workflow names the
package ZIP from the full SemVer on any non-main branch, but derives both the GitHub release tag
and the download URL written into heat.module.json from major.minor.patch. A prerelease build and
a stable build therefore both publish into the tag vX.Y.Z, with different ZIP filenames, and the
last run’s manifest wins. This is audit finding FOLDER-01. Identify the deployed artifact by the ZIP
filename and checksum recorded in the installed manifest’s source block, not by the release tag.
12. Security considerations
The watched directory’s access control list is the entire security boundary. There is no authentication, no signature check and no content inspection, so any process that can create a file in that directory can put arbitrary bytes into a capture session, at arbitrary size, and the payload carries nothing that identifies the writer.
The default Folder of C:\FolderToUpload sits at the root of the system drive and inherits that
root’s access control rather than an access control chosen for this purpose. Treat the default as a
placeholder: point Folder at a directory created for the deployment, with an explicit access
control list granting write access only to the process that produces the files and read access only
to HEAT Capture.
Because file content is published verbatim, whatever the dropped files contain enters the session record unfiltered, including anything personal or secret that a log or export happens to carry. Screen what is written into the watched folder at the source; this module will not.
13. Conformance
- Point
Folderat a writable directory. Start a capture session and confirm the log recordsFile watcher started for folder: {Folder}. - Create one file in that directory after the watcher line appears. Confirm
File created: {FullPath}, then stop the session and confirmFound 1 file(s) to process,File read successfully. Size: {Size} bytesandFolder Upload data published successfully. - Confirm the captured session contains one packet whose payload type URL is
type.googleapis.com/HEAT.Proto.Data.FolderUploadDataand whosecontentbyte length equals the file’s size on disk. - Confirm the source file is still present and unmodified in the watched folder, and that starting a second session without creating anything publishes no packet.
- Place a file in the directory before starting a session and confirm it is never published. This verifies that eligibility comes from notification and not from a directory scan.
- Create a file in a subdirectory of the watched folder during a session and confirm it is never published.
- Confirm the timing of section 4.3: create a file after the session stop is requested but before
DelayBeforeUploadelapses, and confirm it is captured. - Hold a file open with an exclusive lock across the whole window and confirm
Error reading file: {FilePath}and that no further attempt is made. - Exercise section 7 before committing to a value. Set
DelayBeforeUploadto 55 seconds, stop a session, and confirm whetherStop timed out for module {ModuleName}andError ending capture in module {Module}appear and no packet is produced. Then confirm the value the deployment will actually use does not exhibit this. - Exercise FOLDER-02 by creating a file at the instant the module starts, repeatedly, and confirm whether it is captured. A file created in that window is expected to be lost until FOLDER-02 is fixed.
14. Revision history
| Rev | Date | Product version | Change |
|---|---|---|---|
| 1 | 2026-09-02 | 5debe79 | Initial issue. |
| 2 | 2026-09-02 | 5debe79 | Rewritten from source. Full watcher configuration, eligibility, read and publication contract, stop timing budget, and error table. |