Skip to content

Commit 8f98383

Browse files
committed
feat(s4): dual-track data dictionary (Stage S4)
Tasks T043–T052 (US5). One normalized dictionary source feeds both tracks (Constitution Principle IV). truefix-dict: - Normalized dictionary format (authored from the FIX spec; no QuickFIX/J XML copied) + parser: fields/types/enums, header/trailer, message required/optional. - Runtime DataDictionary::validate with toggles (ValidateFieldsHaveValues, AllowUnknownMsgFields, ValidateUserDefinedFields, required-field + type/enum + field-membership checks) and two rejection layers: session-level (dictionary failures) vs business-level (unknown MsgType). - build.rs codegen: per-version MsgType constants + a content hash; runtime asserts its parsed hash equals the generated *_DICT_HASH (proves single source). - FIXT 1.1 transport/application split with DefaultApplVerID resolution. - Bundled subset dictionaries: FIX.4.4, FIXT.1.1, FIX.5.0. truefix-session: - Optional inbound validation hook (set_dictionary): invalid in-order application messages produce a Reject (35=3, SessionRejectReason) or BusinessMessageReject (35=j); admin messages are not dictionary-validated. Opt-in, so existing flows are unchanged. Tests: 21 dict tests (parse, 8 toggle cases, rejection layers, FIXT split, dual-track hash) + 4 session validation-hook tests. 74 workspace tests green; fmt/clippy -D warnings clean. Scope note: dictionaries are working subsets and codegen emits MsgType constants + hash; full per-version breadth and richer typed-message codegen are S7.
1 parent ff56737 commit 8f98383

25 files changed

Lines changed: 1369 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ tracing = "0.1"
3232
metrics = "0.24"
3333
async-trait = "0.1"
3434
truefix-core = { path = "crates/truefix-core" }
35+
truefix-dict = { path = "crates/truefix-dict" }
3536
truefix-session = { path = "crates/truefix-session" }
3637
truefix-transport = { path = "crates/truefix-transport" }
3738
truefix-config = { path = "crates/truefix-config" }

crates/truefix-core/src/field_map.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ impl FieldMap {
7777
})
7878
}
7979

80+
/// Iterate the top-level fields (skipping repeating groups), in order.
81+
pub fn fields(&self) -> impl Iterator<Item = &Field> {
82+
self.members.iter().filter_map(|m| match m {
83+
Member::Field(f) => Some(f),
84+
Member::Group { .. } => None,
85+
})
86+
}
87+
8088
/// Internal: ordered members, for the encoder.
8189
pub(crate) fn members(&self) -> &[Member] {
8290
&self.members

crates/truefix-dict/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,9 @@ repository.workspace = true
88
description = "FIX data dictionary: build-time codegen + runtime validation (dual-track)."
99
publish = false
1010

11+
[dependencies]
12+
truefix-core.workspace = true
13+
thiserror.workspace = true
14+
1115
[lints]
1216
workspace = true

crates/truefix-dict/build.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! Build-time codegen (dual-track, Constitution Principle IV).
2+
//!
3+
//! Reads the same normalized dictionary sources the runtime loads, and generates per-version
4+
//! message-type constants plus a content hash. The runtime asserts its parsed hash equals the
5+
//! generated one, proving codegen and runtime derive from one source.
6+
7+
use std::env;
8+
use std::fmt::Write as _;
9+
use std::fs;
10+
use std::path::Path;
11+
12+
fn fnv1a(bytes: &[u8]) -> u64 {
13+
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
14+
for &b in bytes {
15+
hash ^= u64::from(b);
16+
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
17+
}
18+
hash
19+
}
20+
21+
fn main() {
22+
let manifest = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
23+
let out_dir = env::var("OUT_DIR").expect("OUT_DIR");
24+
let mut code = String::new();
25+
code.push_str("// @generated by build.rs from dict-src/normalized — do not edit.\n");
26+
27+
for (name, file) in [
28+
("FIX44", "FIX44.fixdict"),
29+
("FIXT11", "FIXT11.fixdict"),
30+
("FIX50", "FIX50.fixdict"),
31+
] {
32+
let path = Path::new(&manifest).join("dict-src/normalized").join(file);
33+
println!("cargo:rerun-if-changed={}", path.display());
34+
let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
35+
let hash = fnv1a(&bytes);
36+
let _ = writeln!(code, "/// Content hash of the {name} dictionary source.");
37+
let _ = writeln!(code, "pub const {name}_DICT_HASH: u64 = {hash};");
38+
39+
let module = name.to_lowercase();
40+
let _ = writeln!(
41+
code,
42+
"/// Generated MsgType constants for the {name} dictionary."
43+
);
44+
let _ = writeln!(code, "pub mod {module}_msgs {{");
45+
for line in String::from_utf8_lossy(&bytes).lines() {
46+
let line = line.trim();
47+
if let Some(rest) = line.strip_prefix("message ") {
48+
let mut it = rest.split_whitespace();
49+
if let (Some(msg_type), Some(msg_name)) = (it.next(), it.next()) {
50+
let ident = msg_name.to_uppercase();
51+
let _ = writeln!(code, " /// MsgType for {msg_name}.");
52+
let _ = writeln!(code, " pub const {ident}: &str = {msg_type:?};");
53+
}
54+
}
55+
}
56+
let _ = writeln!(code, "}}");
57+
}
58+
59+
let dest = Path::new(&out_dir).join("generated.rs");
60+
fs::write(&dest, code).unwrap_or_else(|e| panic!("write generated.rs: {e}"));
61+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# TrueFix normalized dictionary — FIX.4.4 (subset).
2+
# Authored from the FIX 4.4 specification (field numbers/names are protocol facts).
3+
# Format (one directive per line; '#' comments):
4+
# version <BeginString>
5+
# field <tag> <Name> <TYPE> [<enumValue> ...]
6+
# header <tag> ...
7+
# trailer <tag> ...
8+
# message <MsgType> <Name> [req:<tag,tag,...>] [opt:<tag,tag,...>]
9+
version FIX.4.4
10+
11+
field 8 BeginString STRING
12+
field 9 BodyLength LENGTH
13+
field 35 MsgType STRING
14+
field 34 MsgSeqNum SEQNUM
15+
field 49 SenderCompID STRING
16+
field 56 TargetCompID STRING
17+
field 52 SendingTime UTCTIMESTAMP
18+
field 43 PossDupFlag BOOLEAN
19+
field 122 OrigSendingTime UTCTIMESTAMP
20+
field 10 CheckSum STRING
21+
field 98 EncryptMethod INT 0 1 2 3 4 5 6
22+
field 108 HeartBtInt INT
23+
field 141 ResetSeqNumFlag BOOLEAN
24+
field 112 TestReqID STRING
25+
field 7 BeginSeqNo SEQNUM
26+
field 16 EndSeqNo SEQNUM
27+
field 36 NewSeqNo SEQNUM
28+
field 123 GapFillFlag BOOLEAN
29+
field 58 Text STRING
30+
field 45 RefSeqNum SEQNUM
31+
field 11 ClOrdID STRING
32+
field 21 HandlInst CHAR 1 2 3
33+
field 55 Symbol STRING
34+
field 54 Side CHAR 1 2 5 6
35+
field 60 TransactTime UTCTIMESTAMP
36+
field 38 OrderQty QTY
37+
field 40 OrdType CHAR 1 2 3 4
38+
field 44 Price PRICE
39+
40+
header 8 9 35 34 49 56 52 43 122
41+
trailer 10
42+
43+
message 0 Heartbeat opt:112
44+
message 1 TestRequest req:112
45+
message 2 ResendRequest req:7,16
46+
message 4 SequenceReset req:36 opt:123
47+
message 5 Logout opt:58
48+
message A Logon req:98,108 opt:141
49+
message 3 Reject req:45 opt:58
50+
message D NewOrderSingle req:11,21,55,54,60,40 opt:38,44
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# TrueFix normalized dictionary — FIX.5.0 application layer (subset).
2+
# Application messages only; the session layer is provided by FIXT.1.1.
3+
version FIX.5.0
4+
5+
field 11 ClOrdID STRING
6+
field 21 HandlInst CHAR 1 2 3
7+
field 55 Symbol STRING
8+
field 54 Side CHAR 1 2 5 6
9+
field 60 TransactTime UTCTIMESTAMP
10+
field 38 OrderQty QTY
11+
field 40 OrdType CHAR 1 2 3 4
12+
field 44 Price PRICE
13+
field 58 Text STRING
14+
15+
message D NewOrderSingle req:11,21,55,54,60,40 opt:38,44,58
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# TrueFix normalized dictionary — FIXT.1.1 transport (session) layer (subset).
2+
version FIXT.1.1
3+
4+
field 8 BeginString STRING
5+
field 9 BodyLength LENGTH
6+
field 35 MsgType STRING
7+
field 34 MsgSeqNum SEQNUM
8+
field 49 SenderCompID STRING
9+
field 56 TargetCompID STRING
10+
field 52 SendingTime UTCTIMESTAMP
11+
field 10 CheckSum STRING
12+
field 98 EncryptMethod INT 0
13+
field 108 HeartBtInt INT
14+
field 141 ResetSeqNumFlag BOOLEAN
15+
field 1137 DefaultApplVerID STRING
16+
field 112 TestReqID STRING
17+
18+
header 8 9 35 34 49 56 52
19+
trailer 10
20+
21+
message 0 Heartbeat opt:112
22+
message 1 TestRequest req:112
23+
message 5 Logout
24+
message A Logon req:98,108,1137

crates/truefix-dict/src/fixt.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! FIXT 1.1 transport/application dictionary separation.
2+
//!
3+
//! Under FIXT 1.1 the session (transport) layer and the application layer use **separate**
4+
//! dictionaries. The application dictionary is selected per-message by ApplVerID, falling back to
5+
//! the session's DefaultApplVerID.
6+
7+
use std::collections::BTreeMap;
8+
9+
use crate::model::DataDictionary;
10+
11+
/// A transport dictionary plus one or more application dictionaries keyed by application version.
12+
#[derive(Debug, Clone)]
13+
pub struct FixtDictionaries {
14+
transport: DataDictionary,
15+
applications: BTreeMap<String, DataDictionary>,
16+
default_appl_ver_id: Option<String>,
17+
}
18+
19+
impl FixtDictionaries {
20+
/// Create from a transport dictionary; add application dictionaries with
21+
/// [`with_application`](Self::with_application).
22+
pub fn new(transport: DataDictionary) -> Self {
23+
Self {
24+
transport,
25+
applications: BTreeMap::new(),
26+
default_appl_ver_id: None,
27+
}
28+
}
29+
30+
/// Register an application dictionary under an application version id (e.g. `"FIX.5.0"`).
31+
pub fn with_application(
32+
mut self,
33+
appl_ver_id: impl Into<String>,
34+
dict: DataDictionary,
35+
) -> Self {
36+
self.applications.insert(appl_ver_id.into(), dict);
37+
self
38+
}
39+
40+
/// Set the DefaultApplVerID used when a message does not carry an explicit ApplVerID.
41+
pub fn with_default_appl_ver_id(mut self, appl_ver_id: impl Into<String>) -> Self {
42+
self.default_appl_ver_id = Some(appl_ver_id.into());
43+
self
44+
}
45+
46+
/// The transport (session-layer) dictionary.
47+
pub fn transport(&self) -> &DataDictionary {
48+
&self.transport
49+
}
50+
51+
/// Resolve the application dictionary for an explicit `appl_ver_id`, falling back to the
52+
/// DefaultApplVerID.
53+
pub fn application_for(&self, appl_ver_id: Option<&str>) -> Option<&DataDictionary> {
54+
let key = appl_ver_id.or(self.default_appl_ver_id.as_deref())?;
55+
self.applications.get(key)
56+
}
57+
}

crates/truefix-dict/src/hash.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//! Stable content hash (FNV-1a, 64-bit) used to prove the codegen track and the runtime track
2+
//! derive from the same dictionary source (Constitution Principle IV). The identical algorithm is
3+
//! duplicated in `build.rs`.
4+
5+
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
6+
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
7+
for &b in bytes {
8+
hash ^= u64::from(b);
9+
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
10+
}
11+
hash
12+
}

0 commit comments

Comments
 (0)