35 KiB
tines-core 1.0 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship tines-core 1.0.0: a versioned wire format, a frozen minimal public API, a settled Store trait, a reserved signature slot, and evidence (fuzzing, pinned vectors, MSRV, semver-checks) that the untrusted-input and compatibility promises hold.
Architecture: 1.0 adds nothing to the exchange's behaviour; it removes, hides, pins, documents and reserves. Two wire-format changes (a version byte, then the Observation reshape) plus a Store trait extension and four core-hardening fixes, each landed as an independently green, committed task on top of master (aedb802).
Tech Stack: Rust 2021, #![no_std] + optional alloc, postcard, heapless, blake2, serde, proptest, cargo-fuzz (nightly), cargo-msrv, cargo-semver-checks.
Spec: docs/superpowers/specs/2026-10-18-tines-1-0-design.md (approved; decisions F and G added 2026-10-20, G amended after review) — the plan argues from the spec, so the spec travels with it; executors read both. Review: HANDOFF.md.
Global Constraints
Every task's requirements implicitly include this section.
tines-corestays#![no_std];allocan optional default-on feature;--no-default-featuresbuilds forthumbv7em-none-eabihf. No async/threads/I/O/panics on untrusted input; wire frames are untrusted, malformed bytes becomeErr/a counted drop, never a panic.Addris never mapped toNodeIdby core.- Identity hash never changes:
id()readsdest.map(|d| d.to)and is byte-for-byte the 0.x hash; the pinned vector (below) carries over unchanged.sigis excluded from the id. - Exact values:
MAX_TOPIC=64,MAX_PAYLOAD=1024,MAX_PEERS=8,SUMMARY_BYTES=512,BLOOM_K=3,SUMMARY_INTERVAL_MS=2000,REASSEMBLY_TIMEOUT_MS=5000,SEND_QUEUE_CAP=256,MAX_FRAME=1500,ACK_IDS_CAP=90,EVENT_QUEUE_CAP=64,BCAST_SENT_CAP=256,ACK_BUFFER_CAP=256,MAX_SIG=64,MAX_FRAGMENTS=64(pub(crate)),FRAG_OVERHEAD(currently 64, becomes 65 in Task 1, private in Task 5),SUMMARY_OVERHEAD(private, currently 24, becomes 25 in Task 1). - Decisions already made (do not reopen): D1 reserve signature slot; D2
Destreplacesto+lifetime_ms; D3 drop-and-report; D4 onlytines-coregoes 1.0; D5 no store migration (old logs refused, wiped); Q1MAX_SIG=64; Q2 coverage = whole observation +version+algo; Q3 malformed reported like version mismatch; R1 drop reports carry transportAddr; R2 drops counted by(Addr, reason), not queued; R3MAX_FRAME=1500stays; R4observed_at= observer's wall-clock ms since Unix epoch; FStore::flush+Node::flush(now)replacesstore_mut; G age across downtime via a wall-clock marker, as amended: a host opens the store only once it has a trusted time source,LogStore::open(path, now, wall_now: u64)takes a plainu64, and the marker's wall time is derived (wall_at_open + (now − tick_at_open)). tines-ros2is not in the default gate (itsros2feature is off); verify any change touchingtines-ros2withdocker build -f Dockerfile.ros2 -t tines-ros2-check .(cached image exists).- Gate: run
sh scripts/ci.shverbatim and require it green at every commit — it runscargo fmt --all -- --check, then clippy/doc/test for the whole workspace, then the no-allocclippy/doc/test matrix fortines-core(sh scripts/check-no-std.sh). Do not substitute the individual steps for the script. - House rule: primary branch is
master; land by rebase + fast-forward. Commit messages use the conventional prefix shown in each task; license Apache-2.0, copyright "K. Isom". - Execution: local
ornith-1.5-35b-a3bsubagents, reviews run sequentially (single-GPU llama-server). When a task touchestines-ros2, its final step is the Docker build, not a plaincargo check.
Review Focus
The six failure modes the spec implies but no single task's tests exercise, most likely to bite first:
- A frame with the wrong version byte (un-updated or foreign peer) must be dropped and reported as
VersionMismatch { version }, never silently swallowed and never folded intoMalformed. An empty frame isMalformed(there is no version to mismatch). → pinned in Task 2. - A frame whose
fromchanges within one contact must be dropped asIdentityChangedand the peer's addressed mail must not be handed over to the new identity. → pinned in Task 9. - A signature with an unknown algorithm id, or the wrong length for a known one — the first must be carried and forwarded untouched; the second (and
algo = 0andversion = 0) isMalformed, including when the observation is rebuilt from fragments. → pinned in Task 4. - A store opened with unknown downtime (no clock marker, or a wall clock that went backwards) must expire held addressed messages (never deliver a directive that may have outlived
lifetime_ms) and floor held broadcasts atunknown_downtime_age_ms, not deliver either as-is. → pinned in Task 10. - A valid frame with trailing bytes must be rejected as
Malformed(canonical decode), and a frame whose decode finds thefromfield missing must not panic. → pinned in Task 9. - Every frame must fit
mtu()after the +1 version byte and the +67-byte signature — swept across MTUs from the BLE floor (MIN_MTU = 23) toMAX_FRAME, with an oversized observation given up cleanly asTooLarge, never sent truncated. → pinned in Task 4.
File Map (decomposition locked in)
tines-core/src/wire.rs— envelope encode/decode,frame_sender;Frame/WireErrorbecome private in Task 5.tines-core/src/observation.rs—Observation,Dest,Signature,SigAlgo,NodeId,ObservationId,Tick,Topic,Payload.tines-core/src/consts.rs—WIRE_VERSION+ the wire/local split.tines-core/src/exchange.rs— drop counters,NodeEvent, peer pinning, hardening. Stays private (pub(crate)from Task 5).tines-core/src/node.rs—Nodefacade:flush(now),drain_events -> NodeEvent.tines-core/src/store.rs—Storetrait:is_empty,flush; thestore_conformancemodule.tines-core/src/lib.rs— the 1.0 public surface +#![deny(missing_docs)].tines-core/tests/vectors.rs— pinned identity + golden wire vectors.tines-store/src/log.rs— format v2 + wall-clock marker + downtime fold.tines-sim/src/capped.rs— theCappedStorewrapper (everyStorechange goes through it).tines/src/ipc.rs,tines-ros2/src/node.rs— host consumers ofNodeEvent/flush/observed_at.tines-bt/src/transport.rs—frame_senderimport site.fuzz/— cargo-fuzz target (outside the workspace).
Task 1: Wire envelope — WIRE_VERSION byte
Files:
- Modify:
tines-core/src/consts.rs(addWIRE_VERSION) - Modify:
tines-core/src/wire.rs(encode/decode prepend+check the byte;frame_senderbecomes version-aware; update the golden Summary vector; keep the pre-change Summary bytes as a 0.x-rejection vector) - Modify:
tines-core/src/exchange.rs(SUMMARY_OVERHEAD24 → 25) - Modify:
tines-core/src/frag.rs(FRAG_OVERHEAD64 → 65) - Modify:
tines-bt/tests/transport.rs,tines-bt/tests/adv.rs(theframe(from, payload)helpers prependWIRE_VERSION)
Interfaces:
-
Produces:
pub const WIRE_VERSION: u8 = 4;(decided: 4 is the first value in 4–0x7F, a single-byte postcard tag, ≥ the 0.x tags 0–3);WireErrorgainsVersionMismatch { version: u8 };frame_sender(bytes: &[u8]) -> Option<NodeId>now skips two leading bytes (version + variant tag) and returnsNonewhenbytes[0] != WIRE_VERSION. -
Step 1: Write the failing tests
In tines-core/src/wire.rs (mod tests): encode_prepends_version_byte (encode a Summary, assert buf[0] == WIRE_VERSION and Frame::decode(&buf[..n]) round-trips — do not decode &buf[1..], which is missing the version byte); decode_wrong_version_is_mismatch (a frame with buf[0] = WIRE_VERSION + 1 returns Err(WireError::VersionMismatch { version })); decode_empty_is_malformed (Frame::decode(&[]) returns Err(WireError::Malformed), not a panic and not VersionMismatch { version: 0 }); rejects_0x_frame (the pre-change 0.x Summary golden bytes — captured from the current summary_golden_vector literal before editing it — are rejected as VersionMismatch { version: 0 }); frame_sender_skips_version_and_tag (bytes [WIRE_VERSION, 0, from..] yields Some(from)); frame_sender_wrong_version_is_none ([WIRE_VERSION+1, 0, from..] → None).
-
Step 2: Run, expect failure —
cargo test -p tines-core wirefails on the new names/behaviour. -
Step 3: Implement — add
WIRE_VERSION; inFrame::encodewriteout[0] = WIRE_VERSIONthen postcard intoout[1..](return1 + len,TooLargeifout.len() < 1 + len); inFrame::decodecheckbytes.is_empty()→Malformed, thenbytes[0] != WIRE_VERSION→Err(VersionMismatch { version: bytes[0] }), then postcard the remainder (the empty-remainder/canonical check is not yet enforced — that is Task 9); rewriteframe_sendertoENVELOPE_OVERHEAD = 2. -
Step 4: Run, expect pass —
cargo test -p tines-coregreen; then update the tines-bt test helpers and re-runcargo test -p tines-bt. -
Step 5: Commit —
git commit -m "feat(core): WIRE_VERSION byte on every frame".
Task 2: Drop reporting — NodeEvent and the drop counter table (§1, §3 events)
Files:
- Modify:
tines-core/src/exchange.rs(renameConfidenceEvent→NodeEvent<A>; addDropReason, the counter table, the overflow counter; report drops inon_frame;drain_eventsreturnsNodeEvent) - Modify:
tines-core/src/node.rs(drain_eventssignature) - Modify:
tines-core/src/lib.rs(exportNodeEvent,DropReason; dropConfidenceEvent) - Modify:
tines-core/src/consts.rs(DROP_TABLE_CAP) - Modify:
tines/src/ipc.rs(mapNodeEvent::DeliveredintoEventOut; logNodeEvent::Droppedto stderr) - Modify:
tines-ros2/src/node.rs+lib.rs(matchNodeEvent; fix the doc-comment links toConfidenceEvent)
Interfaces:
-
Consumes:
WireError::VersionMismatch(Task 1). -
Produces:
#[non_exhaustive] pub enum NodeEvent<A> { Delivered { peer: NodeId, id: ObservationId }, Dropped { addr: Option<A>, reason: DropReason, count: u32 } };#[non_exhaustive] pub enum DropReason { VersionMismatch { version: u8 }, Malformed }(IdentityChangedarrives in Task 9);pub const DROP_TABLE_CAP: usize = 32;(local const);Node::drain_events(&mut self) -> heapless::Vec<NodeEvent<T::Addr>, { EVENT_QUEUE_CAP + DROP_TABLE_CAP + 1 }>. Decided: the return buffer is sizedEVENT_QUEUE_CAP + DROP_TABLE_CAP + 1(64 Delivered + 32 counted Drops + 1 overflow slot) so drops always have room; the table itself isDROP_TABLE_CAP, so a drop counter that would exceed the buffer is impossible. -
Step 1: Write the failing tests — in
exchange.rs(mod tests):version_mismatch_is_counted(drive one frame with a bad version byte fromaddr, drain, assert exactly oneDropped { addr: Some(addr), reason: VersionMismatch{..}, count: 1 });malformed_is_counted(garbage bytes →Dropped { reason: Malformed });same_drop_increments_count(two bad frames → oneDroppedwithcount: 2, drained once);drops_do_not_evict_delivered(fill the event queue withDelivered, add aDropped, assert allDeliveredstill present and theDroppedis reported);overflow_counter_has_no_addr(drive >DROP_TABLE_CAPdistinct(addr, reason)pairs, assert the last is reported withaddr: None);expired_addressed_is_not_a_drop(an addressed observation that arrives already past itslifetime_msproduces noDropped). -
Step 2: Run, expect failure —
cargo test -p tines-core exchangefails onNodeEvent/DropReasonnames. -
Step 3: Implement — rename to
NodeEvent<A>(two variants above); add the counter table (fixedheapless::Vec<(A, DropReason, u32), DROP_TABLE_CAP>or equivalent) + oneOption<(DropReason, u32)>overflow slot; inon_frame, replace theErr(_) => return Nonearm with a version-mismatch vs malformed branch that bumps the table;drain_eventsreturns queuedDeliveredfirst, then oneDroppedper nonzero counter (resetting it). Expired addressed observations are dropped by policy inaccept/reject, not counted. -
Step 4: Run, expect pass —
cargo test -p tines-core, thencargo test -p tines, thendocker build -f Dockerfile.ros2 -t tines-ros2-check .(tines-ros2 consumers). -
Step 5: Commit —
git commit -m "feat(core): NodeEvent with counted drop reporting".
Task 3: Dest — the addressing reshape (§2) + the log format bump (D5)
Files:
- Modify:
tines-core/src/observation.rs(Dest;Observation.destreplacesto/lifetime_ms;id();aged(); constructorsbroadcast/addressed/with_observed_at; theage_mslower-bound andobserved_atrustdoc; update the module's own tests) - Modify:
tines-core/src/node.rs(observe/observe_tobuild viadest;beliefreadsdest),store.rs(docs),mem_store.rs,exchange.rs(everyobs.to/obs.lifetime_ms→obs.dest;reject),wire.rs(Obsvariant + tests) - Modify:
tines-store/src/log.rs(bumpformat_version; refuse the old version on open) - Modify:
tines-store/tests/persistence.rs(moveold_format_is_refusedhere from Task 10) - Modify:
tines-sim/src/capped.rs(re-verify it compiles),tines-sim/src/sim.rs/world.rs(anyto:/lifetime_ms:construction),tines/src/ipc.rs,tines-ros2/src/node.rs
Interfaces:
-
Consumes: nothing new.
-
Produces:
#[non_exhaustive] pub struct Dest { pub to: NodeId, pub lifetime_ms: u64 };Observation { pub observer, pub seq, pub age_ms, pub observed_at, pub topic, pub payload, pub dest: Option<Dest> }(sigis added by Task 4 — this task'sObservationhas nosigfield yet);Observation::broadcast(observer, seq, topic, payload) -> Observation,Observation::addressed(observer, seq, topic, payload, to, lifetime_ms) -> Observation,Observation::with_observed_at(mut self, at: u64) -> Observation.tines-store'sformat_versionis bumped here (D5: old logs are refused, not migrated). -
Step 1: Write the failing tests — in
observation.rs:dest_invariant_by_type(an addressed observation always hasdest: Some);id_reads_dest_to(obs(7,0,None,Some([2;16]),Some(100)).id() == obs(7,0,None,Some([2;16]),Some(200)).id()— lifetime still not part of identity — and the pinnedid_known_vectorvalue is unchanged);constructors_set_age_zero(broadcast/addressedproduceage_ms == 0and the rightdest);with_observed_at_sets_field. Intines-store/tests/persistence.rs:old_format_is_refused(a v1 log →Errnaming the fix; moved from Task 10, since the encoding changes here). -
Step 2: Run, expect failure —
cargo test -p tines-core observationfails ondest. -
Step 3: Implement — mechanical:
dest: Option<Dest>replacesto: Option<NodeId>+lifetime_ms: Option<u64>;id()foldsself.dest.map(|d| d.to)(marker bytes unchanged);rejectbecomesobs.dest.is_some_and(|d| obs.age_ms > d.lifetime_ms)(the malformed-pair branch and its tests are deleted); write the two rustdoc items now —age_msis a lower bound (it counts time on running clocks only; power-off time is recovered on store open, crash time is bounded by the host flush cadence, in-flight time is negligible) and points toobserved_at, andobserved_atis the observer's wall-clock time in ms since the Unix epoch (UTC),Noneif the observer has no trusted clock, never changed by relays. Bumptines-store'sformat_versionand makeopenrefuse any older version. Update every downstream read/construct;cargo check --workspaceand fix all fallout. -
Step 4: Run, expect pass —
cargo test --workspacegreen;cargo build --workspacegreen. -
Step 5: Commit —
git commit -m "feat(core): Dest type replaces to + lifetime_ms".
Task 4: Signature + SigAlgo — the reserved slot (§2a)
Files:
- Modify:
tines-core/src/observation.rs(Signature,SigAlgo,Observation.sig,MAX_SIG; the "signing and verification arrive in 1.1" rustdoc) - Modify:
tines-core/src/consts.rs(MAX_SIG = 64in the wire group) - Modify:
tines-core/src/wire.rs(Obs/Fragencode/decode ofsig; the golden Obs vector) - Modify:
tines-core/tests/vectors.rs(golden Obs vector, both the Ed25519-shaped and unknown-algo cases)
Interfaces:
-
Consumes:
Observation(Task 3). -
Produces:
pub const MAX_SIG: usize = 64;;#[non_exhaustive] pub struct Signature { pub version: u8, pub algo: SigAlgo, pub bytes: heapless::Vec<u8, MAX_SIG> };#[non_exhaustive] #[repr(u8)] pub enum SigAlgo { Ed25519 = 1, Unknown(u8) }(decided: catch-all isUnknown(u8));algoserializes as a rawu8on the wire, not a postcard enum tag. The rustdoc forSignature,SigAlgoandObservation::sigsays "signing and verification arrive in 1.1" in those words. -
Step 1: Write the failing tests — in
wire.rs/vectors.rs:obs_roundtrip_with_ed25519_signature(anObservationcarryingSignature { version: 1, algo: Ed25519, bytes: 64 bytes }decodes equal);obs_roundtrip_with_unknown_algo(algo: Unknown(7), short bytes, decodes and is forwarded, not rejected);algo_zero_is_malformed(algo: Unknown(0)→Malformed);version_zero_is_malformed(version: 0→Malformed);raw_one_decodes_to_ed25519(a wirealgobyte1decodes toSigAlgo::Ed25519, neverUnknown(1));ed25519_wrong_length_is_malformed(algo: Ed25519, 63 bytes →Malformed);sig_excluded_from_id(two observations identical exceptsigshare oneid());reassembled_bad_signature_is_malformed(an observation whose fragments reassemble to a bad-signatureObservationis rejected asMalformed, not accepted);fragmentation_fits_mtu_sweep(Review Focus #6 — see below).fragmentation_fits_mtu_sweep: formtuinMIN_MTU(=23)..=MAX_FRAME, a maximum-size signed observation (fullMAX_PAYLOAD+ 64-byte signature) must either fragment into ≤MAX_FRAGMENTS(64) frames each ≤mtu, or — below the threshold (~85 bytes) — be given up cleanly (SendError::TooLarge/ObserveError::TooLarge), never emitted oversized. -
Step 2: Run, expect failure — tests fail on missing
Signature/SigAlgo. -
Step 3: Implement — the types above;
sig: Option<Signature>onObservation(no public setter in 1.0; core's decode sets it); wire:algoas rawu8(customSerialize/Deserializeor a#[serde(with = "…")]module that writes therepr(u8)discriminant;Unknown(n)writesn);version = 0andalgo = 0and a wrong length forEd25519areMalformed;id()ignoressig; the same decode-level checks run on observations rebuilt from fragments. -
Step 4: Run, expect pass —
cargo test -p tines-coregreen; pin the golden Obs vector. -
Step 5: Commit —
git commit -m "feat(core): reserved Signature slot on Observation".
Task 5: Public API narrowing (§3)
Files:
- Modify:
tines-core/src/lib.rs(the 1.0 export list;mod wire;private,pub use wire::frame_sender;) - Modify:
tines-core/src/wire.rs,exchange.rs,frag.rs,bloom.rs(mark the non-API itemspub(crate)) - Modify:
tines-core/src/observation.rs,store.rs,transport.rs,node.rs(add#[non_exhaustive]where §3 requires) - Modify:
tines-sim/src/world.rs(fix the[Exchange]doc link),tines-bt/src/transport.rs(importtines_core::frame_sender) - Modify:
tines-bt/tests/transport.rs,adv.rs(import path)
Interfaces:
-
Consumes: all prior types.
-
Produces: the exact §3 public list.
Frame,WireError,Exchange,Peer,InFlight,Bloom(and itssaltfield),Reassembler,fragment,FRAG_OVERHEADarepub(crate).#[non_exhaustive]onObserveError,StoreError,SendError,Event,NodeEvent,DropReason,SigAlgo,Observation,Dest,Signature,TrimPolicy.pub use wire::frame_sender;at crate root (decided over keepingwirepublic). -
Step 1: Write the failing tests — a compile-time doc/export check: in
tines-core/tests/public_api.rs(or theconformancefeature), apub use-only probe thatuses each item on the §3 public list and asserts nothing; the failing signal iscargo check -p tines-core(private items are no longer importable — existing external callers break, which the plan fixes in this task). -
Step 2: Run, expect failure —
cargo check --workspacefails ontines-sim/tines-btimporting now-private items. -
Step 3: Implement — apply
pub(crate)/puband#[non_exhaustive]exactly per §3; fix the two external doc-link/import sites; runcargo doc --workspace --no-depsto confirm no broken private-link warnings. -
Step 4: Run, expect pass —
cargo check --workspace+cargo test --workspacegreen. -
Step 5: Commit —
git commit -m "refactor(core): narrow the 1.0 public API".
Task 6: The Store trait, settled (§4)
Files:
- Modify:
tines-core/src/store.rs(is_emptydefault,flushdefault, release docs, newstore_conformancemodule behind aconformancefeature) - Modify:
tines-core/src/node.rs(flush(now)replacesstore_mut) - Modify:
tines-core/Cargo.toml(conformancefeature) - Modify:
tines-store/src/log.rs(implementflush),tines-sim/src/capped.rs(delegate/implementis_empty/flush),tines/src/ipc.rs(callnode.flush(now)inpoll_exchange, around line 214)
Interfaces:
-
Consumes: nothing new.
-
Produces:
Store::is_empty(&self) -> bool(defaultself.len() == 0; drop thelen_without_is_emptyallow);Store::flush(&mut self, now: Tick) -> Result<(), StoreError>(defaultOk(()));Node::flush(&mut self, now: Tick) -> Result<(), StoreError>;Node::store_mutremoved. Aconformancefeature gatingtines_core::store_conformance— ano_std-clean module of functions that take a backend factory (not atines-core/tests/file), so they can build observations withsigset from inside the crate; each function exercises one property (dedupe, age lineage, saturating ticks, release-surviving-get,siground-trip, trim order, no tombstones, idempotentflush).MemStore,LogStore(a fresh file per case) andCappedStorecall the module from their own crates. -
Step 1: Write the failing tests — the
store_conformancemodule itself, with one function per property; then wireMemStore,tines_store::LogStoreandtines_sim::CappedStoreto it from their own crates. -
Step 2: Run, expect failure —
cargo test -p tines-core --features conformancefails onflush/is_empty/store_conformance. -
Step 3: Implement — trait defaults;
Node::flush(now)delegating toStore::flush;LogStore::flush(&mut self, now)writing aRecord::Clockwith the caller'snow(fixing F18's stale-marker tick); deletestore_mut; updatepoll_exchangeintines/src/ipc.rstonode.flush(clock.now()). -
Step 4: Run, expect pass —
cargo test --workspace+docker build -f Dockerfile.ros2 -t tines-ros2-check .. -
Step 5: Commit —
git commit -m "feat(core): settle the Store trait (flush, is_empty, conformance suite)".
Task 7: Constants — wire vs local split (§5)
Files:
- Modify:
tines-core/src/consts.rs(re-group + document each const as wire or local) - Modify:
AGENTS.md(the constant list shows the split)
Interfaces:
-
Produces:
consts.rssplit into two documented groups — wire (frozen for v1:MAX_TOPIC,MAX_PAYLOAD,MAX_FRAME,SUMMARY_BYTES,ACK_IDS_CAP,MAX_SIG,BLOOM_K, and the privateMAX_FRAGMENTS) vs local (MAX_PEERS,SEND_QUEUE_CAP,ACK_BUFFER_CAP,BCAST_SENT_CAP,EVENT_QUEUE_CAP,SUMMARY_INTERVAL_MS,REASSEMBLY_TIMEOUT_MS,DROP_TABLE_CAP). -
Step 1: No new tests — this task only re-documents; the test is
cargo doc --workspace --no-depsstaying clean and the AGENTS.md list matchingconsts.rs. -
Step 2: Implement — move + document per §5's rule ("wire if changing it changes how a v1 peer decodes or interprets a frame").
-
Step 3: Verify —
cargo test --workspacegreen (no behaviour change). -
Step 4: Commit —
git commit -m "docs(core): split wire vs local constants".
Task 8: Bounce-table overflow, pinned (§6)
Files:
- Modify:
tines-sim/src/sim.rs,tines-sim/tests/props.rs(a property driving the table pastSEND_QUEUE_CAP)
Interfaces:
-
Consumes: the exchange's bounce table (unchanged).
-
Produces: a proptest that floods >
SEND_QUEUE_CAPdistinct observations acrossMAX_PEERSpeers and asserts the "always ≥ 1 holder" invariant and that the custody cursor still gives every candidate a turn. -
Step 1: Write the failing test —
tines-sim/tests/props.rs::bounce_overflow_keeps_a_holder(drive custody past capacity; assert ≥1 holder for each id and no candidate starved). -
Step 2: Run, expect pass or a real finding — this pins behaviour; if it fails, the finding is the deliverable (do not "fix" by weakening the invariant).
-
Step 3: Implement any needed fix — only if the property exposes a genuine ≥1-holder violation; §6 says the current drop-oldest design is already safe, so expect pass-as-is.
-
Step 4: Commit —
git commit -m "test(sim): pin bounce-table overflow behaviour".
Task 9: Core hardening — F6, F8, F9, F13 (§10)
Files:
- Modify:
tines-core/src/exchange.rs(peeridpinning; idle eviction; summary replay limit; drop-oldest events; pending-ack purge; canonical decode) - Modify:
tines-core/src/consts.rs(PEER_IDLE_MS = 30000, local)
Interfaces:
-
Consumes:
NodeEvent,DropReason(Task 2). -
Produces:
DropReason::IdentityChanged;pub const PEER_IDLE_MS: u64 = 30_000;. -
Step 1: Write the failing tests —
id_changes_are_dropped_as_identity_changed(frame 1 setspeer.id = A, frame 2 claimsfrom = B→ dropped, countedIdentityChanged, and B receives no addressed mail);idle_peer_is_evicted(a peer with no inbound frame for >PEER_IDLE_MSis removed as ifLost);replayed_summary_is_ignored(two summaries from one peer withinSUMMARY_INTERVAL_MS→ the second is not acted on and produces no re-send);events_drop_oldest(overflow the event queue → the oldestDeliveredis gone, newest retained);lost_peer_purges_pending_acks(pending acks to a removed peer are cleared);trailing_bytes_is_malformed(valid frame + one trailing byte →Malformed, Review Focus #5). -
Step 2: Run, expect failure — each test fails against current behaviour.
-
Step 3: Implement — F6: first decoded frame pins
peer.id, laterfrommismatch →IdentityChangeddrop (reset onContact/Lost); F8: track last-inbound tick, evict atPEER_IDLE_MS; F9: at most one summary per peer perSUMMARY_INTERVAL_MS, and skip already-sent ids on rebuild; F13:events.pushdrops the oldest (remove(0)), purge acks onLost/evict,take_from_bytes+ empty-remainder check. -
Step 4: Run, expect pass —
cargo test -p tines-core+ fullsh scripts/ci.sh. -
Step 5: Commit —
git commit -m "fix(core): peer pinning, idle eviction, replay and queue hardening".
Task 10: tines-store wall-clock marker + age across downtime (§9, decision G as amended)
Files:
- Modify:
tines-store/src/log.rs(wall-clock marker with derived wall time;open(path, now, wall_now); downtime fold; addressed expiry; broadcast floor) - Modify:
tines-store/tests/persistence.rs(downtime tests, reworked per the amendment) - Modify:
tines/src/main.rs+tines/src/ipc.rs,tines-ros2/src/node.rs(hosts setobserved_atand wait for a trusted time source beforeLogStore::open)
Interfaces:
-
Consumes:
Observationwithobserved_at(Task 3),Store::flush(now)(Task 6), theformat_versionbump (Task 3). -
Produces:
LogStore::open(path: &Path, now: Tick, wall_now: u64) -> Result<Self, StoreError>— a plainu64(ms since the Unix epoch), notOption.LogStorerecords wall time and tick at open; eachflush(now)clock marker carrieswall_at_open + (now − tick_at_open). "Downtime unknown" now means: the log has no clock marker (the previous run never flushed), or the wall clock went backwards (marker wall time ahead ofwall_now). ALogStoreoption forunknown_downtime_age_msfloors held broadcasts. -
Step 1: Write the failing tests —
downtime_known_advances_ages(marker at wall 1000, reopen at 2000 → every held age +1000, saturating);downtime_unknown_expires_addressed(a log with no marker → held addressed obs never delivered, count reported once);downtime_unknown_floors_broadcasts(no marker → held broadcast age raised tounknown_downtime_age_ms);clock_went_backwards_is_unknown(marker wall time ahead ofwall_now→ treated as unknown);marker_wall_time_is_derived(open at wall W, tick T; flush at tick T+5000 → the marker carries W+5000);open_reports_downtime_once(a fold/unknown-downtime case is reported once on stderr plus a counter, likeskipped_records). -
Step 2: Run, expect failure —
cargo test -p tines-storefails on the new signature/behaviour. -
Step 3: Implement — per §9 as amended: hosts wait for a trusted time source (an RTC they trust, or a GPS fix) before
open, and setobserved_attoSome(wall ms)on every observation they make;unknown_downtime_age_msis configured below the host's trimmax_age_ms— since the trim defaults are still empty (host hardening), record that this floor'smax_age_mscheck waits for host hardening where it isn't configurable yet. Add a per-host test that an observation made through the IPC carriesSome(wall ms). -
Step 4: Run, expect pass —
cargo test -p tines-store+cargo test -p tines+docker build -f Dockerfile.ros2 -t tines-ros2-check .. -
Step 5: Commit —
git commit -m "feat(store): wall-clock marker and downtime fold".
Task 11: Evidence — pinned vectors + MSRV (§7)
Files:
- Create:
tines-core/tests/vectors.rs(consolidateid_known_vector+ the golden wire vectors) - Modify:
tines-core/Cargo.toml(rust-versionontines-coreonly),scripts/ci.sh(MSRV job building onlytines-core)
Interfaces:
-
Consumes: final wire encodings (Tasks 1–4).
-
Produces:
tines-core/tests/vectors.rswith the identity vector (unchanged value) + one golden encoding perFramevariant including the version byte + the Ed25519/unknown-algo Obs vectors + the 0.x-shaped frame rejected as version mismatch;rust-versionset ontines-coreonly (the host crates' deps — bluer, tokio, rclrs — need newer Rust; do not claim tines-core's MSRV for them); a CI job building/testingtines-corealone on that MSRV. -
Step 1: Write the failing tests — move the existing vectors in (the
id_known_vectorand the golden encodings); add the missing golden encodings and the 0.x-rejection vector. -
Step 2: Run, expect pass —
cargo test -p tines-core vectorsgreen. -
Step 3: Implement — find MSRV with
cargo msrvsearching up to the pinned toolchain (1.99; arepr(u8)enum with a data-carrying variant likeSigAlgoneeds ≥ 1.66, and dependencies set their own floors — don't guess the range); setrust-version; add the CI job. -
Step 4: Commit —
git commit -m "test(core): pinned vectors + MSRV gate".
Task 12: Evidence — fuzz harness + proptest twin (§7)
Files:
- Create:
fuzz/(cargo-fuzz, outside the workspace, nightly),fuzz/Cargo.toml,fuzz/fuzz_targets/node.rs - Modify:
tines-core/tests/(the proptest stable twin of the same harness) - Modify:
.github/workflows/(scheduled fuzz job)
Interfaces:
-
Consumes:
Node,MemStore, a scriptedTransport. -
Produces: a fuzz target whose input decodes into
Contact/Lost/Frame(bytes)/Tick(delta, may go backwards)/observesteps; an adversarial-peer mode building well-formed frames (chosen summary bits, Obs/Frag ids/observers/ages/lifetimes, acks for sent/unsent ids, conflicting fragments, changingfrom) from many addresses beyondMAX_PEERS; post-step checks (no panic, ≤mtu(), no addressed stored past lifetime, drop counts reconcile, signatures round-trip). A proptest twin with a bounded case count runs in the normal suite.Adversarial mode must also check the §10 properties: the peer table recovers after a flood from more than
MAX_PEERSaddresses; output per received summary is bounded and repeating a summary does not re-send; a NodeId never changes within a contact; the event queue drops the oldest; an ack is sent only for an observation that was stored (F1). -
Step 1: Write the harness — the twin first (it runs in CI); then the fuzz target wrapping it.
-
Step 2: Run —
cargo test -p tines-core(twin green); a shortcargo fuzz run node -- -max_total_time=60sanity run (full 24 CPU-hour budget is release-time, §"Done means"). -
Step 3: Commit —
git commit -m "test(core): fuzz harness and proptest twin"(committing the corpus too).
Task 13: Docs, compatibility policy, and the 1.0.0 release (§8, "Done means")
Files:
- Modify:
README.md(a "Stability" section with §8; switch theTrimPolicyexample toTrimPolicy::default()+ field assignment, since the struct is now#[non_exhaustive]),CHANGELOG.md(1.0.0 entry),AGENTS.md(spec list + trait extension rule + wire/local split),tines-core/src/lib.rs(#![deny(missing_docs)]+ document the remaining items),THREAT_MODEL.md(mark F6/F8/F9/F13 fixed with SHAs, and update F18 — Task 6 fixed its stale clock-marker tick),Cargo.toml+ dependent crates (tines-core→ 1.0.0, hosts depend on"1")
Interfaces:
-
Consumes: everything above.
-
Produces: README §8; CHANGELOG 1.0.0 listing every 0.3 break;
#![deny(missing_docs)]with zero missing items (recount — the spec's "26" predates the fleet fixes);THREAT_MODEL.mdmarks F6/F8/F9/F13 fixed (and updates F18);tines-coreat 1.0.0 withtines-store/tines-bt/tines-ros2/tinesdepending on"1". -
Step 1: Write the failing test —
cargo doc --workspace --no-depsunder#. -
Step 2: Implement — the docs; the release bump.
-
Step 3: Verify —
sh scripts/ci.shgreen;THREAT_MODEL.mdmarks F6/F8/F9/F13 fixed with commit SHAs. -
Step 4: Commit —
git commit -m "release(core): tines-core 1.0.0".Publishing is Kyle's step (it is outward-facing). The
cargo-semver-checksgate is not added here: before 1.0.0 is published the only baseline is 0.2.1, and the check fails on every intended break. It is a follow-up commit after publishing, turningcargo-semver-checksagainst the published 1.0.0 on inscripts/ci.sh.