The store opens before trusted time. Observations already in the log are quarantined (not summarised, offered, believed or age-trimmed; addressed ones report released) until LogStore::set_wall_clock runs the downtime fold. Trusted time comes from the robot's RTC/GPS, else a peer's observed_at + age_ms (smallest age wins), else the robot's own fail-safe. Supersedes "don't open the store until trusted time". Plan: Task 10 rewritten (quarantine, set_wall_clock, FoldReport, derived marker wall time); new Task 10b (host trusted-time state machine, time from peers, observed_at). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
40 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 twice on 2026-10-20: the store opens before trusted time (LogStore::open(path, now), unchanged), with everything already in the log quarantined untilLogStore::set_wall_clock(wall_now: u64, now)runs the downtime fold. Trusted time comes from the robot's own RTC/GPS, else from a peer's observation (observed_at + age_ms, smallestage_mswins), else the robot's own fail-safe (outside tines). The marker's wall time is derived (wall_at_set + (now − tick_at_set)). This supersedes "open only once trusted time exists". 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. - Held data must not leak before the fold, and unknown downtime must take the safe path.
- Before
set_wall_clock, a quarantined observation must not appear in a summary, a custody offer or a belief, and a quarantined addressed duplicate must not be acked. - When the downtime is unknown (no marker, a marker without wall time, or a wall clock that went backwards), held addressed messages expire (a directive that may have outlived
lifetime_msis never delivered), and held broadcasts are floored atunknown_downtime_age_ms. Neither is delivered as-is. - → pinned in Task 10.
- Before
- 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 + quarantine +set_wall_clockdowntime 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 quarantine, wall-clock marker and downtime fold (§9, decision G as amended)
Files:
- Modify:
tines-store/src/log.rs(quarantine of held observations;set_wall_clock; wall-clock marker with derived wall time; downtime fold; addressed expiry; broadcast floor; fold report) - Modify:
tines-store/tests/persistence.rs(quarantine and downtime tests)
Interfaces:
-
Consumes:
Observationwithobserved_at(Task 3),Store::flush(now)(Task 6), theformat_versionbump (Task 3). -
Produces:
LogStore::open(path: &Path, now: Tick)keeps its signature. Every observation already in the log is quarantined.- While quarantined, an observation is:
- left out of
for_each_broadcast_id,for_each_addressed_idandfor_each_topic; - reported
truebyis_releasedif addressed; - not trimmed on its age;
- still reported by
contains, so dedupe works.
- left out of
LogStore::set_wall_clock(&mut self, wall_now: u64, now: Tick) -> FoldReport. It runs once (a second call is a no-op), folds the downtime, lifts the quarantine, and starts wall time for markers.FoldReport { downtime_ms: Option<u64>, expired_addressed: usize, floored_broadcasts: usize }. It's also printed once on stderr and readable afterwards, likeskipped_records.Record::Clockgainswall: Option<u64>. Afterset_wall_clockit iswall_at_set + (now − tick_at_set); before,None.- A
LogStoreoptionunknown_downtime_age_ms.
-
Downtime known: the last marker has a wall time, and
wall_now≥ it. Every quarantined observation's age gains the wall time elapsed since that marker, minus the ticks already counted since this open (saturating). -
Downtime unknown: no marker, a marker without wall time, or a marker ahead of
wall_now. Quarantined addressed observations get an age pastlifetime_ms, and quarantined broadcasts getmax(age, unknown_downtime_age_ms). -
Step 1: Write the failing tests
held_is_quarantined_until_wall_clock: reopen a log holding a broadcast and an addressed observation. Beforeset_wall_clock, neither is visited byfor_each_broadcast_id,for_each_addressed_idorfor_each_topic; the addressed one isis_released; both arecontains. After it, they're all visible.quarantined_addressed_duplicate_not_acked: an exchange-level test withLogStore(or intines-simthrough a quarantining wrapper). A peer offers a duplicate of a quarantined addressed observation, and noAckcomes back.new_inserts_not_quarantined: an observation inserted afteropenis visible at once and isn't changed by the fold.downtime_known_advances_ages: marker at wall 1000; reopen;set_wall_clock(2000, …)adds 1000 to every held age (saturating), less any ticks already counted since open.downtime_unknown_expires_addressed(no marker) andmarker_without_wall_is_unknown: held addressed observations are never delivered, and the report counts them.downtime_unknown_floors_broadcasts: a held broadcast's age is raised tounknown_downtime_age_ms.clock_went_backwards_is_unknown: marker wall time ahead ofwall_now.marker_wall_time_is_derived:set_wall_clock(W, T), thenflush(T+5000)writes a marker carrying W+5000. A flush beforeset_wall_clockwriteswall: None.set_wall_clock_runs_once: a second call changes nothing.fold_reported_once: theFoldReportis printed once, and readable afterwards.
-
Step 2: Run, expect failure —
cargo test -p tines-store. -
Step 3: Implement — per §9 as amended. The quarantine is a per-entry flag set at load and cleared by the fold; it isn't persisted. Whether quarantined entries count towards
trim'smax_countis the builder's call, with one rule: untrusted ages must never cause fresher data to be trimmed in their place. -
Step 4: Run, expect pass —
cargo test -p tines-store, thensh scripts/ci.sh. -
Step 5: Commit —
git commit -m "feat(store): quarantine held observations until a wall-clock downtime fold".
Task 10b: Hosts — trusted time, time from peers, observed_at (§9, decision G as amended)
Files:
- Modify:
tines/src/clock.rs(HostClockgains trusted wall time),tines/src/main.rs,tines/src/ipc.rs - Modify:
tines-ros2/src/node.rs
Interfaces:
-
Consumes:
LogStore::set_wall_clock(Task 10). -
Produces, in both hosts:
- Open at once.
LogStore::open(path, now)at startup, as today. The robot runs tines immediately. - Trusted time, in this order:
- A local source. A config flag (
trusted_clock: "system") says the system clock is trusted, meaning an RTC or a GPS/NTP-disciplined clock. This is config, not detection, and the docs say so. - Otherwise, a peer. After each
poll, scan the observations stored since this open for the one carryingobserved_atwith the smallestage_ms. The estimate isobserved_at + age_ms + (now − tick it was stored), which is never later than the true time, because age is a lower bound. Quarantined data is invisible to the scan, so only this boot's observations count. - Otherwise, stay quarantined. The robot's own fail-safe is outside tines: log once that tines is waiting for trusted time, and keep running.
- A local source. A config flag (
- The first trusted time calls
set_wall_clock(wall, now)once and anchorsHostClock's wall time: wall at set + ticks since. - From then on, every observation the host makes carries
observed_at = Some(HostClock wall ms). Before trusted time it staysNone(R4).
- Open at once.
-
Step 1: Write the failing tests (in
tines, over the existing in-memory transport)observe_has_observed_at_after_trusted_time: withtrusted_clockset, an IPCObserveproduces an observation withobserved_at = Some(_).observe_has_no_observed_at_before_trusted_time: without it,None.time_from_peer: instance A has trusted time and observes; instance B has none and a log holding a quarantined observation. After an exchange, B'sset_wall_clockhas run with an estimate within one poll interval of A's wall time (A'sobserved_at + age_ms), and B's held observation is visible.smallest_age_wins: given two received observations withobserved_at, the estimate comes from the one with the smallerage_ms.
-
Step 2: Run, expect failure.
-
Step 3: Implement —
HostClockwall anchor; the trusted-time state machine (waiting → set) in the service's poll loop and inTinesNode::poll; thetrusted_clockconfig field;observed_aton every observe once trusted.unknown_downtime_age_msis a host config field passed toLogStore. It must be below the trimmax_age_ms, but the trim defaults are still empty (host hardening), so note in the host docs that this check waits for host hardening. -
Step 4: Run, expect pass —
cargo test -p tines,sh scripts/ci.sh, thendocker build -f Dockerfile.ros2 -t tines-ros2-check .. -
Step 5: Commit —
git commit -m "feat(hosts): trusted wall time from the system clock or a peer; set observed_at".
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; update F18 — Task 6 fixed its stale clock-marker tick; add a finding for peer-derived time: unauthenticated until 1.1, a forged time ahead only expires early, behind takes the unknown path, but the robot's ownobserved_atstamps are wrong until a local source corrects them),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.