Skip to content

feat(antd-elixir): manifest API (REST + gRPC) - #360

Merged
Nic-dorman merged 3 commits into
mainfrom
nic/v2-1580-manifests-elixir
Oct 19, 2026
Merged

Nic-dorman merged 3 commits into
mainfrom
nic/v2-1580-manifests-elixir

Conversation

@Nic-dorman

@Nic-dorman Nic-dorman commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Part of the manifest API fan-out. Stacked on #346 (the manifest endpoints in antd and antd-go), which sits on the pointer API #326; review those first.

Both clients can now build and read ADR-0006 .ant manifests and
ant:// links through the daemon, with bang variants and Antd delegates
for the REST ones:

  • build_manifest(client, entries, opts): entries are
    Antd.ManifestEntryInput structs (path, size, and exactly one of
    data_map or address); opts are name, torrent_info_hashes and
    embed_roots. Returns Antd.BuiltManifest (manifest bytes, link, entries).
  • decode_manifest(client, bytes, opts) and decode_manifest_link(client,
    link, opts), with a resolve option (default false). Both return
    Antd.DecodedManifest (source, name, the two torrent info hashes,
    entries of Antd.ManifestEntryInfo).

The code sits in new files: lib/antd/manifests.ex (request building and
response parsing for both transports) and lib/antd/manifest_models.ex
(the four structs). Client and GrpcClient each gain one self-contained
section, away from where the pointer branch adds its own.

The .ant bytes are always a raw binary in the API. The REST client
base64-encodes them in the decode request and decodes the build
response's manifest, and a build response that is not valid base64 is
an error. gRPC carries raw bytes. embed_roots defaults to true and is
always sent, so a bare options list means "embed", as antd-go's zero
value does.

Sizes are plain integers. An unset size or exact_size is nil, never 0,
on both transports: REST omits the field, and the gRPC stubs leave
proto3 optional fields nil when they are absent. A recorded 0 stays 0.
Every other absent value is nil too (link, path, name, data_map, error,
the torrent hashes). gRPC sends these as "", and "" is truthy in
Elixir, so an if entry.error check stays safe.

An entry with both or neither of data_map and address is refused before
anything is sent. So are a size that is not a u64, a non-string field and
an empty decode input. Each is an Antd.BadRequestError naming entries[i],
with status_code 0. The daemon's own rejections (a 400 over REST,
INVALID_ARGUMENT over gRPC) map to the existing Antd.BadRequestError with
the daemon's message kept. There are no new error modules. One entry's
resolve failure is reported in its error field.

gRPC receive limit: grpc-elixir 0.11.5 has no receive limit to raise. Its
Gun adapter reads a unary response whole, GRPC.Stub.connect takes no size
option, and a call raises on any option it does not know. Sends are
capped only by GRPC.Message's 2 GiB default. So no option is passed, and
a code comment records why. A one-off check moved 72 MiB through a mock
ManifestService each way with the stock client. Each gRPC manifest
function takes a :timeout option, default five minutes rather than
grpc's ten seconds, since the embedding policy and resolve read the
network.

lib/antd/v1/manifests.pb.ex comes from scripts/generate-protos.sh with
manifests added to its list (protoc-gen-elixir 0.16.0, protoc 25.3). The
existing stubs regenerate byte-identically.

Tests: 23 new. 11 REST tests run over Bypass and 10 gRPC tests run
against a mock ManifestService. They cover:

  • the base64 round trip over all 256 byte values;
  • unset size and exact_size reading as nil next to a recorded 0;
  • the entry check before sending;
  • the embed_roots default and proto3 presence on the wire;
  • the 400 and INVALID_ARGUMENT mapping, and the :timeout option;
  • a manifest over 4 MiB received whole, and one sent whole.
    The full suite passes (178 tests). The modules start an unlinked
    GRPC.Client.Supervisor, and wait out one that is still going down with
    another module, after an early full run hit that race.

test/antd/live_manifests_test.exs is gated on ANTD_TEST_URL and
ANTD_TEST_MANIFEST_ADDRESSES, with gRPC when ANTD_TEST_GRPC is set. It
pays for nothing. It passed over REST and gRPC against a 10-node devnet
daemon:

  • a build from two public addresses (one with a size) and a private
    DataMap, all with paths, checking kinds, names, sizes and the link;
  • resolving decodes of the bytes and the link, with exact sizes matching
    the fixtures and the DataMap entry embedded;
  • a non-resolving decode, a bare address and the fixture link;
  • both-sources and ../x entries, and a malformed manifest, as
    BadRequestError.

examples/09_manifests.exs (optional ANTD_BASE_URL) ran against the same
daemon. It made two public uploads, built a 184-byte manifest named
"example" with a link, and decoded the link with resolve, getting exact
sizes 28 and 45.

Overlap with this SDK's pointer PR #330. A trial merge conflicts only in: antd-elixir/README.md, antd-elixir/scripts/generate-protos.sh. Resolve by keeping both sides when the two land.

Version bumps happen at release time; nothing here bumps a package version.

🤖 Generated with Claude Code

Nic-dorman and others added 2 commits October 15, 2026 14:25
Both clients can now build and read ADR-0006 `.ant` manifests and
`ant://` links through the daemon, with bang variants and Antd delegates
for the REST ones:
- build_manifest(client, entries, opts): entries are
  Antd.ManifestEntryInput structs (path, size, and exactly one of
  data_map or address); opts are name, torrent_info_hashes and
  embed_roots. Returns Antd.BuiltManifest (manifest bytes, link, entries).
- decode_manifest(client, bytes, opts) and decode_manifest_link(client,
  link, opts), with a resolve option (default false). Both return
  Antd.DecodedManifest (source, name, the two torrent info hashes,
  entries of Antd.ManifestEntryInfo).

The code sits in new files: lib/antd/manifests.ex (request building and
response parsing for both transports) and lib/antd/manifest_models.ex
(the four structs). Client and GrpcClient each gain one self-contained
section, away from where the pointer branch adds its own.

The `.ant` bytes are always a raw binary in the API. The REST client
base64-encodes them in the decode request and decodes the build
response's `manifest`, and a build response that is not valid base64 is
an error. gRPC carries raw bytes. embed_roots defaults to true and is
always sent, so a bare options list means "embed", as antd-go's zero
value does.

Sizes are plain integers. An unset size or exact_size is nil, never 0,
on both transports: REST omits the field, and the gRPC stubs leave
proto3 optional fields nil when they are absent. A recorded 0 stays 0.
Every other absent value is nil too (link, path, name, data_map, error,
the torrent hashes). gRPC sends these as "", and "" is truthy in
Elixir, so an `if entry.error` check stays safe.

An entry with both or neither of data_map and address is refused before
anything is sent. So are a size that is not a u64, a non-string field and
an empty decode input. Each is an Antd.BadRequestError naming entries[i],
with status_code 0. The daemon's own rejections (a 400 over REST,
INVALID_ARGUMENT over gRPC) map to the existing Antd.BadRequestError with
the daemon's message kept. There are no new error modules. One entry's
resolve failure is reported in its error field.

gRPC receive limit: grpc-elixir 0.11.5 has no receive limit to raise. Its
Gun adapter reads a unary response whole, GRPC.Stub.connect takes no size
option, and a call raises on any option it does not know. Sends are
capped only by GRPC.Message's 2 GiB default. So no option is passed, and
a code comment records why. A one-off check moved 72 MiB through a mock
ManifestService each way with the stock client. Each gRPC manifest
function takes a :timeout option, default five minutes rather than
grpc's ten seconds, since the embedding policy and resolve read the
network.

lib/antd/v1/manifests.pb.ex comes from scripts/generate-protos.sh with
manifests added to its list (protoc-gen-elixir 0.16.0, protoc 25.3). The
existing stubs regenerate byte-identically.

Tests: 23 new. 11 REST tests run over Bypass and 10 gRPC tests run
against a mock ManifestService. They cover:
- the base64 round trip over all 256 byte values;
- unset size and exact_size reading as nil next to a recorded 0;
- the entry check before sending;
- the embed_roots default and proto3 presence on the wire;
- the 400 and INVALID_ARGUMENT mapping, and the :timeout option;
- a manifest over 4 MiB received whole, and one sent whole.
The full suite passes (178 tests). The modules start an unlinked
GRPC.Client.Supervisor, and wait out one that is still going down with
another module, after an early full run hit that race.

test/antd/live_manifests_test.exs is gated on ANTD_TEST_URL and
ANTD_TEST_MANIFEST_ADDRESSES, with gRPC when ANTD_TEST_GRPC is set. It
pays for nothing. It passed over REST and gRPC against a 10-node devnet
daemon:
- a build from two public addresses (one with a size) and a private
  DataMap, all with paths, checking kinds, names, sizes and the link;
- resolving decodes of the bytes and the link, with exact sizes matching
  the fixtures and the DataMap entry embedded;
- a non-resolving decode, a bare address and the fixture link;
- both-sources and `../x` entries, and a malformed manifest, as
  BadRequestError.

examples/09_manifests.exs (optional ANTD_BASE_URL) ran against the same
daemon. It made two public uploads, built a 184-byte manifest named
"example" with a link, and decoded the link with resolve, getting exact
sizes 28 and 45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A manifest input the SDK refuses before sending is now the same error as
the daemon's own refusal of it: Antd.BadRequestError with status_code 400
(it was 0), and the daemon's wording.

build_manifest collects every bad entry and reports them together, as the
daemon does: "N invalid entries: entries[i]: give data_map or address, not
both; entries[j]: needs a data_map or an address", at most 20 shown, then
"; and K more". The wording has no SDK prefix, and an empty string counts
as unset. The other input checks (a size outside u64, a non-string field,
a value that is not an Antd.ManifestEntryInput) stay, with the same type,
status and "entries[i]: " prefix. An empty decode input (bytes or link) is
"needs a manifest (base64 .ant bytes) or a link". A bad base64 manifest in
a daemon response is unchanged: it is not input validation.

Tests: the unit tests assert type, status and exact message, with a
two-bad-entry case and a 23-entry case for the cap. The full suite passes
(179 tests), with --warnings-as-errors and mix format --check-formatted
clean on the touched files. The live test passed over REST and gRPC, and
now checks that the client-side message equals what the daemon
returns (REST minus its "Bad request: " prefix) for the same request sent past the check (REST: both-sources and
neither; gRPC: neither, since a oneof cannot hold both).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Nic-dorman
Nic-dorman marked this pull request as ready for review October 16, 2026 09:08
@Nic-dorman
Nic-dorman changed the base branch from nic/v2-1570-antd-manifest-endpoints to main October 16, 2026 14:34

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Formal review: APPROVE

Repository: WithAutonomi/ant-sdk, PR #360.
Reviewed head: bc3aa2833fa5315c9c0599c236b11ce515f74ba0. Original merge base: 6c907eda10948c37cd92dd921d9dd5f68bd3e817.

No material blocker was found in the Elixir manifest API. All seven available scope/role reviews returned APPROVE, but the 7/12 panel is incomplete and is not full three-model consensus. Runtime limits are explicit.

Source and test coverage

  • Complete Elixir manifest DTO/builders, shared validation, REST/gRPC adapters, tests and example against the frozen daemon manifest contract.
  • Read the assertions in the REST and gRPC suites, including the in-process gRPC server cases for responses larger than 4 MiB. Exact-head GitHub checks succeeded. No mix test was run locally.

Findings and adjudication

  • Both transports use shared entry validation before sending and preserve absent optional sizes rather than treating them as zero.
  • REST base64 encoding and gRPC raw bytes remain transport-specific; embedding defaults and optional request fields follow the contract.
  • The deliberate gRPC limit difference is documented at lib/antd/grpc_client.ex:1040-1048: this grpc-elixir version has no receive-limit option to raise. The source has real in-process-server tests for the large-message path, rather than merely asserting that an unused option was configured. Those tests were read, not executed locally.

Panel dissent and its disposition

  • No substantive panel seat raised a change request for this PR. Missing seats are unavailable, not additional approval votes.

Review team: actual participation

7/12 scope/role/model seats returned substantive, receipt-validated reviews; 3/3 distinct named models. Partial panel; provisional coverage limits apply to this formal verdict. A receipt is evidence of participation, not proof that its findings are correct. Counts below are per evidence scope, not additional independent models.

  • source-1: glm: correctness=unavailable (Incomplete termination; unavailable seat), adversarial=unavailable (Incomplete termination; unavailable seat); qwen: correctness=APPROVE, adversarial=APPROVE; ds4: correctness=unavailable (Incomplete termination; unavailable seat), adversarial=unavailable (TimeoutError).
  • source-2: glm: correctness=APPROVE, adversarial=unavailable (Incomplete termination; unavailable seat); qwen: correctness=APPROVE, adversarial=APPROVE; ds4: correctness=APPROVE, adversarial=APPROVE.
  • Models: GLM 5.3 / OpenRouter z-ai/glm-5.3; local Qwen 3.8 / qwen38-27b; local DS4 Flash / deepseek-v4-flash. Missing seats received the configured same-model retry; no replacement model is counted as an independent vote.

Publication gates and limits

  • Live head rechecked against the reviewed head. Live base ea389c79c2ac40601c574e93031a66a9791db1d2: binary diff matches the reviewed diff, merge base is unchanged, no intersecting base-drift paths, and trial merge exited 0.
  • GitHub check snapshot: SUCCESS=7.
  • Partial panel; all three named models contributed, but five scope/role/model seats remained unavailable after retry. No full-panel consensus is claimed.
  • No local Elixir build/test run or live manifest test. Author-reported mix test/devnet results remain separate from the observed GitHub check snapshot and source inspection.
  • No paid or live-network tests. No merge, deployment, source change, or Linear state change. This verdict applies only to the exact head above; it is not a fleet-wide or cross-SDK integration sign-off.

main now has the pointer fan-out. Conflicts resolved exactly as in the
integration preview #367 (bc339ab): antd-elixir/README.md antd-elixir/scripts/generate-protos.sh
The result matches #367 for antd-elixir byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Nic-dorman
Nic-dorman merged commit 2cac016 into main Oct 19, 2026
7 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.

2 participants