Skip to content

refactor(bpa,bpv7): stream the ingress spool — validate the payload as it drains, one metadata write - #717

Open
ricktaylor wants to merge 55 commits into
mainfrom
refactor/ingress-spool
Open

ricktaylor wants to merge 55 commits into
mainfrom
refactor/ingress-spool

Conversation

@ricktaylor

Copy link
Copy Markdown
Owner

Stream the ingress spool: validate on the drain, store as received, write metadata once

Stacked PR. This branch sits on refactor/filters (#712), which sits on fix/bpa-dispatch-redelivery (#710); with main as the base the diff shows those legs too. This PR's own content is the 17 commits from feat(bpv7): add incremental payload-BIB verification to refactor(bpa): drop the rebuild-and-retry loop on DuplicateBundle.

What this leg does

Bundle reception becomes one streaming pipeline: every gate decision is taken on the resident header prefix, the payload then drains through a validating tail, and an admitted bundle is written to the metadata store exactly once. Nothing about a bundle is edited on input — what is stored is what arrived.

  • Incremental payload-BIB verification (bpv7). bpsec::bib::Operation::begin_verify returns a Verifier pre-fed with every header-resident IPPT part; the caller feeds payload data as it streams and settles with a constant-time tag compare. Verifiers are begun inside the keyed header pass — key material is resolved once per bundle and never crosses an await; only the Send digest states ride the drain. BREAKING: checks::verify_payload is removed — an oversized signed bundle needs no whole-buffer verification pass, which is the point.
  • TailReceiver, the validating drain. Payload CRC, bundle framing, and each deferred payload-BIB digest are fed per segment as the payload drains; finalize/verify_payload and the resident re-verification they implied are gone.
  • The Ingress chain runs at the pre-drain gate. A chain Drop skips the drain and the store entirely and reports like the sibling gate rejections. RFC 9171 §5.1.1 failure-drops and unrecognised-block removals are scheduled in metadata and applied per attempt at the output doors — received bytes are never rewritten in place, and a shared BCB whose failed co-target is dropped keeps its remaining coverage.
  • One metadata write. An admitted bundle is inserted directly at Dispatching, carrying the chain's classifier deltas; New becomes an in-memory marker that is never persisted, and restart treats a recovered Dispatching record as chain-complete. BREAKING: MetadataStorage::replace is removed with the last non-status rewrite of a stored record — after insert, only status moves.
  • Conforming, coalesced reject reporting. Every ingress reject with a recoverable bundle id emits the RFC 9171 §5.6/§5.10 pair as a single status report bundle (one §6.1.1 record asserting both reception and deletion, each gated on its own flag). §5.6 Step 4 is honoured: a block flagged report_on_failure triggers its "Block unsupported" report on the block flag alone. Drain failures report like any other parsing failure; truncated streams are refused, never reported.

Rides along

  • The add_peer mid-construction removal race (whole-codebase review Bump regex from 1.10.4 to 1.10.5 #14) is fixed and pinned: peers are constructed complete, published once, and the addition re-checks its address claim after construction.
  • The post-registration WaitingForService poll is spawned, not awaited inline, so a gRPC-bridge-shaped sink cannot deadlock registration against its own announcements (the Refactor/grpc api v1 #667 salvage, now pinned by test).
  • The rebuild-and-retry loop on DuplicateBundle at the originate door is gone.
  • The controller-owned queue assignment is recorded in docs/policy_subsystem_redesign.md.

Storage backends change shape (replace deleted); external implementors should read the BREAKING bullets in bpa/CHANGELOG.md and bpv7/CHANGELOG.md.

🤖 Generated with Claude Code

@ricktaylor
ricktaylor force-pushed the refactor/ingress-spool branch 5 times, most recently from fe5c5a6 to 0cf18bb Compare September 10, 2026 19:49
@ricktaylor
ricktaylor force-pushed the refactor/ingress-spool branch 2 times, most recently from c4e01f6 to bab1eef Compare September 29, 2026 16:35
ricktaylor and others added 22 commits September 30, 2026 13:54
`ExtensionEditor::insert` passed flags, CRC type and block bodies
through unchecked, so an edit could be accepted at call time and then
fail at `finish()` or produce bytes the parser rejects: a
`report_on_failure` flag on an administrative-record or null-source
bundle (RFC 9171 §4.2.3-4/-5), an unrecognised CRC type, or a Previous
Node / Bundle Age / Hop Count body that does not decode. Each is now
refused at call time as `Error::Invalid`, carrying the error the parser
raises for it (`InvalidFlags`, `InvalidCrc`, or the body's own decode
error); `replace` validates well-known bodies too.

The RFC rule gets one statement, `PrimaryBlock::forbids_report_on_failure`,
which the parser's block check now consults.

`Builder::build` emitted bundles its own parser rejects: `with_hop_count`
sets `report_on_failure`, so any null-source or admin-record bundle with
a hop limit was unparseable. `build()` now clears the flag on every
block of such a bundle, normalised like the fragment flag.

Found by the external review of #712 (CRIT-1, HIGH-2, MED-3): the BPA's
filter editor mirrors this one, and a Rewriter tripping the gap aborts
the node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
Classification gains registered annotation slots and the policy-epoch
stamp, written only through the engine's apply()/clear_classification()
paths. Embedders register a slot (stable name, typed value, size bound)
at construction and receive an unforgeable SlotHandle; values are
canonically-encoded CBOR at rest, name-keyed so a registration that
disappears across a restart reads as unset and clears at re-derivation.
Blob makes an opaque byte string a first-class slot value (bare byte
containers deliberately are not: the blanket [T] encode gives [u8]
array semantics), and slot_str/slot_bytes give zero-copy borrowed
reads, sound because canonical writes keep the stored payload
contiguous. Slot-free records serialize byte-identical to the
pre-slots shape. Per refactor_plan C2, BpaBuilder::annotation_slot is
scaffolding the FilterPack replaces before any release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The committed extension-API surface for the Phase 2 filter redesign:
three payload-free, byte-pure kinds behind one verdict, added additively
alongside the old ReadFilter/WriteFilter machinery (which the C3 engine
swap removes). Nothing consumes them yet.

- Verdict<T = ()> — one enum across all kinds: Continue(T) / Drop(reason),
  where T is the kind's contribution (a MetadataDelta for a Classifier,
  () otherwise), so the drop path and its reason code are identical
  everywhere.
- Verifier — read-only admission check; parallel; any hook.
- Classifier — sequential input annotator; contributes a MetadataDelta.
- Rewriter — sequential extension-block editor at the two output
  boundaries, distinguished by RewriteContext::{Egress { next_hop },
  Deliver}; next-hop context is Egress-only so it rides the variant, not
  the method signature.

Resolves filter-redesign open-question 4: Deliver hosts a Rewriter for
stripping transport-scoped extension blocks (network QoS, custody) before
a raw-Service delivery — the dual of the ingress Classifier that consumed
them. It edits extension blocks, never the payload, so it runs before the
payload BPSec decrypt and decrypts what it needs via the KeySource.

ScopedEditor is a placeholder; its operation set and the FilterPack
registration surface follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The read side of the filter invocation: the three kinds now take a single
`&BundleReader<'_>` instead of the `(&Bundle, &[u8], &dyn KeySource)`
triple.

BundleReader bundles a bundle, its resident source bytes, the decoded BCB
OperationSets, and the key source into one borrow, and exposes a curated
projection rather than the raw record:

- metadata() — the BPA-local metadata, via the record's own field privacy.
- primary() / block(n) — the primary block and per-block headers.
- block_data(n) — a block's plaintext bytes: raw when unencrypted,
  BCB-decrypted (via the OperationSets + KeySource) when covered. Same
  contract as bpsec::block_data; Ok(None) is the not-available path
  (absent / not resident / covered-with-no-key). There is no raw-ciphertext
  accessor: a content filter never wants it, and coverage is visible via
  block(n).bcb.
- extract::<T>(n) — decodes T from block_data's plaintext, so it works
  uniformly on plaintext and covered blocks.

The OperationSets are stack-local at the call site (decoded once by parse),
lent to the reader — not stashed on the bundle: a serde copy in metadata
would cost the same on reload as re-parsing the source CBOR that is already
stored. The engine constructs a reader per hook when the C3 swap lands;
BundleReader::new carries the until-then dead_code allow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The embedder's shipping unit for a filter pair: annotation slots and
per-hook filter registrations are declared on one FilterPack, and
BpaBuilder::add_filters splices packs into the per-hook chains in call
order. The pack name prefixes both the at-rest slot names
("<pack>.<slot>") and the diagnostic labels, making cross-pack slot
collisions unrepresentable; build() validates names (non-empty, no '.'),
merges and freezes the slot table, splices the four chains, and fixes
the node-wide payload peek P as the max declared across the input-hook
_with_peek registrations. This replaces and deletes C1's
BpaBuilder::annotation_slot scaffolding and its (Self, handle) tuple.

Both filter modules split into a pub surface and a pub(crate) machinery
child, so internals carry plain `pub` capped by the module instead of
per-item pub(crate) noise:

- filter/slots: the embedder surface (Error, SlotValue, Blob,
  SlotHandle, MetadataDelta) stays at slots; SlotWrite, SlotMap,
  PolicyEpoch, SlotRegistry and SlotTable move to slots::state.
- filter/pack: FilterPack and its Error stay at pack; the frozen
  chains (the three entry types, InputChain, OutputChain, FilterChains
  and its freeze) live in pack::chains.

Descendant-module privacy does the rest: state.rs constructs SlotHandle
and chains.rs consumes FilterPack's fields directly, with no pub(crate)
accessors. The frozen chains carry the until-C3 dead_code allows; the
old machinery still drives execution, and the ScopedEditor operation
surface lands with the engine swap (noted on the plan's C3 row).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The draft's header still presented the deleted ReadFilter/WriteFilter
API as current, refactor_plan.md's C5 row already recorded it retired
into filter_subsystem_design.md, and its two post-fold refinements (the
Deliver-host resolution and the next_hop-rides-the-record shape) were
verified present in the design doc before deletion (review F-14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The [Unreleased] section carried duplicate Added/Changed/Fixed heading
pairs and four statements contradicting the shipped API: a classify
label parameter on a method that no longer exists (FlowController::
queue_for is label-free), a `with_cla` builder method that was always
`cla`, fluent setters on a deleted type, and a migration cited as 0006
when the file is 0007_forward_pending_next_hop (in both changelogs).
The same false classify claim is corrected in the design doc's one
parenthetical (review F-10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ully

Four doc sites said the Deliver chain applies to the raw-Service path
only; the code runs it for every local delivery. Ruled code-direction:
the docs now state both halves — a Drop verdict applies to Service and
Application deliveries alike, while only the raw-Service path observes
the rewritten blocks (the Application path receives the decrypted
payload alone, so a Rewriter's edits are invisible there). The rustdoc
site is clarified rather than retracted: its visibility claim was
literally true but misled about hook scope (review F-09).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-hook table an embedder can act on — transplanted from the
2026-08-28 prior-art patch and re-verified against this branch's actual
dispatcher arms (its Ingress row reflects the post-store hook this
branch runs; the pre-drain gate move updates it when that lands). The
Verdict::Drop rustdoc gains the semantics nothing on the public surface
previously stated: Some(reason) reports per the bundle's request flags,
Originate never reports (the reason returns via services::Error::
Dropped), and None is silent even when the flags request reporting
(reviews OF-12 + F-12).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The public doc promised parallel execution; the engine runs every chain
synchronously inline (the decoded BCB OperationSets are not Send). The
rustdoc now states the truth — inline invocation with order-independence
as the contract (no ordering, no cross-talk, no depending on another
filter having run) — and the engine doc's crate-private correction trims
to the !Send rationale (review F-33).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both dead-code allowances pointed at "the engine swap (C3)" — which
landed on this branch without wiring either function — and the slots
module doc claimed clearing/pruning in the present tense. The comments
now name the real consumer (the Phase 3 restart re-admission path), and
the module doc is future-scoped: until Phase 3 a recovered bundle keeps
its stored slot values, the fact an embedder designing against this doc
needs (review F-11; the metadata.rs placeholder lines are corrected
upstack by the route-early rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three sites claimed unforgeability ("no other code can name the slot",
cross-pack collisions "unrepresentable", "privacy by unforgeability");
a throwaway unregistered pack mints a working handle for any pack's
slot, so the claims are corrected to what the scheme provides: other
registered code cannot obtain a handle (duplicate registration fails
build()), distinctly-named packs cannot collide, and the scheme is
cooperative, not cryptographic. No machinery added — registry-bound
handles fail the simplification test for an in-process cooperative
threat model (review F-21).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The FilterPack peek paragraph and all four _with_peek methods now state
that the declaration is recorded but has no effect until the Phase 3
streaming ingress gate lands — hooks run with the full bundle resident.
Previously an embedder's declared peek silently did nothing today and
would silently shrink their filter's input at the Phase 3 release
boundary; the methods stay, per the ruling keeping the early
licence-boundary API (review F-24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… compiler

Both remaining "compile-time property" claims (editor.rs module doc and
the design doc; the third copy died with the redesign doc) now state
the real mechanism: out-of-scope targets are refused with a typed error
at call time, so payload-purity is enforced by the handle rather than
by code review (review F-26).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The forward path re-derived the peer identity it already had by
destructuring the channel's target status behind a hot-path
unreachable!, through an accessor with exactly one caller. Peer now
carries its id, forward composes ForwardPending { peer, queue, next_hop }
directly (the out-of-range clamp clamps the index), and
Sender::queue_status is deleted; send_to's same_queue debug_assert
still covers the queue-identity contract. A future mis-keyed queue is
no longer a forwarding panic (review F-13; the fuller QueueKey redesign
rides the queue tranche).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…non-resident blocks

A present block whose extents fall outside the resident bytes surfaced
as Err(Altered), diverging from the method's own rustdoc and from
Block::extract. A residency pre-check (compared in u64 before any
slicing, per the 32-bit rule) now returns the documented Ok(None);
extract inherits it. Unreachable until the Phase 3 peek seat delivers
truncated buffers, but the contract is public today; the inline test
pins both directions (review F-20).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fold

The eight near-identical FilterPack registration bodies reduce to
constructor-then-push through a generic Pending<E> and three private
labelled-entry constructors — the only places the entry literals are
spelled — and chains.rs's four hand-copied max_peek fold loops become
one generic drain_pending, so a future hook that forgets the fold is a
missing call rather than a silently dropped peek declaration. Public
API, chain order, and label format unchanged (review F-31; the
closure-pair alternative was verified E0499-uncompilable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…description deleted

Three additive adoptions from the prior-art comparison: BundleReader
gains creation_time/expiry/has_expired (delegating to the record's
clockless-source-aware arithmetic, so an embedder Verifier stops
re-deriving it by hand); the embedder surface flattens to
filter::{ScopedEditor, FilterPack, MetadataDelta, SlotHandle}; and the
bpa.filter.registered describe block is deleted as ruled — chains
freeze at build(), so the gauge was a compile-time constant described
but never emitted (reviews OF-15, OF-17, OF-13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… leftovers

The crate doc stops claiming no_std compatibility the default build
does not have (Tokio, and therefore std, is required); status_label's
ten qualified BundleStatus arms become bare names with the import; the
slot registry's merge doc loses the prototype's "Absorbs" leftover; and
the pack rustdoc spells out "the node-wide payload peek" instead of the
bare design-doc token P (review OF-21 a/c/d/e; the RoutingSink rename
is extracted to its own PR, and F-29's originate half landed on the
acceptance branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refactor/filters deep review routed its out-of-scope findings here
rather than onto the branch: the dispatcher-hardening group (deferred
dispatcher start, claimed resolutions), the claim-on-dequeue fold into
the hardening design's lifecycle::transitions edges, the ingress
Final-segment commit points, the undifferentiated forward-error park,
pre-store ingress filtering, slot-write validation, and the smaller
mechanical items — each entry naming its owner (queue tranche,
hardening legs, Phase 3, release boundary) and, where the 2026-08-28
hardening patch already implements the shape, naming it as the
starting point. The OF-03 queue-key entry lands beside its R3-4
sibling in the transfer-outcome section; the ScopedEditor
concept-revisit note is recorded to survive the review independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ricktaylor and others added 28 commits October 1, 2026 10:24
A Rewriter's edit is materialised and re-parsed after it returns, and a
failure there aborts the process under the fail-stop ruling. But
`ScopedEditor::insert` passed flags, CRC type and block bodies through
unchecked, so a correct Rewriter could abort the node on an
attacker-chosen bundle: a `report_on_failure` flag — which bpv7's own
`Builder::with_hop_count` sets — on any admin-record transit bundle at
Egress, or on a null-source bundle at Deliver (RFC 9171 §4.2.3-4/-5).

`insert` and `replace` now refuse at call time, as
`Error::Invalid(hardy_bpv7::Error)` carrying the parser's own error,
everything the parser would reject: the forbidden flag (`InvalidFlags`),
an unrecognised CRC type (`InvalidCrc`), and an undecodable Previous
Node, Bundle Age or Hop Count body. `insert` canonicalizes its block
type before the reserved-type check. A refusal is the Rewriter's
no-match path; the fail-stop stays for genuine invariant breaks.

The engine's two fail-stop sites format their panic message only on
failure, and the docs say what the panic does: it aborts the process.
New filter_dispositions integration tests pin the Egress and Deliver
cases through the real pipeline.

Found by the external review of #712 (CRIT-1, HIGH-1, HIGH-2, MED-3,
MED-14, L-7). bpv7's `ExtensionEditor` gets the same refusals in
fix/bpv7-extension-editor-accept-set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The disposition table in the filter module docs had no pipeline test
for any cell. filter_dispositions now drives each live Egress and
Deliver cell through a running BPA:

- Drop(Some(reason)): one deletion report carrying the filter's reason,
  nothing transmitted or delivered, the record tombstoned.
- Drop(None): silent even when the bundle requests reports. "No report"
  is proven by counting bundle-storage saves after the shutdown barrier:
  the node saves every report it originates before queueing it, while
  a report still in flight at shutdown never reaches the CLA.
- Stored bytes that do not decode: Deliver parks WaitingForService and
  Egress parks Waiting (at Egress the fixed per-hop rewrite decodes the
  stored bytes before the chain, so its park is the one reached). Both
  wait on the park swap itself, since the post-registration poll may
  re-dispatch the parked bundle afterwards.

From the external review of #712 (HIGH-3, L-26).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…ors the truth

- postgres: drop the stale copy of the `next_hop` column entry, whose
  claim that pre-column `forward_pending` rows no longer decode
  contradicts the migration beside it (0007 re-parks them to
  `waiting`).
- sqlite: the same false claim is cut from the forwarding-queue entry;
  `03_forward_pending_repark` re-parks those rows.
- bpa: drop the byte-identical serialization claim (records from 0.2.0
  do not load at all, per the BREAKING format entry); describe filters
  as trusted code that reads block plaintext, the payload's included,
  with only a Rewriter's edits scoped to extension blocks; say the
  `_with_peek` declarations are recorded but not yet consumed; and map
  each old filter-API name to its replacement.

From the external review of #712 (MED-5, MED-7, MED-12, L-17, L-18).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
- Filters are trusted code with plaintext read access (block bodies
  BCB-decrypted on request, the payload's included when resident); only
  a Rewriter's edits are scoped, to extension blocks. The
  "payload-free" / "payload-independent" claims are gone.
- The Rewriter contract states that removing or replacing a per-hop
  block overrides the BPA's RFC 9171 §4.4 handling, and that an
  accepted edit which fails to materialise aborts the process.
- The engine and design doc state the peak-memory cost of an editing
  Rewriter (one wire-form copy per editing link, per attempt).
- Slots are persisted unencrypted and must not carry BPSec plaintext or
  secrets; `MetadataDelta::set` keeps the last write that fits.
- `services::Error::Dropped` names the origination gate too.
- Doc corrections: which bpv7 editor call silently accepts a missing
  block, the real reason `Blob` exists, and today's per-fragment chain
  versus the draft reassembly redesign. The user docs no longer call
  the RFC 9171 checks and the IPN legacy re-encode "filters", and bibe
  says the egress BPSec seam is not yet built.
- Test plans: unit-plan rows for the engine, editor and slot tests;
  component-plan Suite D (the filter dispositions, and the INT-BPA-06
  extent-consistency test pipeline.rs already carried); the coverage
  report drops the deleted `filters/` modules.
- The editor module's intra-doc links resolve.

From the external review of #712 (MED-4, MED-5, MED-6, MED-9, MED-10,
LOW-11, L-15, L-17, L-19, L-21).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
- Slot values never print: `SlotMap` and `SlotWrite` show names and
  value lengths in their `Debug`, not the embedder's bytes.
- The ingress gate logs reserved primary-block flag bits again — the
  only operator-visible sign of a peer setting them, lost with the
  validity filter.
- Comments that described the deleted post-store validity filter name
  the expiry checkpoints that replaced it.
- `bpa.filter.modified` is described as what it counts: one per
  editing link.
- `max_cached_bundle_size` gets back the rustdoc that had been spliced
  onto `max_bundle_size`.
- Imports follow the house rules: grouped blocks, bare names at use
  sites (the CBOR error aliased beside bpv7's), and `RewriteContext`
  derives `Debug`.

From the external review of #712 (MED-10, L-2, L-3, L-4, L-13, L-25).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…switches

No test parsed `ipn-legacy-nodes` (whose type changed from a newtype to
a plain `Vec<EidPattern>`) or `rfc9171-validity`: the example-config
test only proves the file loads. Pin both keys to their typed values.

From the external review of #712 (L-28).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The per-hop rewrite and the scoped editor's guard each restated RFC
9171 §4.2.3-4/-5 (no block of an administrative-record or null-source
bundle may request a report on failure). Both now ask the primary block,
through bpv7's `PrimaryBlock::forbids_report_on_failure` — the predicate
the parser enforces — so the rule has one statement.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…erify

The deferred block-1 verification the header pass hands over in
`VerifyFacts::deferred_bibs` can now be settled without a resident
payload: `checks::begin_payload_verification` constructs one
`bib::Verifier` per deferred op-set from the headers-only buffer, the
caller feeds the payload's block-type-specific data through `update()`
as it streams past, and `finish()` settles the tag —
`IntegrityCheckFailed` on mismatch, checked in constant time. A
BCB-covered payload and `NoKey` remain the same soft skips as before,
and each verifier carries its BIB's block number so a failure
attributes to the block that made the claim.

Under the RFC 9173 context the verifier shares one set of primitives
with the all-in-one path: `ippt_prefix` absorbs the header-resident
IPPT parts (scope flags, canonical primary, target and security
headers), `MacInner` holds the per-variant HMAC state, and each path
then emits the target's byte-string head + body its own way — the
resident path from the payload's own length (`absorb_resident_target`),
the streaming path from the parsed extent, feeding the body through
`update()`. That divergence is necessary: a primary-block target is
canonicalized, and the editor's in-flight template carries a
placeholder `Block.data` range, so the head cannot always come from the
extent. `Operation::verify` and `Operation::sign` are now thin
compositions of these primitives, replacing the duplicated
`calculate_hmac`/`verify_inner`.

`checks::verify_payload` becomes a thin resident driver over
`begin_payload_verification` (feed the whole payload, finish), so the
iterate-and-skip logic lives once; its now-unused `decrypted_data` /
`to_update` params are dropped, updating the one bpa caller. The
verifier deliberately owns its state, including a copy of the resolved
key inside the MAC, so it can cross the drain's await points and a task
boundary — a recorded exception to the header pass's
no-key-material-across-awaits rule.

`verify_payload` itself is deleted when the ingress pipeline stops
calling it.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
TailReceiver wraps the CLA segment stream between parse_headers and the
spool: as each segment flows through, it feeds the bpv7 PayloadTail
(payload CRC, block/outer break, anti-smuggling) and the block-type-
specific-data prefix of the segment to each deferred payload-BIB
verifier, then yields the segment onward unchanged. Downstream — the
interim in-memory spool now, BundleStorage::store after the storage
tranche — sees a plain Receiver<Segment>; validation is invisible to it,
surfacing only as a failed pull, with the categorised verdict (Truncated
/ Invalid / IntegrityFailed) read from finish() once drained.

The BIB verifiers need body-only bytes, so PayloadTail gains a
body_remaining() accessor: the body is always consumed from the front of
a push, so the before/after delta slices each segment's body prefix out
from its CRC/break trailer without changing push()'s signature.

Not yet wired into the ingress pipeline (a later commit does that), so
the type carries #[allow(dead_code)]; it is exercised by its own tests —
pass-through fidelity, payload-CRC failure, truncation, trailing data,
a deferred BIB verifying over the streamed body and failing on tamper,
and a Send bound.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The ingress gate no longer rewrites the bundle to drop RFC 9172 §5.1.1
failure-drops and honoured delete_block_on_failure unknowns: it schedules
them. finalize_with_provider verifies the deferred payload BIBs and hands
back the removal set; the bundle is stored exactly as received (no editing
on input), and the removals ride BundleMetadata::to_remove. Each output
door applies them per attempt through its editor — the egress rewrite head
and a deliver-side strip on the raw-Service path — so the BPSec cascade for
a BCB-covered BIB whose target list shrinks runs there, and the transmitted
/ delivered wire form no longer carries the block while the stored bundle
keeps it. Prerequisite for the streaming spool, which cannot rewrite a
whole buffer at ingress.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…ite (P1)

Dissolve the process_received_bundle/ingress_bundle split into a single
flow. The Ingress chain now runs in memory on the resident bundle, and
only the finished, classified record is persisted — a single
insert_metadata directly at Dispatching. This replaces the old
insert-New-then-update-Dispatching pair (P1) and lets the now-unused
Store::update_metadata wrapper go.

New becomes a purely in-memory "under construction" marker that never
reaches storage, so no chain-incomplete record can ever be persisted: a
crash mid-chain leaves orphaned data that restart's orphan path re-runs
from scratch. dispatch_bundle keeps its strict Dispatching|DispatchPending
contract; the send's swap to DispatchPending is the queue commit, and a
crash between the insert and the send recovers via the Dispatching restart
arm. Restart's New arm is removed — with no v0.2.0 upgrade path a persisted
New cannot occur.

Chain Drop/Err are now pre-insert: they report deletion and delete only
CLA-saved data directly, rather than via drop_bundle, which would try to
tombstone a bundle that was never admitted. Received::Bundle collapses to
a unit Dispatched, and reassemble's Box::pin recursion break is no longer
needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
The single-write ingress model (P1) left MetadataStorage::replace with no
production caller: a record's non-status metadata is now fixed at insert,
and thereafter only its status moves (update_status / swap_status). Remove
the trait method, its metadata-mem / sqlite / postgres impls, and the two
test mocks.

The in-memory store's `apply` helper was replace's only caller, so it goes
too. The cross-backend conformance test retargets to update_status
(meta_03_update_replace -> meta_03_update_status), and the sqlite
poll_waiting and metadata-mem tombstone tests now drive status changes the
same way. Document the invariant on update_status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
Move the Ingress chain to its designed position: it now runs on the
resident header prefix before the payload is drained, not post-store. A
filter that reads the not-yet-streamed payload gets BundleReader's
existing not-resident None, so no peek scaffolding is needed;
finalize_with_provider and the in-line drain stay for the streaming leg.

parse_headers now returns the header-region BCB OperationSets so the gate
can thread them into the chain without re-parsing. The engine splits
run_input into a self-parsing entry (originate) and a shared
run_input_decoded taking pre-decoded bytes + BCB ops, and run_ingress
gains a bcbs parameter plus a has_ingress guard.

The gate runs the chain on a throwaway clone of the wire bundle so hv
stays whole for finalize, salvaging the classifier deltas back into the
real metadata; the clone dissolves in the streaming leg. A chain drop at
the gate is pre-store — nothing was spooled — and reports
reception-then-deletion like the sibling lifetime/rfc9171 gates.

While here, correct a stale claim that BCB OperationSets are !Send (they
are entirely Send; the chains run inline because the reader borrows local
data, which cannot cross a spawn boundary).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…rop finalize/verify_payload

The oversized-payload drain now runs through the I2 TailReceiver inline over
the borrowed CLA stream: it feeds the payload CRC / block+outer breaks and
each deferred payload-BIB digest as the bytes stream past, accumulating the
whole bundle bounded by max_size. A resident bundle (no payload tail) was
already fully validated by the header pass — the payload was present, so no
BIB was deferred — so it needs no further work.

TailReceiver borrows its inner receiver (its only use is this inline drain;
the spawned-spool phase will revisit ownership). HeaderVerify is destructured
once at the gate, moving the extension fields into metadata now that no
post-drain finalize needs it whole.

Deletes the interim resident-buffer paths this replaces: bpa's drain_payload
and parse::finalize_with_provider, and bpv7's checks::verify_payload
(BREAKING — the streaming ingress drain uses begin_payload_verification
directly). Coverage moves to the TailReceiver streaming tests and bpv7's
incremental-verifier tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…ader pass

The key source is resolved once per bundle, inside verify_headers' sync
scope: HeaderVerify carries the begun Send verifiers (deferred_verifiers)
in place of the deferred op-set map, TailReceiver takes them directly,
and the ingress drain's key re-request block dissolves — a begin failure
surfaces as an ordinary HeaderFailure::Invalid at the header pass.
bib::Verifier documents the key-handling contract for future security
contexts: minimum derived state crosses the drain; raw key material
stays in sync scopes (resolve at begin, or extend finish() to
re-resolve at settle).

Also: historical-commentary comments swept (including a stale finalize
reference at the ingress gate), and every workspace rustdoc link warning
fixed — the /// summaries on filter's pub mod declarations made rustdoc
resolve the child modules' //! links at the declaration scope, so they
go; cargo doc --workspace --all-features is now warning-free.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…anche ledgers

The A-02/03/04 and P1 TODO ledgers close (their subjects are deleted or
implemented), the drain's no-report deviation joins the report-
conformance ledger, and two key-zeroization gaps (unwiped Key storage,
hmac's zeroize feature off) are ledgered in bpv7. The streaming, filter,
and refactor-plan docs go present-tense — the Ingress hook at its
designed position, the RAM accumulator named as the one storage-tranche
seam — and gain the concurrent drain + routing (routing-at-commit) row.
CHANGELOGs record the pipeline unification, store-as-received,
MetadataStorage::replace removal, and the BREAKING removal of
checks::verify_payload.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
TailFailure carries its status-report reason (reason_code): Invalid maps
through the shared keyed-failure mapping, IntegrityFailed is
FailedSecurityOperation, and Truncated is None — a refused transfer is
never reported. The ingress drain raises the RFC 9171 §5.6/§5.10
reception-then-deletion pair on any reportable failure, matching the
pre-drain gates; parse_headers' single-report shape is now the one
remaining deviation in the report-conformance ledger.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
report_bundle_reception takes the parsed primary parts plus an optional
deletion reason: one §6.1.1 admin record asserts reception (receipt-flag
gated, carrying the §5.6 Step-2/4 reception reason) and deletion
(delete-flag gated, its reason taking the record's one reason slot) —
the coalescing ION performs, so every ingress reject emits one report
bundle where two were sent before, and a rejected bundle never becomes
a metadata record.

The keyed header pass now reports the same shape, closing the last
ingress invalid-bundle reporting deviation, and the post-verification
reject sites thread the Step-4 report_reason through instead of
discarding it, so a reception-only reporter still learns the
block-unsupported facts the header verify established.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
An unprocessable extension block flagged report_on_failure forces its
"Block unsupported" reception report on the block flag alone — the
bundle-level receipt flag no longer gates a demand the block itself
made. The reception facts ride the new ReceptionReport enum (Requested /
FailureDropped / Demanded), which keeps the §5.1.1 failure-drop at its
RFC 9172 requested-MAY gating and makes the nonsense states
unrepresentable. Expired-on-arrival is now the only remaining §5.6
deviation, and it is the deliberate, documented one.

Report construction is gated up front (reportable): with reporting
disabled — the default — or a null report-to endpoint (reachable only
via the block-flag trigger; the §4.2.4 admin/anonymous combinations are
parse-rejected), no report is encoded and the status_report.sent
counters no longer tick for reports that were never sent.

Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>
…vage ledger

The delivery_queue regression parks the WaitingForService poll inside a
delegating metadata-storage wrapper: registration must return while the
poll is still parked, and the parked bundle still arrives once it runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iew #14

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CreationTimestamp::now issues process-monotonic (time, sequence) pairs
(fix/creation-timestamp-uniqueness, PR #716), so a freshly built bundle's
id is unique by construction and the local_dispatch rebuild-and-retry
loop no longer has a case: DuplicateBundle can only mean a collision with
a pre-restart bundle (a wall clock that stepped backwards across a
restart, per RFC 9171 §4.2.7). Build once and surface DuplicateBundle to
the caller, who may resend; the store's atomic insert refusal remains the
backstop for the restart case.

Seated on this leg with the status-report generation rework; the later
originate-doors leg already assumed this shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design-doc statements this PR made stale: the persisted New checkpoint
and ingress_bundle() (the single insert is the first persisted status),
MetadataStorage::replace (sqlite batch-insert and postgres docs now use
swap_status), the Ingress hook running on the fully stored bundle (it
runs at the pre-drain gate, in memory), ingress block removals (deferred
to the output doors through to_remove), finalize_with_provider and the
fixed finalize step, the payload peek (recorded but not yet wired), and
deferred payload-BIB checks (the drain feeds the deferred verifiers, so
the payload-BIB tee has landed; the hot-forward tee remains).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MetadataStorage::replace left the storage harness in this PR: META-03 is
a compare-and-swap status update and META-17 is gone, so the harness
plan, the sqlite and postgres test plans and coverage reports count
META-01..16. The FilterPack peek docs said hooks run with the full
bundle resident until the streaming ingress gate lands; the gate has
landed, the Ingress chain runs on the resident header prefix, and the
peek is recorded but not yet wired.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
failed_streamed_forward_does_not_retry_inline flaked under load: the
registrations in its setup are routing changes that wake the Waiting
poller, and a poll still scanning when the failed bundle parked
re-attempted it for a change that predates it. Nothing in the test
ordered the bundle's arrival in Waiting after those polls.

The poll task now reads a request count before each poll and skips a
wakeup whose requests an earlier poll already covered: Notify stores a
permit for a request landing between a poll's wakeup and its read, and
that permit woke a second, redundant scan. Every wake site goes through
Rib::request_poll, which counts the request before notifying.

The test moves into bpa's unit tests, where a #[cfg(test)]
Rib::poll_waiting_idle waits for a poll covering every request raised
so far before the bundle is originated. Without the redundant-scan
skip that barrier is inexact: the stored permit starts one more poll
after it releases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The Ingress row of the failure and Drop contract describes the gate:
  a Drop is disposed of before anything is stored, with one reception
  + deletion report per the bundle's flags (a silent Drop still sends a
  requested reception report); the chain decodes nothing itself, so it
  has no failure of its own.
- The CHANGELOG says the RFC 9171 checks screen arrivals only: nothing
  stored is re-checked, or re-run through the Ingress chain, on
  restart.
- `max_bundle_size` is described as the in-memory accumulation bound on
  every ingress door; the unconsumed payload peek no longer claims a
  consumer.
- The design doc gives the real reasons: no filter kind needs the
  payload to decide (a registered filter may still read a resident
  one), and an invocation cannot migrate across tasks because the
  reader borrows the caller's state.
- The editor module's `Error` link names the enum.

From the external review of #712 (L-5, L-6, L-11, L-25, MED-5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Rick Taylor <rtaylor@aalyria.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant