Skip to content

feat(protected): NonEmpty::with and From<integer> for NonEmpty - #314

Merged
coderdan merged 8 commits into
mainfrom
non-empty-with
Sep 7, 2026
Merged

feat(protected): NonEmpty::with and From<integer> for NonEmpty#314
coderdan merged 8 commits into
mainfrom
non-empty-with

Conversation

@coderdan

@coderdan coderdan commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #313.

What

  • NonEmpty::with<U: MaybeEmpty>(tail) -> NonEmpty<(T, U)>: pairs a proven head with a tail and keeps the proof, checking nothing. A pair is empty only when both halves are, so a non-empty head makes the pair non-empty whatever the tail, () or "" included. The tail must be MaybeEmpty for the same reason NonEmpty::new requires it, so an already-encoded Aad/PrfContext or a nested NonEmpty is rejected (pinned by a compile_fail doctest). The pair encodes exactly as the bare (T, U) would; a test in vitaminc-aead pins nonempty!("users/email").with(42u64) against ("users/email", 42u64).
  • From<$int> for NonEmpty<$int> for the ten integer types never_empty! already covers: NonEmpty::from(7u64) / 7u64.into() with no check.
  • Breaking: IsEmpty is renamed MaybeEmpty. The old name read as a marker ("values of this type are empty") when it is a capability ("this value can be asked whether it is empty"); integers implement it and are never empty. The method is_empty is unchanged.
  • Docs: the struct-level "two ways to build one / no third path" narrative is replaced with one rule (checked once where the type cannot prove non-emptiness, converted freely where it can) listing all four routes. with documents that chaining nests left (a.with(b).with(c) is ((a, b), c)), that it only extends rightwards, and three things the tail does not inherit: it is unchecked, an empty tail still frames as a pair, and integer tails encode untagged in AEAD.

No encoding changes.

Why

A row-level consumer (stack-encrypt) derives each field under a fixed context and lets the caller extend it with a record id, so a field is bound to its row as well as its column. Without with the only way to build that pair is NonEmpty::new, which returns a Result for a case that cannot fail. The integer conversion removes a ? on an infallible check at every call site that passes an id.

The rename came out of review: the U: MaybeEmpty bound on with only makes sense once the trait name says what it provides rather than what it sounds like.

Deferred

  • with_head mirror for layouts with the fixed part on the right (ContextTag::aad_with).
  • NeverEmpty marker trait replacing the hand-listed integer From impls.

Both non-breaking to add later.

https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6
https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd

Stack: #318 (aad-pieces) is based on this branch and retargets to main once this merges.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🧬 Mutation testing (cargo-mutants, --in-diff)

caught missed unviable timeout
0 0 4 0

✅ Every mutant in the changed lines was caught by a test.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

✅ No CRAP threshold violations

573 function(s) analyzed · threshold 30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Public documentation is now inconsistent, and the defining no-bound guarantee lacks regression coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds infallible composition and integer conversion APIs for NonEmpty.

Changes:

  • Adds NonEmpty::with.
  • Adds integer From implementations.
  • Tests AEAD and PRF encoding transparency.
File summaries
File Description
packages/protected/src/non_empty.rs Implements APIs and unit tests.
packages/prf/src/context.rs Tests PRF encoding equivalence.
packages/aead/src/aad/mod.rs Tests AAD encoding equivalence.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/protected/src/non_empty.rs
Comment thread packages/protected/src/non_empty.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation matches the documented invariants and has focused behavioral and encoding coverage.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

`NonEmpty::with(tail)` pairs a proven value with any tail and keeps the
proof: a pair is empty only when both halves are, so a non-empty head makes
the pair non-empty whatever the tail. This is how a caller extends a fixed
context (`nonempty!("users/email")`) with a value known only at the call
site (a record id) without re-checking, and without an unchecked
constructor. The pair encodes exactly as the bare tuple would.

`From<T> for NonEmpty<T>` for the ten integer types, which `IsEmpty` already
declares never empty: an API taking `impl Into<NonEmpty<T>>` accepts a bare
integer, where a string goes through `nonempty!` or `NonEmpty::new`.

Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6
`IsEmpty` read as a marker on the type ("values of this type are empty")
when it is a capability ("this value can be asked whether it is empty").
Integers implement it and are never empty, which made the old name
actively misleading at the `NonEmpty::new` bound. `MaybeEmpty` says what
the trait provides and pairs naturally with `NonEmpty`.

BREAKING CHANGE: `vitaminc_protected::IsEmpty` is now `MaybeEmpty`.
Downstream implementations and bounds must be renamed; the method
`is_empty` is unchanged.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
…ts shape

`with<U>` had no bound, making it the only constructor that could produce
a `NonEmpty<X>` whose `X` could never have passed `NonEmpty::new`: an
already-encoded `Aad` or `PrfContext`, or another `NonEmpty`, all slipped
in as a tail. The invariant held (the head carries the bytes) but the
crate-wide rule "wrap the whole composite, not the parts" was no longer
enforced. `U: MaybeEmpty` closes that, pinned with a `compile_fail`
doctest; every documented tail already satisfies it.

The `with` docs now state that chaining nests to the left,
`a.with(b).with(c)` is `((a, b), c)`, which PAE-frames differently from
`(a, (b, c))`. A caller migrating an existing tuple context would
otherwise silently change its AAD or PRF bytes.

The struct docs still said there were "two ways to build one" and "no
third path" while the same file shipped `From<integer>` and `with`.
Rewritten as one rule, checked once where the type cannot prove
non-emptiness and converted freely where it can, listing all four
routes. The `From<integer>` doc no longer recommends `impl Into<..>`
APIs, which contradicted the guidance to take `NonEmpty<C>` directly,
and the README qualifies "no implicit conversion" to strings and byte
slices.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
…tail

`with` is documented as the way to attach a call-site value such as a
record id, but the tail inherits nothing from the head beyond the
non-emptiness proof. Three consequences were easy to miss and each leads
to a silent wire-format or separation bug, so they now sit in the
rustdoc: the tail is unchecked, so an empty id must be validated before
pairing; an empty tail still frames as a pair and several empty tails
encode identically in AEAD, so `()` cannot stand for "absent"; and
integers encode untagged in AEAD, so the tail's type is not part of the
authenticated context.

Also notes that `with` only extends rightwards. A layout with the fixed
part on the right, like `ContextTag::aad_with`, goes through
`NonEmpty::new` on the whole tuple.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
The `compile_fail` doctest now pins the `MaybeEmpty` bound, so the unit
test is renamed to what it actually covers: unchecked tails and the
left-nesting shape the rustdoc promises. Its `with(42u64)` line
duplicated the doctest and is gone.

The prf hunk exercising `with` is removed. `IntoPrfContext for
NonEmpty<T>` unwraps and delegates, so it cannot observe how the value
was built; the aead hunk stays as the single end-to-end pin of the
"encodes to the same bytes as the bare pair" claim.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
A generic consumer holding a `NonEmpty<C>` must be able to extend it
without `C: MaybeEmpty` leaking into its own bounds; that is the reason
`with` exists instead of `NonEmpty::new` on the pair. Every existing test
called `with` on a concrete head, so adding `T: MaybeEmpty` to the method
would have passed unnoticed. A generic helper over unconstrained `C` now
fails to compile if that bound is ever introduced.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standards

No documented-standard or maintainability findings. The rename is mechanically consistent and correctly marked as breaking; MaybeEmpty better communicates the trait’s capability semantics. Integer conversions are generated from one authoritative list, and with thoroughly documents left nesting, transparent encoding, unchecked tails, and AEAD’s untagged integer caveat. CI is green.

Spec

Changes are required because the implementation diverges from issue #313 in two material ways.

First, #313 specifies with<U> for any tail, with no check. Requiring U: MaybeEmpty excludes encoded contexts, nested proven contexts, and arbitrary downstream context types even though the method never calls is_empty and the proven head alone establishes the result’s invariant.

Second, #313 explicitly says both proposed changes are additive. Removing the public IsEmpty name is a separate breaking API change, regardless of whether MaybeEmpty is a better name. Please keep the existing name in this PR or split/propose the rename with an appropriate compatibility plan; alternatively amend the originating issue to make the changed contract explicit.

The ten integer conversions and tuple-encoding transparency otherwise match the issue.

Comment thread packages/protected/src/non_empty.rs Outdated
Comment thread packages/protected/src/lib.rs
@tobyhede

tobyhede commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Reviewed — no correctness findings. Two non-blocking notes:

with has no #[must_use] (packages/protected/src/non_empty.rs:324)

with is builder-style: consumes self, returns a new value, mutates nothing. Usually the move catches a discarded call, but NonEmpty<T> is Copy (line 242), so for Copy payloads — including the &'static str from nonempty! — a bare statement compiles silently:

let ctx = nonempty!("users/email");
ctx.with(row_id);           // compiles, does nothing
let aad = Aad::from(ctx);   // AAD binds only the field name, not the row

Silently narrower AAD than intended, no warning. Not a regression from this PR — nothing else in the file carries #[must_use] either — so it's really a case for adding it across new / from_static / with in a follow-up.

IsEmptyMaybeEmpty ships with no transitional alias (packages/protected/src/lib.rs:37)

The old name is gone from the public API entirely, so downstream code at 0.2.0 (use vitaminc_protected::IsEmpty, impl IsEmpty for …, T: IsEmpty) gets a hard "cannot find trait" with no pointer to the new name. One release of:

#[deprecated(since = "0.3.0", note = "renamed to `MaybeEmpty`")]
pub use non_empty::MaybeEmpty as IsEmpty;

would keep existing impls and bounds compiling with a warning that names the replacement. Defensible to skip at 0.x with the refactor(protected)! marker doing the changelog work — just flagging it so it's a deliberate call rather than a surprise in a downstream build.

…constructors `#[must_use]`

#313 specifies `with<U>` for any tail with no check, and review made the
case that the bound added in 8dee8ec bought nothing the method uses:
`with` never evaluates `is_empty`, the head's proof alone makes the pair
non-empty, and the bound's real effect was to make a downstream context
type such as a `RecordId` newtype implement `MaybeEmpty` purely to be a
tail it is never checked as. Already-encoded contexts and nested
`NonEmpty` values are harmless tails for the same reason: the pair
frames them once, as the tuple would.

The bound is gone. `MaybeEmpty` stays exactly where the check runs,
on `NonEmpty::new`. The `compile_fail` doctest becomes a positive one
with a tail that implements nothing, a unit test pins the same for an
opaque type and a nested `NonEmpty`, and the `MaybeEmpty` rustdoc scopes
its "wrap the whole composite" rule to `new`.

`NonEmpty<T>` is `Copy`, so `ctx.with(row_id);` as a bare statement
compiled and did nothing, leaving the AAD bound to the column alone.
`with`, `new`, `from_static` and `from_static_bytes` are now
`#[must_use]`, so that line warns with a message saying why.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
The rename in 2d993fa removed the public name outright, so code written
against 0.2.0 (`use vitaminc_protected::IsEmpty`, `impl IsEmpty for
..`, `T: IsEmpty`) would fail with "cannot find trait" and no pointer to
the replacement. #313 also scoped this PR as additive.

`pub use MaybeEmpty as IsEmpty`, deprecated, keeps every existing bound
and impl compiling with a warning that names `MaybeEmpty`. A test under
`#[allow(deprecated)]` pins that the alias binds, implements and feeds
`NonEmpty::new`. The rename is now additive plus deprecation rather than
a hard break; the alias can go in a later major.

Claude-Session: https://claude.ai/code/session_01QvrvBVdcecdr4LfywiGnDd
@coderdan

coderdan commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Both notes taken, in 8cd5146 and 4274a82. #[must_use] is on with, new, from_static and from_static_bytes, and the Copy case you described now warns with a message saying the receiver is unchanged. IsEmpty stays as a deprecated alias for MaybeEmpty, pinned by a test, so 0.2.0 code compiles with a pointer to the new name.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remediation resolves both prior blockers. NonEmpty::with<U> now accepts any tail without checking or requiring MaybeEmpty, while preserving the tuple encoding, and the deprecated IsEmpty re-export keeps existing bounds and downstream impls compiling. The integer conversions remain complete and the additional #[must_use] annotations are appropriate. No remaining spec or repository-standards concerns.

@coderdan
coderdan merged commit 79d75b6 into main Sep 7, 2026
6 checks passed
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.

Compose and convert NonEmpty without re-checking: NonEmpty::with and From<integer>

4 participants