Skip to content

fix(contracts): enforce integer bounds on the Rust serialize path, not only on parse - #3537

Merged
ctwoodwa merged 1 commit into
mainfrom
fix/3526-rust-serialize-bounds
Aug 2, 2026
Merged

fix(contracts): enforce integer bounds on the Rust serialize path, not only on parse#3537
ctwoodwa merged 1 commit into
mainfrom
fix/3526-rust-serialize-bounds

Conversation

@ctwoodwa

@ctwoodwa ctwoodwa commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

fix(contracts): enforce integer bounds on the Rust serialize path, not only on parse

The generator emitted bounds checks when deserializing and none when serializing, so a value that
could never be ACCEPTED could still be EMITTED. That is the one-sided form of a cross-language gate:
this runtime can produce a payload its siblings will reject, which surfaces as a different wrong
answer rather than a clean failure.

Rust now generates serializer hooks from the same schema maximum the parse path already reads, so
the two directions cannot drift.

TypeScript and C# were checked for the same asymmetry and did not have it — the outbound assertion
seam and the generated bounded setters were already symmetric. Neither needed a generator change,
and neither is claimed as fixed.

The generator also now rejects a scalar, null or array additionalProperties before writing anything,
following the existing convention that a rejection test asserts both a non-zero exit and an empty
output directory.

Proven by mutation toward a different wrong answer: with the serializer hooks suppressed, the round
trip emits the out-of-range integer rather than refusing it, and the new Rust test fails on exactly
that. The shared fixture covers the case in all three languages.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

Summary by CodeRabbit

  • Bug Fixes

    • Numeric contract fields now reject values outside supported cross-language safe-integer ranges during serialization and deserialization.
    • Sync cadence intervals exceeding the maximum supported value are reported as schema validation errors.
  • Tests

    • Added coverage across JavaScript, Rust, and .NET to verify out-of-range sync cadence values are rejected.
    • Added validation for unsupported schema definitions.

…t only on parse

The generator emitted bounds checks when deserializing and none when serializing, so a value that
could never be ACCEPTED could still be EMITTED. That is the one-sided form of a cross-language gate:
this runtime can produce a payload its siblings will reject, which surfaces as a different wrong
answer rather than a clean failure.

Rust now generates serializer hooks from the same schema maximum the parse path already reads, so
the two directions cannot drift.

TypeScript and C# were checked for the same asymmetry and did not have it — the outbound assertion
seam and the generated bounded setters were already symmetric. Neither needed a generator change,
and neither is claimed as fixed.

The generator also now rejects a scalar, null or array additionalProperties before writing anything,
following the existing convention that a rejection test asserts both a non-zero exit and an empty
output directory.

Proven by mutation toward a different wrong answer: with the serializer hooks suppressed, the round
trip emits the out-of-range integer rather than refusing it, and the new Rust test fails on exactly
that. The shared fixture covers the case in all three languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds serializer-side numeric range validation to generated Rust protocol bindings. It updates code generation, validates SyncCadence.roundIntervalSeconds fixtures, and adds Rust, JavaScript, and C# rejection tests.

Changes

Numeric validation

Layer / File(s) Summary
Generator validation and serializer emission
tooling/carrier-contract-codegen/generate.mjs, tooling/carrier-contract-codegen/tests/generate.test.mjs
The generator accepts boolean or schema-valued additionalProperties. It emits serializers that enforce numeric minimum and maximum constraints.
Generated Rust range enforcement
packages/contracts/rust/src/generated.rs, packages/contracts/rust/src/lib.rs
Generated serializers validate bounded protocol fields, including SyncCadence.round_interval_seconds, during serialization. Rust tests verify schema-range errors.
Fixture and projection conformance tests
packages/contracts/protocol/fixtures/*, packages/contracts/src/__tests__/protocol-fixtures.test.ts, packages/contracts/tests/ProtocolFixtureTests.cs
Fixtures and JavaScript and C# tests reject an unsafe SyncCadence interval during outbound serialization.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProtocolFixture
  participant OutboundSerializer
  participant RangeValidator
  ProtocolFixture->>OutboundSerializer: serialize unsafe SyncCadence interval
  OutboundSerializer->>RangeValidator: check roundIntervalSeconds maximum
  RangeValidator-->>OutboundSerializer: return range error
  OutboundSerializer-->>ProtocolFixture: reject outbound payload
Loading

Possibly related issues

  • Harborline-Software/shipyard issue 3526: Covers the same Rust serializer-side integer validation, SyncCadence conformance tests, and scalar additionalProperties rejection.

Possibly related PRs

  • Harborline-Software/shipyard#3517: Modifies the same generated Rust bindings, protocol fixtures, and code-generation tests for cross-language numeric range validation.

Suggested labels: needs-deep-review

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the code changes but omits the required ICM Stage, Affected Packages, and Checklist sections. Add the missing template sections and complete the applicable ICM, package, build, test, documentation, and compatibility checks.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states that Rust serialization must enforce integer bounds, which matches the primary change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/3526-rust-serialize-bounds

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

thought (non-blocking):

Accessibility audit (advisory)

The sharded axe audit is report-only while the baseline and runtime budget mature.
Unknown moderate-or-higher findings must be resolved or added to the reviewed
accessibility baseline.

Shard Result Findings
1/4 pass 0 axe finding(s), 1 coverage warning(s)
2/4 pass 0 axe finding(s), 1 coverage warning(s)
3/4 pass 0 axe finding(s), 1 coverage warning(s)
4/4 pass No moderate-or-higher findings
Shard 1 report
[shipyard-syncstate-indicator] skipping directionalIcons enforcement: component has not opted in via [data-shipyard-direction="<state>"] sentinel.
Shard 2 report
[shipyard-button] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".
Shard 3 report
[shipyard-dialog] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/contracts/rust/src/lib.rs`:
- Around line 186-189: The invalid sync-cadence test data must come from the
shared fixture instead of duplicated literals. In
packages/contracts/rust/src/lib.rs lines 186-189, read
sync-cadence-above-safe-integer.json and derive next_round_at and
round_interval_seconds before constructing SyncCadence; in
packages/contracts/tests/ProtocolFixtureTests.cs lines 45-49, likewise read the
fixture and derive NextRoundAt and RoundIntervalSeconds before constructing the
invalid model.

In `@tooling/carrier-contract-codegen/generate.mjs`:
- Around line 255-258: Update rustDefinition to preserve schema-valued
additionalProperties by emitting a serde-flattened BTreeMap<String,
rustType(definition.additionalProperties)> field, while retaining the existing
handling for boolean values. Add a successful round-trip test covering
schema-valued maps and review the generated Rust output for architectural
consistency and required public API documentation.
- Around line 1131-1135: Update rustNumeric() so the .0 suffix is added only
when a number-valued bound is an integer; preserve fractional values unchanged
while continuing to stringify non-number types as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ea85b34-2cdc-4321-9096-2d77263245e5

📥 Commits

Reviewing files that changed from the base of the PR and between ed9c43b and 4975d63.

📒 Files selected for processing (8)
  • packages/contracts/protocol/fixtures/manifest.json
  • packages/contracts/protocol/fixtures/sync-cadence-above-safe-integer.json
  • packages/contracts/rust/src/generated.rs
  • packages/contracts/rust/src/lib.rs
  • packages/contracts/src/__tests__/protocol-fixtures.test.ts
  • packages/contracts/tests/ProtocolFixtureTests.cs
  • tooling/carrier-contract-codegen/generate.mjs
  • tooling/carrier-contract-codegen/tests/generate.test.mjs

Comment on lines +186 to +189
let cadence = SyncCadence {
next_round_at: Some("2026-08-02T12:00:00Z".to_owned()),
round_interval_seconds: 9_007_199_254_740_992,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

suggestion [blocking]: Use the shared unsafe fixture as the test-data source.

The Rust and C# tests copy the same invalid payload that TypeScript reads from sync-cadence-above-safe-integer.json. Read the fixture in each test and extract its values before constructing the invalid model.

  • packages/contracts/rust/src/lib.rs#L186-L189: derive next_round_at and round_interval_seconds from sync-cadence-above-safe-integer.json.
  • packages/contracts/tests/ProtocolFixtureTests.cs#L45-L49: derive NextRoundAt and RoundIntervalSeconds from sync-cadence-above-safe-integer.json.

As per path instructions, “avoid hand-parallel duplicate copies of single-source things (A4).”

📍 Affects 2 files
  • packages/contracts/rust/src/lib.rs#L186-L189 (this comment)
  • packages/contracts/tests/ProtocolFixtureTests.cs#L45-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/rust/src/lib.rs` around lines 186 - 189, The invalid
sync-cadence test data must come from the shared fixture instead of duplicated
literals. In packages/contracts/rust/src/lib.rs lines 186-189, read
sync-cadence-above-safe-integer.json and derive next_round_at and
round_interval_seconds before constructing SyncCadence; in
packages/contracts/tests/ProtocolFixtureTests.cs lines 45-49, likewise read the
fixture and derive NextRoundAt and RoundIntervalSeconds before constructing the
invalid model.

Source: Path instructions

Comment on lines +255 to +258
if (Object.hasOwn(value, 'additionalProperties')
&& typeof value.additionalProperties !== 'boolean'
&& (typeof value.additionalProperties !== 'object' || value.additionalProperties === null || Array.isArray(value.additionalProperties))) {
throw new Error(`${path}: additionalProperties must be a boolean or schema object for consistent TypeScript, C#, and Rust projections`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

issue [blocking]: Preserve schema-valued additional properties in Rust.

Line 255 permits a schema object for additionalProperties. rustDefinition only emits a flattened map when the value is true. A schema-valued map is therefore ignored by Rust deserialization and lost during serialization.

Emit BTreeMap<String, ${rustType(definition.additionalProperties)}> with #[serde(flatten)] for this case, or reject it until all projections support it. Add a successful schema-valued-map round-trip test.

As per path instructions, “Review generated Rust output for architectural consistency and public API documentation requirements.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tooling/carrier-contract-codegen/generate.mjs` around lines 255 - 258, Update
rustDefinition to preserve schema-valued additionalProperties by emitting a
serde-flattened BTreeMap<String, rustType(definition.additionalProperties)>
field, while retaining the existing handling for boolean values. Add a
successful round-trip test covering schema-valued maps and review the generated
Rust output for architectural consistency and required public API documentation.

Source: Path instructions

Comment on lines +1131 to +1135
function rustNumeric(value, property) {
const type = Array.isArray(property.type)
? property.type.find((item) => item !== 'null')
: property.type
return type === 'number' ? `${value}.0` : String(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

pnpm kb query "What command compiles generated Rust contract bindings, and what Rust toolchain does CI use?"

rg -n -C 3 '"type"[[:space:]]*:[[:space:]]*"number"|minimum|maximum' \
  packages/contracts/protocol/schemas \
  tooling/carrier-contract-codegen \
  2>/dev/null || true

Repository: Harborline-Software/shipyard

Length of output: 23066


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Relevant generate.mjs sections =="
sed -n '1000,1145p' tooling/carrier-contract-codegen/generate.mjs

echo
echo "== Numerical bounds in schemas =="
python3 - <<'PY'
import json
from pathlib import Path
p=Path('packages/contracts/protocol/schemas/carrier-protocol.schema.json')
s=json.loads(p.read_text())
for path, schema in [(None,s), *(('/'+'/'.join(k),sub) for sub in s.get('definitions',{}).items())] + [('/',*kv) for kv in (s.get('$defs') or {}).items() if isinstance(kv[1],dict)]:
    def walk(node, p='`#/`'):
        if isinstance(node, dict):
            if node.get('type')=='number' and ('minimum' in node or 'maximum' in node):
                print(path + p, node)
            for k,v in node.items():
                walk(v, f"{p}{k}/")
    walk(schema, path+'/')
PY

echo
echo "== Rust tests for generated Rust binding compilation =="
rg -rn 'cargo[ -]|rustc|--release|generated|Carriers|Protocol|carrier|contract.*rust|rust|generate' tooling packages .github 2>/dev/null | head -200 || true

echo
echo "== Read-only behavior probe for rustNumeric =="
node - <<'JS'
function rustNumeric(value, property) {
  const type = Array.isArray(property.type)
    ? property.type.find((item) => item !== 'null')
    : property.type
  return type === 'number' ? `${value}.0` : String(value)
}
for (const value of [0, 5, -5, 2.5, -2.5, 1e3, 1e-2, 2.9e1]) {
  const s = String(value);
  const out = rustNumeric(value, {type: 'number'});
  console.log(`${s}`.padEnd(8), '=>', out, out.includes('.0') ? (s.includes('.') || s.includes('e') || s.includes('E') ? 'bad-decimal' : 'integer-suffix') : 'ok')
}
JS

Repository: Harborline-Software/shipyard

Length of output: 7276


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Progress bound definitions =="
python3 - <<'PY'
import json
from pathlib import Path
schema = json.loads(Path('packages/contracts/protocol/schemas/carrier-protocol.schema.json').read_text())
for section_name, section in schema.get('definitions', {}).items():
    if not isinstance(section, dict):
        continue
    props = section.get('properties') or {}
    for prop_name, prop in props.items():
        if prop.get('type') == 'number' and ('minimum' in prop or 'maximum' in prop):
            print(section_name, prop_name, json.dumps({'type': prop.get('type'), 'minimum': prop.get('minimum'), 'maximum': prop.get('maximum')}, separators=(',', ':')))
PY

echo
echo "== Rust literal probe =="
node - <<'JS'
function rustNumeric(value, property) {
  const type = Array.isArray(property.type)
    ? property.type.find((item) => item !== 'null')
    : property.type
  return type === 'number' ? `${value}.0` : String(value)
}
for (const value of [0, 5, -5, 2.5, -2.5, 1e3, 1e-2, 2.9e1]) {
  const out = rustNumeric(value, {type: 'number'});
  console.log(`${String(value)} => ${out}`);
}
JS

echo
echo "== Search for Rust codegen tests or generated Rust compilation =="
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' 'generate.*Rust|cargo[ -]|rustc|--release|generated.*rust|rust.*binding|carrier.*rust|target[\\/]rust|\\.rs' .github tooling packages 2>/dev/null || true

Repository: Harborline-Software/shipyard

Length of output: 4910


issue [blocking]: Generate valid Rust literals for fractional bounds.

rustNumeric() appends .0 to every number bound, so fractional values such as 2.5 emit 2.5.0, which Rust rejects. Generate the suffix only for integer-looking numeric bounds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tooling/carrier-contract-codegen/generate.mjs` around lines 1131 - 1135,
Update rustNumeric() so the .0 suffix is added only when a number-valued bound
is an integer; preserve fractional values unchanged while continuing to
stringify non-number types as before.

Source: Coding guidelines

@ctwoodwa
ctwoodwa merged commit 833bcb5 into main Aug 2, 2026
52 checks passed
@ctwoodwa
ctwoodwa deleted the fix/3526-rust-serialize-bounds branch August 2, 2026 16:16
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