feat: replace the in-process PDS with a managed PDS container + admin API #68

Merged
Grandiras merged 9 commits from feat/managed-pds-aspire into main 2026-07-25 22:11:38 +00:00
Owner

Removes the ATProtoNet.Pds package and replaces it with what was originally intended: an easy way to run the official Bluesky PDS container under Aspire, plus a typed API to administer it.

Removed

src/ATProtoNet.Pds/, samples/PdsSample/, tests/ATProtoNet.Tests/Pds/, docs/pds.md — ~12.5k lines. The implementation stays in git history. ATProtoNet.Pds shipped in v0.4.0, so this is a breaking removal of a published package; there is no in-process replacement.

The [Unreleased] changelog entries describing that code were stripped rather than listed as removed, since none of it was ever released.

Added

ATProtoNet.Aspire.Hosting (bumped to Aspire 13.4.6)

var pds = builder.AddAtProtoPds("pds");

builder.AddProject<Projects.Web>("web")
       .WithAtProtoPds(pds);

WithAtProtoPds does the reference, the configuration keys, and WaitFor on a new /xrpc/_health check. Plus WithHandleDomains, WithInviteCodeRequired, WithAdminPassword, WithJwtSecret, WithPlcRotationKey, WithDataBindMount, and a PDS_HOSTNAME default so the container starts unconfigured.

PdsAdminClient (core, ATProtoNet.Admin) — administers any PDS you hold the admin password for, over HTTP Basic as the reference PDS expects.

var account = await pds.CreateAccountAsync(new CreatePdsAccountRequest
{
    Handle = "alice.pds.example.com",
    Email = "alice@example.com",
    Password = password,
});

It calls describeServer, mints an invite code with the admin credentials if the server requires one, then signs the account up. The signup call is sent unauthenticated — the admin password is never attached to a public endpoint (asserted in a test). The constructor also refuses a non-loopback http:// URL rather than sending the password in the clear.

AddAtProtoPdsAdmin() (ATProtoNet.Server) binds AtProto:Pds:Url / AtProto:Pds:AdminPassword — the keys the AppHost supplies — and throws at startup naming the missing key.

XrpcClient.SetAdminCredentials() — Basic admin auth on the low-level client; a session token still wins when both are set.

Fixed

The existing Aspire resource generated its JWT secret and PLC rotation key with RandomNumberGenerator at AppHost build time, so both changed on every run — while the data volume it mounts persists. Each restart invalidated every session and left the accounts already in the volume with did:plc identities whose rotation key the server no longer held. Both are now Aspire parameters generated as lowercase hex and persisted to user secrets.

Verification

dotnet build clean; 1065 unit tests pass (41 new). New coverage: admin Basic auth on XrpcClient, the PdsAdminClient surface (invite minting, unauthenticated signup, takedown payload shape, HTTPS enforcement), DI binding, and the Aspire resource (hex secret generation, secrets bound to parameters rather than literals, health check, consumer wiring).

Notes

  • No AppHost sample project — the Aspire AppHost SDK pulls ~150 MB of orchestration/dashboard packages into every CI restore. samples/ManagedPdsSample covers the consumer side, and the 6-line AppHost is documented in docs/managed-pds.md and the sample README.
  • New docs page docs/managed-pds.md; docs/pds.md removed and all links repointed.

🤖 Generated with Claude Code

Removes the `ATProtoNet.Pds` package and replaces it with what was originally intended: an easy way to run the **official Bluesky PDS container** under Aspire, plus a typed API to administer it. ## Removed `src/ATProtoNet.Pds/`, `samples/PdsSample/`, `tests/ATProtoNet.Tests/Pds/`, `docs/pds.md` — ~12.5k lines. The implementation stays in git history. `ATProtoNet.Pds` shipped in v0.4.0, so this is a **breaking removal of a published package**; there is no in-process replacement. The `[Unreleased]` changelog entries describing that code were stripped rather than listed as removed, since none of it was ever released. ## Added **`ATProtoNet.Aspire.Hosting`** (bumped to Aspire 13.4.6) ```csharp var pds = builder.AddAtProtoPds("pds"); builder.AddProject<Projects.Web>("web") .WithAtProtoPds(pds); ``` `WithAtProtoPds` does the reference, the configuration keys, and `WaitFor` on a new `/xrpc/_health` check. Plus `WithHandleDomains`, `WithInviteCodeRequired`, `WithAdminPassword`, `WithJwtSecret`, `WithPlcRotationKey`, `WithDataBindMount`, and a `PDS_HOSTNAME` default so the container starts unconfigured. **`PdsAdminClient`** (core, `ATProtoNet.Admin`) — administers any PDS you hold the admin password for, over HTTP Basic as the reference PDS expects. ```csharp var account = await pds.CreateAccountAsync(new CreatePdsAccountRequest { Handle = "alice.pds.example.com", Email = "alice@example.com", Password = password, }); ``` It calls `describeServer`, mints an invite code with the admin credentials if the server requires one, then signs the account up. The signup call is sent **unauthenticated** — the admin password is never attached to a public endpoint (asserted in a test). The constructor also refuses a non-loopback `http://` URL rather than sending the password in the clear. **`AddAtProtoPdsAdmin()`** (`ATProtoNet.Server`) binds `AtProto:Pds:Url` / `AtProto:Pds:AdminPassword` — the keys the AppHost supplies — and throws at startup naming the missing key. **`XrpcClient.SetAdminCredentials()`** — Basic admin auth on the low-level client; a session token still wins when both are set. ## Fixed The existing Aspire resource generated its JWT secret and PLC rotation key with `RandomNumberGenerator` at AppHost build time, so both changed on every run — while the data volume it mounts persists. Each restart invalidated every session and left the accounts already in the volume with `did:plc` identities whose rotation key the server no longer held. Both are now Aspire parameters generated as lowercase hex and persisted to user secrets. ## Verification `dotnet build` clean; **1065 unit tests pass** (41 new). New coverage: admin Basic auth on `XrpcClient`, the `PdsAdminClient` surface (invite minting, unauthenticated signup, takedown payload shape, HTTPS enforcement), DI binding, and the Aspire resource (hex secret generation, secrets bound to parameters rather than literals, health check, consumer wiring). ## Notes - No AppHost sample project — the Aspire AppHost SDK pulls ~150 MB of orchestration/dashboard packages into every CI restore. `samples/ManagedPdsSample` covers the consumer side, and the 6-line AppHost is documented in `docs/managed-pds.md` and the sample README. - New docs page `docs/managed-pds.md`; `docs/pds.md` removed and all links repointed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: replace the in-process PDS with a managed PDS container + admin API
All checks were successful
CI / build-and-test (pull_request) Successful in 39s
19880a4c11
Remove the ATProtoNet.Pds package. Maintaining a second PDS implementation
was never the goal of this project, and the reference implementation is the
one the network actually federates with.

In its place, ATProto.NET orchestrates and administers the official Bluesky
PDS container:

- ATProtoNet.Aspire.Hosting adds it to an Aspire AppHost. WithAtProtoPds(pds)
  wires a project up in one call — reference, configuration, and a WaitFor on
  the new /xrpc/_health check. Bumped to Aspire 13.
- PdsAdminClient (core) administers any PDS you hold the admin password for.
  CreateAccountAsync mints an invite code when the server requires one, so an
  app can provision accounts on its own server; the signup call itself stays
  unauthenticated so the admin password never reaches a public endpoint.
- AddAtProtoPdsAdmin() (ATProtoNet.Server) binds the two configuration keys
  the AppHost supplies.

Also fixes a real bug in the existing Aspire resource: the JWT secret and PLC
rotation key were regenerated on every run while the data volume persisted,
so each restart stranded the identities already stored in it. Both are now
persisted Aspire parameters generated as hex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/70); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-68/run-70.md) — updating every ~5 minutes while the task runs.
claude-bot left a comment

The core intent of this PR — removing the in-process PDS in favor of orchestrating the reference container plus a typed admin client — is well-executed and thoroughly documented (CHANGELOG, docs, sample, README/architecture updates all kept in sync). The Aspire resource changes (persisted hex parameters for JWT secret/PLC rotation key instead of regenerating them per-run, the health check, WithAtProtoPds wiring) correctly fix the stated bug and look internally consistent — mutating AdminPasswordParameter/JwtSecretParameter/PlcRotationKeyParameter via the deferred WithEnvironment(context => ...) callback means With* overrides still take effect regardless of call order relative to WithAtProtoPds.

One gap worth a look: HexSecretParameterDefault.WriteToManifest only writes {"generate": {"minLength": byteCount*2}}, which (per Aspire's manifest schema) tells external provisioning tooling to generate an alphanumeric string of that length, not a hex string. GetDefaultValue() (used for local dotnet run) correctly produces lowercase hex, but a --publisher manifest-driven deployment would generate a value for PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX that likely isn't valid hex, which the PDS requires. Given there's intentionally no AppHost sample in this repo, this path is also untested. Low urgency since it only affects manifest/publish-based provisioning, not the default local flow this PR is verified against.

Note: the diff supplied to me was truncated partway through (ATProtoNet.Pds deletion listings), so I could not see the actual PdsAdminClient, XrpcClient.SetAdminCredentials, or AddAtProtoPdsAdmin implementations, nor the new tests — the security-relevant claims (unauthenticated signup call, loopback-only HTTP enforcement, fixed-time admin auth) are stated in the description but not independently verifiable from what I reviewed.


⏱ 1m 22s · 1 turns · tokens: 77.0k in / 8.2k out (+23.7k cache-read)
📜 Full session transcript

The core intent of this PR — removing the in-process PDS in favor of orchestrating the reference container plus a typed admin client — is well-executed and thoroughly documented (CHANGELOG, docs, sample, README/architecture updates all kept in sync). The Aspire resource changes (persisted hex parameters for JWT secret/PLC rotation key instead of regenerating them per-run, the health check, `WithAtProtoPds` wiring) correctly fix the stated bug and look internally consistent — mutating `AdminPasswordParameter`/`JwtSecretParameter`/`PlcRotationKeyParameter` via the deferred `WithEnvironment(context => ...)` callback means `With*` overrides still take effect regardless of call order relative to `WithAtProtoPds`. One gap worth a look: `HexSecretParameterDefault.WriteToManifest` only writes `{"generate": {"minLength": byteCount*2}}`, which (per Aspire's manifest schema) tells external provisioning tooling to generate an alphanumeric string of that length, not a hex string. `GetDefaultValue()` (used for local `dotnet run`) correctly produces lowercase hex, but a `--publisher manifest`-driven deployment would generate a value for `PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX` that likely isn't valid hex, which the PDS requires. Given there's intentionally no AppHost sample in this repo, this path is also untested. Low urgency since it only affects manifest/publish-based provisioning, not the default local flow this PR is verified against. Note: the diff supplied to me was truncated partway through (`ATProtoNet.Pds` deletion listings), so I could not see the actual `PdsAdminClient`, `XrpcClient.SetAdminCredentials`, or `AddAtProtoPdsAdmin` implementations, nor the new tests — the security-relevant claims (unauthenticated signup call, loopback-only HTTP enforcement, fixed-time admin auth) are stated in the description but not independently verifiable from what I reviewed. --- ⏱ 1m 22s · 1 turns · tokens: 77.0k in / 8.2k out (+23.7k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-68/run-70.md) <!-- claude-reviewed-head:19880a4c11e9ae980d96b2a1903205936a908335 -->
@ -0,0 +22,4 @@
public override void WriteToManifest(ManifestPublishingContext context)
{
context.Writer.WriteStartObject("generate");
Collaborator

WriteToManifest emits a generic {"generate": {"minLength": ...}} directive with no charset restriction. Aspire's manifest-based generation (used by external provisioning tools, e.g. azd) will produce an arbitrary alphanumeric string of minLength characters rather than a hex string — but this value ends up in PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX (and PDS_JWT_SECRET), which the PDS container expects to be hex. Only the in-process GetDefaultValue() path (local dotnet run) actually produces valid hex. Worth constraining the manifest schema to a hex-only charset, or documenting that manifest-published deployments must override these parameters explicitly.

`WriteToManifest` emits a generic `{"generate": {"minLength": ...}}` directive with no charset restriction. Aspire's manifest-based generation (used by external provisioning tools, e.g. `azd`) will produce an arbitrary alphanumeric string of `minLength` characters rather than a hex string — but this value ends up in `PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX` (and `PDS_JWT_SECRET`), which the PDS container expects to be hex. Only the in-process `GetDefaultValue()` path (local `dotnet run`) actually produces valid hex. Worth constraining the manifest schema to a hex-only charset, or documenting that manifest-published deployments must override these parameters explicitly.
fix(aspire): don't generate hex PDS secrets in publish mode
All checks were successful
CI / build-and-test (pull_request) Successful in 38s
f7e8927340
An Aspire manifest's `generate` instruction can only describe an
alphanumeric string, so a manifest-driven deployment would provision a
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX the server rejects — the local
run-mode value was correct hex, but the published instruction was not.

Generate (and persist) the hex secrets in run mode only. In publish mode
the two parameters carry no default, so the value must be supplied at
deploy time instead of being generated wrongly. HexSecretParameterDefault
now throws if it is ever asked to describe itself in a manifest.

Found in review of #68.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Good catch on the manifest gap — that was a real bug, now fixed in f7e8927.

You're right that {"generate": {"minLength": 64}} tells provisioning tooling to produce an alphanumeric string, and Aspire's generator has no hex mode at all (its alphabet is 23 lower + 23 upper + 10 numeric + 11 special, deliberately excluding i/l/o), so there was no way to express the requirement correctly in a manifest. Local dotnet run produced valid hex; a published deployment would have provisioned a PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX the PDS rejects.

Rather than emit a wrong instruction, the secrets are now generated in run mode only:

if (builder.ExecutionContext.IsPublishMode)
{
    return builder.AddParameter(name, secret: true).Resource;   // must be supplied at deploy time
}

return builder
    .AddParameter(name, new HexSecretParameterDefault(byteCount), secret: true, persist: true)
    .Resource;

In publish mode the parameter carries no default, so the deployment has to supply the value — failing closed instead of provisioning something invalid. HexSecretParameterDefault.WriteToManifest now throws rather than emitting the misleading block, so the path can't silently come back. Two tests cover it: one builds a genuine publish-mode builder (asserting ExecutionContext.IsPublishMode) and checks both parameters have no Default, the other asserts the throw names WithPlcRotationKey. Documented in docs/managed-pds.md and the CHANGELOG.

On the truncated diff: understood, and worth stating plainly that the security-relevant claims are test-backed rather than just asserted in the description. The three you couldn't see:

  • Unauthenticated signupPdsAdminClient holds two XrpcClients over one HttpClient; only one carries the admin credential. com.atproto.server.createAccount goes through the other. CreateAccountAsync_SendsSignupWithoutAdminCredentials asserts the signup request's Authorization header is null.
  • Loopback-only HTTP — the constructor rejects any non-HTTPS URL that isn't loopback, covered by Constructor_WithPlaintextHttpUrl_Throws / Constructor_WithLoopbackHttpUrl_IsAllowed.
  • Fixed-time admin auth — that one was the removed in-process PDS's endpoint-side comparison, not something this PR adds. PdsAdminClient is a client: it sends Authorization: Basic base64(user:password) and the reference PDS does the comparing. Nothing in this PR does credential verification, so there's no constant-time claim to verify here.

1067 unit tests pass.

Good catch on the manifest gap — that was a real bug, now fixed in f7e8927. You're right that `{"generate": {"minLength": 64}}` tells provisioning tooling to produce an alphanumeric string, and Aspire's generator has no hex mode at all (its alphabet is 23 lower + 23 upper + 10 numeric + 11 special, deliberately excluding `i/l/o`), so there was no way to express the requirement correctly in a manifest. Local `dotnet run` produced valid hex; a published deployment would have provisioned a `PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX` the PDS rejects. Rather than emit a wrong instruction, the secrets are now generated in **run mode only**: ```csharp if (builder.ExecutionContext.IsPublishMode) { return builder.AddParameter(name, secret: true).Resource; // must be supplied at deploy time } return builder .AddParameter(name, new HexSecretParameterDefault(byteCount), secret: true, persist: true) .Resource; ``` In publish mode the parameter carries no default, so the deployment has to supply the value — failing closed instead of provisioning something invalid. `HexSecretParameterDefault.WriteToManifest` now throws rather than emitting the misleading block, so the path can't silently come back. Two tests cover it: one builds a genuine publish-mode builder (asserting `ExecutionContext.IsPublishMode`) and checks both parameters have no `Default`, the other asserts the throw names `WithPlcRotationKey`. Documented in `docs/managed-pds.md` and the CHANGELOG. On the truncated diff: understood, and worth stating plainly that the security-relevant claims are test-backed rather than just asserted in the description. The three you couldn't see: - **Unauthenticated signup** — `PdsAdminClient` holds two `XrpcClient`s over one `HttpClient`; only one carries the admin credential. `com.atproto.server.createAccount` goes through the other. `CreateAccountAsync_SendsSignupWithoutAdminCredentials` asserts the signup request's `Authorization` header is null. - **Loopback-only HTTP** — the constructor rejects any non-HTTPS URL that isn't loopback, covered by `Constructor_WithPlaintextHttpUrl_Throws` / `Constructor_WithLoopbackHttpUrl_IsAllowed`. - **Fixed-time admin auth** — that one was the *removed* in-process PDS's endpoint-side comparison, not something this PR adds. `PdsAdminClient` is a client: it sends `Authorization: Basic base64(user:password)` and the reference PDS does the comparing. Nothing in this PR does credential verification, so there's no constant-time claim to verify here. 1067 unit tests pass.
Author
Owner

@claude-bot Please do another review.

@claude-bot Please do another review.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/71); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-71.md) — updating every ~5 minutes while the task runs.
Collaborator

I reviewed the full branch this time (the earlier truncation is gone), and independently built and ran the suite.

Verified

Installed the .NET 10 SDK in this container and ran the canonical check on feat/managed-pds-aspire @ f7e8927:

  • dotnet test tests/ATProtoNet.Tests/ -p:EnableSourceControlManagerQueries=false1067 passed, 0 failed.
  • Build is clean apart from the repo's pre-existing CS1591 noise (plus one new warning, below).

The three security claims I couldn't check last time now hold up against the code, not just the description:

  • Unauthenticated signupPdsAdminClient really does hold two XrpcClients over one HttpClient (PdsAdminClient.cs:115-117), and createAccount goes through _publicXrpc (:238). CreateAccountAsync_SendsSignupWithoutAdminCredentials asserts the header is absent.
  • Loopback-only HTTPPdsAdminClient.cs:88-94, covered both ways.
  • Fixed-time comparison — agreed, that belonged to the removed in-process endpoint; nothing here verifies credentials, so there's no claim to check.

The publish-mode fix is the right call and the tests are honest ones — AddAtProtoPds_InPublishMode_DoesNotGenerateHexSecrets asserts ExecutionContext.IsPublishMode before checking Default is null, and HexSecretParameterDefault.WriteToManifest now throws with a message naming the override. Failing closed beats provisioning a key the PDS rejects.

New findings

1. The HTTPS guard rejects the URL WithAtProtoPds itself supplies to a containerized consumer. (PdsAdminClient.cs:88)

WithAtProtoPds sets AtProto__Pds__Url from pds.GetEndpoint("http"). For a project running on the host that resolves to http://localhost:<port> — loopback, fine. But Aspire 13's ExpressionResolver rewrites endpoint references to the container-network hostname when the consumer is a container (I confirmed ExpressionResolver / GetContainerHostNameAsync are present in Aspire.Hosting 13.4.6), so a containerized consumer gets http://pds:3000 — not HTTPS, not loopback — and AddAtProtoPdsAdmin() throws ArgumentException while resolving the singleton. Same for any published deployment where the PDS is reached over plaintext inside a private network. AtProtoPdsHostingExtensionsTests wires exactly that shape (AddContainer("web", "nginx").WithAtProtoPds(pds)), so it's a supported topology, and PdsAdminOptions offers no opt-out. An explicit AllowInsecureHttp (default false) would keep the guard meaningful while letting a private-network deployment through deliberately.

2. The workaround people will find for #1 bypasses the guard silently. (PdsAdminClient.cs:107)

_httpClient.BaseAddress ??= baseUri; — when the caller supplies an HttpClient that already has a BaseAddress, the guard validated options.Url but the requests (admin Authorization: Basic included) go to the client's address. So configuring the named client with http://pds:3000 while leaving options.Url as https://… satisfies the check and sends the password in the clear, with nothing to warn you. Validating the effective base address — or throwing when the two disagree — would close it. Low severity on its own; it's mostly notable as the pressure-release valve for #1.

3. Singleton pins one factory-created HttpClient for the process lifetime. (PdsAdminExtensions.cs:112-115)

AddHttpClient + a singleton that captures CreateClient() once means the pooled handler never rotates, so DNS changes behind the PDS URL are never picked up. Minor for a fixed local container; worth knowing for a long-running deployment.

4. Nit: new CS0618 warnings on a touched file. AtProtoPdsHostingExtensionsTests.cs:318-319 use the obsolete ParameterResource.Value; GetValueAsync() is the replacement. CLAUDE.md asks for new warnings on touched files to be addressed.

None of these block the PR as scoped — #1 is the only one I'd want a decision on before this lands, since it's reachable through the documented wiring rather than a hand-rolled setup. I pushed no changes; the branch is untouched.


⏱ 3m 44s · 32 turns · tokens: 96.8k in / 14.7k out (+1561.2k cache-read)
📜 Full session transcript

I reviewed the full branch this time (the earlier truncation is gone), and independently built and ran the suite. ## Verified Installed the .NET 10 SDK in this container and ran the canonical check on `feat/managed-pds-aspire` @ `f7e8927`: - `dotnet test tests/ATProtoNet.Tests/ -p:EnableSourceControlManagerQueries=false` → **1067 passed, 0 failed**. - Build is clean apart from the repo's pre-existing `CS1591` noise (plus one new warning, below). The three security claims I couldn't check last time now hold up against the code, not just the description: - **Unauthenticated signup** — `PdsAdminClient` really does hold two `XrpcClient`s over one `HttpClient` (`PdsAdminClient.cs:115-117`), and `createAccount` goes through `_publicXrpc` (`:238`). `CreateAccountAsync_SendsSignupWithoutAdminCredentials` asserts the header is absent. - **Loopback-only HTTP** — `PdsAdminClient.cs:88-94`, covered both ways. - **Fixed-time comparison** — agreed, that belonged to the removed in-process endpoint; nothing here verifies credentials, so there's no claim to check. The publish-mode fix is the right call and the tests are honest ones — `AddAtProtoPds_InPublishMode_DoesNotGenerateHexSecrets` asserts `ExecutionContext.IsPublishMode` before checking `Default is null`, and `HexSecretParameterDefault.WriteToManifest` now throws with a message naming the override. Failing closed beats provisioning a key the PDS rejects. ## New findings **1. The HTTPS guard rejects the URL `WithAtProtoPds` itself supplies to a containerized consumer.** (`PdsAdminClient.cs:88`) `WithAtProtoPds` sets `AtProto__Pds__Url` from `pds.GetEndpoint("http")`. For a project running on the host that resolves to `http://localhost:<port>` — loopback, fine. But Aspire 13's `ExpressionResolver` rewrites endpoint references to the container-network hostname when the *consumer* is a container (I confirmed `ExpressionResolver` / `GetContainerHostNameAsync` are present in `Aspire.Hosting` 13.4.6), so a containerized consumer gets `http://pds:3000` — not HTTPS, not loopback — and `AddAtProtoPdsAdmin()` throws `ArgumentException` while resolving the singleton. Same for any published deployment where the PDS is reached over plaintext inside a private network. `AtProtoPdsHostingExtensionsTests` wires exactly that shape (`AddContainer("web", "nginx").WithAtProtoPds(pds)`), so it's a supported topology, and `PdsAdminOptions` offers no opt-out. An explicit `AllowInsecureHttp` (default `false`) would keep the guard meaningful while letting a private-network deployment through deliberately. **2. The workaround people will find for #1 bypasses the guard silently.** (`PdsAdminClient.cs:107`) `_httpClient.BaseAddress ??= baseUri;` — when the caller supplies an `HttpClient` that already has a `BaseAddress`, the guard validated `options.Url` but the requests (admin `Authorization: Basic` included) go to the client's address. So configuring the named client with `http://pds:3000` while leaving `options.Url` as `https://…` satisfies the check and sends the password in the clear, with nothing to warn you. Validating the effective base address — or throwing when the two disagree — would close it. Low severity on its own; it's mostly notable as the pressure-release valve for #1. **3. Singleton pins one factory-created `HttpClient` for the process lifetime.** (`PdsAdminExtensions.cs:112-115`) `AddHttpClient` + a singleton that captures `CreateClient()` once means the pooled handler never rotates, so DNS changes behind the PDS URL are never picked up. Minor for a fixed local container; worth knowing for a long-running deployment. **4. Nit: new `CS0618` warnings on a touched file.** `AtProtoPdsHostingExtensionsTests.cs:318-319` use the obsolete `ParameterResource.Value`; `GetValueAsync()` is the replacement. CLAUDE.md asks for new warnings on touched files to be addressed. None of these block the PR as scoped — #1 is the only one I'd want a decision on before this lands, since it's reachable through the documented wiring rather than a hand-rolled setup. I pushed no changes; the branch is untouched. --- ⏱ 3m 44s · 32 turns · tokens: 96.8k in / 14.7k out (+1561.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-71.md)
fix(pds-admin): make the HTTPS guard work with Aspire's container network
All checks were successful
CI / build-and-test (pull_request) Successful in 39s
e22dcd5e71
Four fixes from review of #68.

The HTTPS guard rejected the URL WithAtProtoPds itself supplies. Aspire
resolves an endpoint reference to the container-network host when the
consumer is a container, so a containerized consumer got http://pds:3000
— neither HTTPS nor loopback — and AddAtProtoPdsAdmin threw while
resolving the client. Add PdsAdminOptions.AllowInsecureHttp (default
false); WithAtProtoPds sets it in run mode, where both resources share a
local network, but not when publishing, so sending the admin password
unencrypted across a deployed network stays an explicit decision.

The guard also validated options.Url while requests went to the supplied
HttpClient's BaseAddress, so configuring a client with a plaintext
address and leaving the URL as https:// sent the password in the clear.
Validate the effective base address instead.

Register PdsAdminClient as a typed HttpClient rather than a singleton
capturing one client, so the factory rotates the handler and DNS changes
behind the PDS URL are picked up.

Drop the obsolete ParameterResource.Value from the Aspire tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Thanks for building and running it independently — all four findings were real and all four are fixed in e22dcd5. 1074 tests pass, build clean with no new warnings.

1. HTTPS guard vs. the container network — you were right, and this was the one that mattered. The guard rejected the URL WithAtProtoPds itself supplies. Confirmed the mechanism from Aspire's own docs on ExpressionResolver.ResolveInternalAsync: "When it is being used from inside a container, endpoints may be evaluated (either in a container-to-container or container-to-exe communication)." So the topology my own test wires up (AddContainer("web", "nginx").WithAtProtoPds(pds)) would have thrown at singleton resolution.

Added PdsAdminOptions.AllowInsecureHttp (default false) as you suggested, with one refinement: WithAtProtoPds sets it in run mode only.

if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
    builder = builder.WithEnvironment(AllowInsecureHttpConfigurationKey, "true");
}

Locally the PDS container and its consumer share one network, so the AppHost can assert that itself and the documented flow works out of the box. Published deployments are left to decide deliberately — either front the PDS with TLS or set the key — because that's where "admin password in cleartext" stops being obviously fine. The error message names both AllowInsecureHttp and the config key, so the fix is discoverable either way. Two tests cover the split (run mode sets it; publish mode, asserted via ExecutionContext.IsPublishMode, does not).

2. The bypass. Fixed properly rather than papered over — the check moved after BaseAddress ??= and now validates the effective address, so the pressure-release valve you predicted is closed at the same time as #1. Test supplies an HttpClient with BaseAddress = http://pds:3000/ alongside Url = https://pds.example.com and asserts the throw names the address requests would actually reach.

3. Pinned handler. Now a typed client:

services
    .AddHttpClient(nameof(PdsAdminClient), httpClient => httpClient.BaseAddress = baseAddress)
    .AddTypedClient((httpClient, sp) => new PdsAdminClient(options, httpClient, sp.GetService<ILogger<PdsAdminClient>>()));

Worth flagging a trap I hit on the way: AddHttpClient<PdsAdminClient>((sp, httpClient) => new PdsAdminClient(...)) compiles but silently binds to the Action<IServiceProvider, HttpClient> configure overload — the returned instance is discarded and the client gets activated through ActivatorUtilities instead, which then can't resolve PdsAdminOptions. Tests caught it. Lifetime is now transient, and the DI test asserts that explicitly rather than the old Assert.Same.

4. CS0618. Gone — GetValueAsync(), test made async. Verified with a clean --no-incremental build: zero CS0618, only the repo's pre-existing CS1591 noise.

Docs and CHANGELOG updated for the new option and the run/publish split.

Thanks for building and running it independently — all four findings were real and all four are fixed in e22dcd5. 1074 tests pass, build clean with no new warnings. **1. HTTPS guard vs. the container network — you were right, and this was the one that mattered.** The guard rejected the URL `WithAtProtoPds` itself supplies. Confirmed the mechanism from Aspire's own docs on `ExpressionResolver.ResolveInternalAsync`: *"When it is being used from inside a container, endpoints may be evaluated (either in a container-to-container or container-to-exe communication)."* So the topology my own test wires up (`AddContainer("web", "nginx").WithAtProtoPds(pds)`) would have thrown at singleton resolution. Added `PdsAdminOptions.AllowInsecureHttp` (default `false`) as you suggested, with one refinement: `WithAtProtoPds` sets it **in run mode only**. ```csharp if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { builder = builder.WithEnvironment(AllowInsecureHttpConfigurationKey, "true"); } ``` Locally the PDS container and its consumer share one network, so the AppHost can assert that itself and the documented flow works out of the box. Published deployments are left to decide deliberately — either front the PDS with TLS or set the key — because that's where "admin password in cleartext" stops being obviously fine. The error message names both `AllowInsecureHttp` and the config key, so the fix is discoverable either way. Two tests cover the split (run mode sets it; publish mode, asserted via `ExecutionContext.IsPublishMode`, does not). **2. The bypass.** Fixed properly rather than papered over — the check moved after `BaseAddress ??=` and now validates the effective address, so the pressure-release valve you predicted is closed at the same time as #1. Test supplies an `HttpClient` with `BaseAddress = http://pds:3000/` alongside `Url = https://pds.example.com` and asserts the throw names the address requests would actually reach. **3. Pinned handler.** Now a typed client: ```csharp services .AddHttpClient(nameof(PdsAdminClient), httpClient => httpClient.BaseAddress = baseAddress) .AddTypedClient((httpClient, sp) => new PdsAdminClient(options, httpClient, sp.GetService<ILogger<PdsAdminClient>>())); ``` Worth flagging a trap I hit on the way: `AddHttpClient<PdsAdminClient>((sp, httpClient) => new PdsAdminClient(...))` compiles but silently binds to the `Action<IServiceProvider, HttpClient>` *configure* overload — the returned instance is discarded and the client gets activated through `ActivatorUtilities` instead, which then can't resolve `PdsAdminOptions`. Tests caught it. Lifetime is now transient, and the DI test asserts that explicitly rather than the old `Assert.Same`. **4. CS0618.** Gone — `GetValueAsync()`, test made async. Verified with a clean `--no-incremental` build: zero CS0618, only the repo's pre-existing CS1591 noise. Docs and CHANGELOG updated for the new option and the run/publish split.
feat(samples): add the Aspire AppHost sample, and fix what it exposed
All checks were successful
CI / build-and-test (pull_request) Successful in 41s
479c22213b
Adds samples/ManagedPdsSample.AppHost — the six lines that wire the PDS
container to the sample API. It closes the gap noted in review: nothing
in the repo exercised the publish path against real Aspire.

Generating its manifest immediately found two local-only defaults being
baked into a deployment:

- PDS_HOSTNAME=localhost, which fixes the server's did:web identity and
  its handle domain. A PDS deployed that way issues identities nothing
  can resolve. It now defaults to localhost only when running locally;
  publishing creates a {name}-hostname parameter for the deployment to
  supply. WithHostname gained a ParameterResource overload.
- PDS_DEV_MODE=true, whose relaxed checks are not meant for production.
  Now set in run mode only; unset, the container defaults it off.

The manifest also confirms end to end what was previously only asserted
at the app-model level: both hex secrets carry no generated default, and
AtProto__Pds__AllowInsecureHttp is absent when publishing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Closed the remaining gap: samples/ManagedPdsSample.AppHost is now in the repo and in the solution, and generating its manifest immediately earned its keep — it found two more bugs that neither review nor the app-model unit tests could see.

What the manifest showed

PDS_HOSTNAME = localhost
PDS_DEV_MODE = true

Both local-only defaults, baked straight into a deployment manifest. The hostname is the serious one: it fixes the server's did:web identity and the domain new handles are created under, so a PDS deployed that way issues identities nothing on the network can resolve. Dev mode's relaxed checks aren't meant for production either.

Fixed with the same principle as the secrets — local defaults in run mode, explicit when publishing:

  • PDS_HOSTNAME defaults to localhost locally; publishing creates a {name}-hostname parameter for the deployment to supply. WithHostname gained a ParameterResource overload alongside the string one.
  • PDS_DEV_MODE=true is set in run mode only. Unset, the container defaults it off, so no operator action is needed for the safe outcome.

The regenerated manifest is now what a deployment should get:

"pds": { "env": {
  "PDS_DATA_DIRECTORY": "/pds",
  "PDS_HOSTNAME": "{pds-hostname.value}",
  "PDS_ADMIN_PASSWORD": "{pds-admin-password.value}",
  "PDS_JWT_SECRET": "{pds-jwt-secret.value}",
  "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX": "{pds-plc-rotation-key.value}"
}}

"pds-jwt-secret":       { "inputs": { "value": { "type": "string", "secret": true } } }
"pds-plc-rotation-key": { "inputs": { "value": { "type": "string", "secret": true } } }
"pds-hostname":         { "inputs": { "value": { "type": "string" } } }
"pds-admin-password":   { "inputs": { "value": { "type": "string", "secret": true,
                            "default": { "generate": { "minLength": 22, "special": false } } } } }

That also confirms end to end, against real Aspire rather than at the app-model level, what the earlier fixes only asserted indirectly: both hex secrets carry no generated default, pds-admin-password keeps its alphanumeric one (correct for a password), and AtProto__Pds__AllowInsecureHttp is absent from the api env when publishing.

Anyone can reproduce it in one command, and it's documented in the sample README:

dotnet run --project samples/ManagedPdsSample.AppHost -- \
  --publisher manifest --output-path manifest.json

One thing to know about CI

Adding the AppHost to the solution costs ~247 MB of extra restore per CI run (aspire.hosting.orchestration.linux-x64 is 185 MB, aspire.dashboard.sdk 56 MB) — more than the ~150 MB I estimated when I originally skipped it. CI can only build it, not run it, since there's no container runtime there. The build is still a real drift check: an API change to AddAtProtoPds/WithAtProtoPds now breaks the build rather than rotting a sample silently. Easy to drop from ATProto.NET.slnx if the restore cost isn't worth it — the sample stays usable either way.

1077 tests pass, build clean, no new warnings (including the xUnit2031 one I introduced and fixed on the way).

Closed the remaining gap: `samples/ManagedPdsSample.AppHost` is now in the repo and in the solution, and generating its manifest immediately earned its keep — it found **two more bugs** that neither review nor the app-model unit tests could see. ## What the manifest showed ``` PDS_HOSTNAME = localhost PDS_DEV_MODE = true ``` Both local-only defaults, baked straight into a deployment manifest. The hostname is the serious one: it fixes the server's `did:web` identity and the domain new handles are created under, so a PDS deployed that way issues identities nothing on the network can resolve. Dev mode's relaxed checks aren't meant for production either. Fixed with the same principle as the secrets — local defaults in run mode, explicit when publishing: - `PDS_HOSTNAME` defaults to `localhost` locally; publishing creates a `{name}-hostname` parameter for the deployment to supply. `WithHostname` gained a `ParameterResource` overload alongside the string one. - `PDS_DEV_MODE=true` is set in run mode only. Unset, the container defaults it off, so no operator action is needed for the safe outcome. The regenerated manifest is now what a deployment should get: ```json "pds": { "env": { "PDS_DATA_DIRECTORY": "/pds", "PDS_HOSTNAME": "{pds-hostname.value}", "PDS_ADMIN_PASSWORD": "{pds-admin-password.value}", "PDS_JWT_SECRET": "{pds-jwt-secret.value}", "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX": "{pds-plc-rotation-key.value}" }} "pds-jwt-secret": { "inputs": { "value": { "type": "string", "secret": true } } } "pds-plc-rotation-key": { "inputs": { "value": { "type": "string", "secret": true } } } "pds-hostname": { "inputs": { "value": { "type": "string" } } } "pds-admin-password": { "inputs": { "value": { "type": "string", "secret": true, "default": { "generate": { "minLength": 22, "special": false } } } } } ``` That also confirms end to end, against real Aspire rather than at the app-model level, what the earlier fixes only asserted indirectly: **both hex secrets carry no generated default**, `pds-admin-password` keeps its alphanumeric one (correct for a password), and `AtProto__Pds__AllowInsecureHttp` is absent from the `api` env when publishing. Anyone can reproduce it in one command, and it's documented in the sample README: ```bash dotnet run --project samples/ManagedPdsSample.AppHost -- \ --publisher manifest --output-path manifest.json ``` ## One thing to know about CI Adding the AppHost to the solution costs **~247 MB of extra restore per CI run** (`aspire.hosting.orchestration.linux-x64` is 185 MB, `aspire.dashboard.sdk` 56 MB) — more than the ~150 MB I estimated when I originally skipped it. CI can only *build* it, not run it, since there's no container runtime there. The build is still a real drift check: an API change to `AddAtProtoPds`/`WithAtProtoPds` now breaks the build rather than rotting a sample silently. Easy to drop from `ATProto.NET.slnx` if the restore cost isn't worth it — the sample stays usable either way. 1077 tests pass, build clean, no new warnings (including the xUnit2031 one I introduced and fixed on the way).
fix(pds): make the container actually start, and the admin API actually work
Some checks failed
CI / build-and-test (pull_request) Successful in 45s
CI / pds-integration (pull_request) Failing after 5m33s
e66e4474ff
Running the reference PDS for the first time — now automated in CI — found
two bugs that no amount of app-model testing would have caught.

AddAtProtoPds() never configured a blobstore, so the container exited on
startup with "Must configure either S3 or disk blobstore". Every
`AddAtProtoPds("pds")` since the integration shipped in 0.4.0 produced a
crash-looping container. It now sets PDS_BLOBSTORE_DISK_LOCATION under the
data volume. The same omission was in every documented `podman run`
snippet, including CONTRIBUTING.md.

Seven AdminClient methods asked for a deserialized response from endpoints
the PDS answers with an empty body, so DeleteAccount, UpdateAccountHandle,
UpdateAccountEmail, UpdateAccountPassword, Disable/EnableAccountInvites and
DisableInviteCodes all threw JsonException regardless of the server's
answer. They now use the body-only ProcedureAsync<TRequest> overload that
already existed.

Also corrects the handle domain in the docs: with PDS_HOSTNAME=localhost
the reference PDS serves `.test`, not `.localhost`, so the sample's example
handle could never have been accepted.

CI gains two jobs covering the seams: PdsAdminTests against a real PDS
service container, and AspireManifestTests over a manifest the AppHost
sample actually publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ci: give the PDS service container a data directory that exists
All checks were successful
CI / pds-integration (pull_request) Successful in 18s
CI / build-and-test (pull_request) Successful in 46s
31130740a0
The pds-integration job failed: a service container gets no volume, and the
PDS does not create its data directory, so PDS_DATA_DIRECTORY=/pds exited
with "Cannot open database because the directory does not exist" — the same
error hit while verifying locally. Point it at /tmp, which already exists;
the server is ephemeral and nothing needs to outlive the job.

Reproduced locally with no volume mounted: the PDS comes up and all nine
PdsAdminTests pass against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

CI now runs both untested seams, and doing so found two more bugs — including one that meant the headline feature never worked at all.

AddAtProtoPds() produced a container that could not start

Error: Must configure either S3 or disk blobstore
    at envToCfg (@atproto/pds/src/config/config.ts:94:11)

The integration set PDS_DATA_DIRECTORY but no blobstore, and the PDS exits during startup without one. Every builder.AddAtProtoPds("pds") since the integration shipped in 0.4.0 produced a crash-looping container. Nothing caught it because nothing had ever run the image — every test asserted on the Aspire application model, which was perfectly well-formed and describing a container that dies. It now sets PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks under the data volume. The same omission was in every documented podman run snippet, CONTRIBUTING.md included.

Seven admin methods threw on every call

DeleteAccountAsync, UpdateAccountHandleAsync, UpdateAccountEmailAsync, UpdateAccountPasswordAsync, Disable/EnableAccountInvitesAsync and DisableInviteCodesAsync all asked for a deserialized response from endpoints the PDS answers with an empty body:

System.Text.Json.JsonException : The input does not contain any JSON tokens.

They failed regardless of what the server did. The correct body-only ProcedureAsync<TRequest> overload already existed in XrpcClientAdminClient just used ProcedureAsync<TRequest, object> instead. This predates the PR but broke the PdsAdminClient wrappers I documented, so it's fixed here, with a unit test that feeds each one an empty 200.

Unit tests could never have caught it: the mock handler returned {}, which deserializes fine.

Also corrected

availableUserDomains is [".test"], not .localhost — so the sample's alice.localhost was a handle the server would always have rejected. That was the one thing I explicitly flagged as unverified when I first opened this; now it's measured rather than assumed, and the docs say so.

What CI runs now

  • pds-integration — starts the real ghcr.io/bluesky-social/pds as a service container and runs nine PdsAdminTests against it: describe, invite minting, account provisioning, auto-minted invites, handle updates, takedown/restore, deletion, and signing in with the session a provisioned account was handed — the actual promise of the feature, end to end.
  • Manifest verification — publishes the AppHost sample's manifest and asserts on it (AspireManifestTests), covering the publish path with no container runtime needed.

Both green. One iteration was needed: a service container gets no volume and the PDS won't create its data directory, so PDS_DATA_DIRECTORY=/pds hit the same "directory does not exist" error I'd already seen locally. Reproduced it locally with no volume, confirmed /tmp fixes it and all nine tests still pass, then pushed.

Final state: 1078 unit tests + 9 integration tests, clean build, no new warnings.

CI now runs both untested seams, and doing so found **two more bugs — including one that meant the headline feature never worked at all.** ## `AddAtProtoPds()` produced a container that could not start ``` Error: Must configure either S3 or disk blobstore at envToCfg (@atproto/pds/src/config/config.ts:94:11) ``` The integration set `PDS_DATA_DIRECTORY` but no blobstore, and the PDS exits during startup without one. Every `builder.AddAtProtoPds("pds")` since the integration shipped in 0.4.0 produced a crash-looping container. Nothing caught it because nothing had ever run the image — every test asserted on the Aspire application model, which was perfectly well-formed and describing a container that dies. It now sets `PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks` under the data volume. The same omission was in every documented `podman run` snippet, `CONTRIBUTING.md` included. ## Seven admin methods threw on every call `DeleteAccountAsync`, `UpdateAccountHandleAsync`, `UpdateAccountEmailAsync`, `UpdateAccountPasswordAsync`, `Disable`/`EnableAccountInvitesAsync` and `DisableInviteCodesAsync` all asked for a deserialized response from endpoints the PDS answers with an empty body: ``` System.Text.Json.JsonException : The input does not contain any JSON tokens. ``` They failed regardless of what the server did. The correct body-only `ProcedureAsync<TRequest>` overload already existed in `XrpcClient` — `AdminClient` just used `ProcedureAsync<TRequest, object>` instead. This predates the PR but broke the `PdsAdminClient` wrappers I documented, so it's fixed here, with a unit test that feeds each one an empty 200. Unit tests could never have caught it: the mock handler returned `{}`, which deserializes fine. ## Also corrected `availableUserDomains` is `[".test"]`, not `.localhost` — so the sample's `alice.localhost` was a handle the server would always have rejected. That was the one thing I explicitly flagged as unverified when I first opened this; now it's measured rather than assumed, and the docs say so. ## What CI runs now - **`pds-integration`** — starts the real `ghcr.io/bluesky-social/pds` as a service container and runs nine `PdsAdminTests` against it: describe, invite minting, account provisioning, auto-minted invites, handle updates, takedown/restore, deletion, and **signing in with the session a provisioned account was handed** — the actual promise of the feature, end to end. - **Manifest verification** — publishes the AppHost sample's manifest and asserts on it (`AspireManifestTests`), covering the publish path with no container runtime needed. Both green. One iteration was needed: a service container gets no volume and the PDS won't create its data directory, so `PDS_DATA_DIRECTORY=/pds` hit the same "directory does not exist" error I'd already seen locally. Reproduced it locally with no volume, confirmed `/tmp` fixes it and all nine tests still pass, then pushed. Final state: **1078 unit tests + 9 integration tests**, clean build, no new warnings.
Author
Owner

@claude-bot Please do another review.

@claude-bot Please do another review.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/72); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-72.md) — updating every ~5 minutes while the task runs.
Collaborator

I re-reviewed at 3113074, built and ran everything myself, and independently reproduced the manifest.

Verified

  • dotnet build -c Release — 0 errors. Warnings are 35 pre-existing CS1591 plus one pre-existing RZ10012 in ServerIntegrationSample; nothing new from the touched files.
  • dotnet test tests/ATProtoNet.Tests/1078 passed, 0 failed, 5 skipped (the manifest tests, unset env var).
  • Generated the manifest myself (--publisher manifest) and ran AspireManifestTests against it — 6 passed. The output matches what you posted: both hex secrets carry no default, pds-admin-password keeps its generate block, PDS_DEV_MODE is absent, PDS_HOSTNAME is {pds-hostname.value}, and api has no AtProto__Pds__AllowInsecureHttp.

The four fixes from last round hold up in the code: the guard now validates PdsUrl after BaseAddress ??= (so the supplied-client bypass is closed), AddTypedClient is genuinely transient, and XrpcClient.Dispose() is a no-op so the shared HttpClient isn't torn out from under the second client. Finding the blobstore and empty-body bugs by actually running the image is the right kind of result.

New findings

1. The empty-body fix is 7 of 25 call sites. ProcedureAsync<TRequest, TResponse> ends in ReadFromJsonAsync, so it throws on any endpoint the server answers with no body — that's the root cause you found. AdminClient was fixed; 19 ProcedureAsync<…, object> call sites remain elsewhere. I checked each NSID against the upstream lexicons: 18 of the 19 define no output, so they fail identically against any real server.

IdentityClient      updateHandle, submitPlcOperation, requestPlcOperationSignature
SyncClient          notifyOfUpdate, requestCrawl
GraphClient         muteActor, unmuteActor, muteActorList, unmuteActorList,
                    muteThread, unmuteThread
NotificationClient  updateSeen, registerPush
ActorClient         putPreferences
Ozone               communication.deleteTemplate, team.deleteMember,
                    set.addValues, set.deleteValues

Only tools.ozone.set.deleteSet legitimately has an output. Same one-token change (ProcedureAsync<TRequest>), and the unit tests have the same blind spot that hid the admin ones — the mock returns {}, which deserializes fine. PutPreferencesAsync and the mute methods are ordinary Bluesky client calls, so this isn't a corner of the API. Out of this PR's stated scope, but it's the same defect you already diagnosed; say the word and I'll push it with a test in the shape of VoidAdminProcedures_TolerateAnEmptyResponseBody.

2. Both new CI checks fail open. dotnet test --filter exits 0 when every matched test skips — I confirmed it: Skipped! - Failed: 0, Passed: 0, Skipped: 5 … EXIT=0. The manifest step depends on "$PWD/manifest.json" and ${{ github.workspace }}/manifest.json naming the same file; if they ever diverge, cat manifest.json still succeeds and all six tests skip to a green step that asserted nothing. pds-integration has the same shape — rename or drop ATPROTO_PDS_ADMIN_PASSWORD and nine tests silently become zero. Given this PR exists precisely because a check that was never run went unnoticed, worth hardening: use --output-path "$ATPROTO_ASPIRE_MANIFEST" so there's one variable rather than two that must agree, and gate the skip attributes on something like ATPROTO_REQUIRE_INTEGRATION=1 that turns Skip into a failure in CI.

3. Publish mode always creates pds-hostname, even when WithHostname supplies one. AddAtProtoPds adds the parameter before any override can run, and WithHostname only reassigns resource.Hostname — the parameter resource stays in the model. I confirmed with a scratch test: AddAtProtoPds("pds").WithHostname("pds.example.com") in publish mode still leaves pds-jwt-secret, pds-plc-rotation-key, pds-hostname, so a deployment is prompted for a hostname value nothing consumes. Creating it lazily, or dropping it when a literal is set, would fix it.

4. Nit: the docs say "singleton" where the code is now transient. Three XML doc summaries on PdsAdminExtensions still say "Registers a PdsAdminClient singleton" (PdsAdminExtensions.cs:23, :77, :119), which the new AddAtProtoPdsAdmin_RegistersAsTypedHttpClient test directly contradicts.

5. Minor: transient IDisposable resolved from the root provider. AddTypedClient registers PdsAdminClient as transient and it implements IDisposable, so root-provider resolutions (a singleton dependency, or app.Services.GetRequiredService) are tracked and never released. The documented shape — endpoint injection, i.e. request scope — is unaffected, and Dispose neither owns the HttpClient nor does real work, so it's a small object leak rather than handle exhaustion. A doc line is probably enough.

Nothing here blocks the PR. #1 is the one I'd act on, since it's a bug this PR already understands that's live in the shipped client API. I pushed no changes; the branch is untouched at 3113074.


⏱ 5m 22s · 33 turns · tokens: 84.2k in / 20.7k out (+2290.9k cache-read)
📜 Full session transcript

I re-reviewed at `3113074`, built and ran everything myself, and independently reproduced the manifest. ## Verified - `dotnet build -c Release` — 0 errors. Warnings are 35 pre-existing `CS1591` plus one pre-existing `RZ10012` in `ServerIntegrationSample`; nothing new from the touched files. - `dotnet test tests/ATProtoNet.Tests/` — **1078 passed, 0 failed**, 5 skipped (the manifest tests, unset env var). - Generated the manifest myself (`--publisher manifest`) and ran `AspireManifestTests` against it — **6 passed**. The output matches what you posted: both hex secrets carry no `default`, `pds-admin-password` keeps its `generate` block, `PDS_DEV_MODE` is absent, `PDS_HOSTNAME` is `{pds-hostname.value}`, and `api` has no `AtProto__Pds__AllowInsecureHttp`. The four fixes from last round hold up in the code: the guard now validates `PdsUrl` after `BaseAddress ??=` (so the supplied-client bypass is closed), `AddTypedClient` is genuinely transient, and `XrpcClient.Dispose()` is a no-op so the shared `HttpClient` isn't torn out from under the second client. Finding the blobstore and empty-body bugs by actually running the image is the right kind of result. ## New findings **1. The empty-body fix is 7 of 25 call sites.** `ProcedureAsync<TRequest, TResponse>` ends in `ReadFromJsonAsync`, so it throws on any endpoint the server answers with no body — that's the root cause you found. `AdminClient` was fixed; 19 `ProcedureAsync<…, object>` call sites remain elsewhere. I checked each NSID against the upstream lexicons: **18 of the 19 define no `output`**, so they fail identically against any real server. ``` IdentityClient updateHandle, submitPlcOperation, requestPlcOperationSignature SyncClient notifyOfUpdate, requestCrawl GraphClient muteActor, unmuteActor, muteActorList, unmuteActorList, muteThread, unmuteThread NotificationClient updateSeen, registerPush ActorClient putPreferences Ozone communication.deleteTemplate, team.deleteMember, set.addValues, set.deleteValues ``` Only `tools.ozone.set.deleteSet` legitimately has an output. Same one-token change (`ProcedureAsync<TRequest>`), and the unit tests have the same blind spot that hid the admin ones — the mock returns `{}`, which deserializes fine. `PutPreferencesAsync` and the mute methods are ordinary Bluesky client calls, so this isn't a corner of the API. Out of this PR's stated scope, but it's the same defect you already diagnosed; say the word and I'll push it with a test in the shape of `VoidAdminProcedures_TolerateAnEmptyResponseBody`. **2. Both new CI checks fail open.** `dotnet test --filter` exits 0 when every matched test skips — I confirmed it: `Skipped! - Failed: 0, Passed: 0, Skipped: 5 … EXIT=0`. The manifest step depends on `"$PWD/manifest.json"` and `${{ github.workspace }}/manifest.json` naming the same file; if they ever diverge, `cat manifest.json` still succeeds and all six tests skip to a green step that asserted nothing. `pds-integration` has the same shape — rename or drop `ATPROTO_PDS_ADMIN_PASSWORD` and nine tests silently become zero. Given this PR exists precisely because a check that was never run went unnoticed, worth hardening: use `--output-path "$ATPROTO_ASPIRE_MANIFEST"` so there's one variable rather than two that must agree, and gate the skip attributes on something like `ATPROTO_REQUIRE_INTEGRATION=1` that turns `Skip` into a failure in CI. **3. Publish mode always creates `pds-hostname`, even when `WithHostname` supplies one.** `AddAtProtoPds` adds the parameter before any override can run, and `WithHostname` only reassigns `resource.Hostname` — the parameter resource stays in the model. I confirmed with a scratch test: `AddAtProtoPds("pds").WithHostname("pds.example.com")` in publish mode still leaves `pds-jwt-secret, pds-plc-rotation-key, pds-hostname`, so a deployment is prompted for a hostname value nothing consumes. Creating it lazily, or dropping it when a literal is set, would fix it. **4. Nit: the docs say "singleton" where the code is now transient.** Three XML doc summaries on `PdsAdminExtensions` still say "Registers a `PdsAdminClient` singleton" (`PdsAdminExtensions.cs:23`, `:77`, `:119`), which the new `AddAtProtoPdsAdmin_RegistersAsTypedHttpClient` test directly contradicts. **5. Minor: transient `IDisposable` resolved from the root provider.** `AddTypedClient` registers `PdsAdminClient` as transient and it implements `IDisposable`, so root-provider resolutions (a singleton dependency, or `app.Services.GetRequiredService`) are tracked and never released. The documented shape — endpoint injection, i.e. request scope — is unaffected, and `Dispose` neither owns the `HttpClient` nor does real work, so it's a small object leak rather than handle exhaustion. A doc line is probably enough. Nothing here blocks the PR. #1 is the one I'd act on, since it's a bug this PR already understands that's live in the shipped client API. I pushed no changes; the branch is untouched at `3113074`. --- ⏱ 5m 22s · 33 turns · tokens: 84.2k in / 20.7k out (+2290.9k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-72.md)
fix: the empty-body defect was 7 of 26 call sites, not 7
All checks were successful
CI / pds-integration (pull_request) Successful in 17s
CI / build-and-test (pull_request) Successful in 46s
22aeb04081
Review found the same bug live across the rest of the client API. Every
XRPC procedure whose Lexicon declares no output was asking for a
deserialized response, so all of them failed against a real server with
"The input does not contain any JSON tokens" — including PutPreferences,
the six mute/unmute methods, UpdateSeen, RegisterPush and UpdateHandle.
All 19 remaining sites now use the body-only overload. No public
signatures change: each already returned Task and discarded the value.

EmptyResponseBodyTests drives every one through a handler that answers the
way a real PDS does. The old doubles returned `{}`, which deserializes
fine — that is why the defect survived. Verified the test fails when a
call site is reverted.

Also from review:

- Both new CI checks failed open. `dotnet test --filter` exits 0 when
  every matched test skips, so a drifted variable would leave a green
  check asserting nothing. The manifest path is now one variable rather
  than two that must agree, and ATPROTO_REQUIRE_INTEGRATION=1 turns a
  skipped gate into a failure.
- Overriding a generated parameter left the original in the model, so a
  published manifest prompted for a hostname or secret nothing reads.
- Docs said "singleton" where the registration is now transient, and did
  not mention that root-provider resolution never releases it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

All five fixed in 22aeb04. Both CI jobs green. Finding #1 was the right call to act on — and it was worse than 19 stragglers: it was the same bug, and the reason it survived was that the tests were shaped to hide it.

1. Fixed all 19 — and made the tests capable of seeing it

Confirmed your reading: all 19 sit in public async Task methods that discard the result, so the body-only overload changes no public signature and loses nothing. That includes tools.ozone.set.deleteSet — its Lexicon does declare an output, but the method throws the value away today, so deserializing it only creates a way to fail. All 26 call sites now use ProcedureAsync<TRequest>; zero ProcedureAsync<…, object> remain in src/.

The more useful part is EmptyResponseBodyTests: a [Theory] over all 19 NSIDs driving real AtProtoClient calls through a handler that answers 200 with an empty body, the way a real PDS does. As you noted, the old doubles returned {} — which deserializes fine and is precisely why 26 broken methods looked tested. I verified the new test isn't vacuous by reverting one call site and watching that case fail.

It then caught a mistake of mine within the hour: undoing that probe with git checkout <file> also discarded the six uncommitted GraphClient fixes, and the suite failed on exactly those six NSIDs.

2. Both CI checks failed open — confirmed and closed

Reproduced it: Skipped! - Failed: 0, Passed: 0, Skipped: 5 … exit=0.

Took both of your suggestions. --output-path "$ATPROTO_ASPIRE_MANIFEST" means one variable rather than two that must agree, and ATPROTO_REQUIRE_INTEGRATION=1 on both jobs turns a skipped gate into a failure. Verified in both directions:

gate off, no manifest  → Skipped! … exit=0
gate on,  no manifest  → exit=1
   System.InvalidOperationException : Set ATPROTO_ASPIRE_MANIFEST to a manifest produced by …

You put your finger on the thing that makes this PR embarrassing rather than merely buggy: it exists because a check nobody ran went unnoticed, and I'd added two more of the same shape.

3. Orphaned parameter — and it wasn't only the hostname

Confirmed, and the same mechanism affects WithJwtSecret and WithPlcRotationKey: all are created before any override can run, so a superseded one stayed in the model and appeared as a manifest input nothing reads. Overrides now drop the parameter they replace (Replace(...)), covered by a [Theory] across all three in publish mode.

4 & 5. Docs

Three summaries now say "typed HttpClient" rather than "singleton", and both the XML docs and docs/managed-pds.md note that it's transient: inject it into an endpoint or controller, where the request scope releases it; root-provider resolution keeps every instance alive until shutdown.

Verification

  • Build clean; no new warnings.
  • 1101 unit tests pass, 5 skipped (manifest, gate off).
  • Manifest regenerated and asserted with the gate on: 6 passed.
  • 9 integration tests against a live PDS with the gate on: 9 passed.
  • CI: build-and-test and pds-integration both green.
All five fixed in `22aeb04`. Both CI jobs green. Finding #1 was the right call to act on — and it was worse than 19 stragglers: it was the same bug, and the reason it survived was that the tests were shaped to hide it. ## 1. Fixed all 19 — and made the tests capable of seeing it Confirmed your reading: all 19 sit in `public async Task` methods that discard the result, so the body-only overload changes no public signature and loses nothing. That includes `tools.ozone.set.deleteSet` — its Lexicon does declare an output, but the method throws the value away today, so deserializing it only creates a way to fail. All 26 call sites now use `ProcedureAsync<TRequest>`; zero `ProcedureAsync<…, object>` remain in `src/`. The more useful part is `EmptyResponseBodyTests`: a `[Theory]` over all 19 NSIDs driving real `AtProtoClient` calls through a handler that answers **200 with an empty body**, the way a real PDS does. As you noted, the old doubles returned `{}` — which deserializes fine and is precisely why 26 broken methods looked tested. I verified the new test isn't vacuous by reverting one call site and watching that case fail. It then caught a mistake of mine within the hour: undoing that probe with `git checkout <file>` also discarded the six uncommitted `GraphClient` fixes, and the suite failed on exactly those six NSIDs. ## 2. Both CI checks failed open — confirmed and closed Reproduced it: `Skipped! - Failed: 0, Passed: 0, Skipped: 5 … exit=0`. Took both of your suggestions. `--output-path "$ATPROTO_ASPIRE_MANIFEST"` means one variable rather than two that must agree, and `ATPROTO_REQUIRE_INTEGRATION=1` on both jobs turns a skipped gate into a failure. Verified in both directions: ``` gate off, no manifest → Skipped! … exit=0 gate on, no manifest → exit=1 System.InvalidOperationException : Set ATPROTO_ASPIRE_MANIFEST to a manifest produced by … ``` You put your finger on the thing that makes this PR embarrassing rather than merely buggy: it exists because a check nobody ran went unnoticed, and I'd added two more of the same shape. ## 3. Orphaned parameter — and it wasn't only the hostname Confirmed, and the same mechanism affects `WithJwtSecret` and `WithPlcRotationKey`: all are created before any override can run, so a superseded one stayed in the model and appeared as a manifest input nothing reads. Overrides now drop the parameter they replace (`Replace(...)`), covered by a `[Theory]` across all three in publish mode. ## 4 & 5. Docs Three summaries now say "typed `HttpClient`" rather than "singleton", and both the XML docs and `docs/managed-pds.md` note that it's transient: inject it into an endpoint or controller, where the request scope releases it; root-provider resolution keeps every instance alive until shutdown. ## Verification - Build clean; no new warnings. - **1101 unit tests** pass, 5 skipped (manifest, gate off). - Manifest regenerated and asserted with the gate **on**: 6 passed. - **9 integration tests** against a live PDS with the gate on: 9 passed. - CI: `build-and-test` and `pds-integration` both green.
Author
Owner

@claude-bot Please do another review pass.

@claude-bot Please do another review pass.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/73); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-73.md) — updating every ~5 minutes while the task runs.
refactor: scope the empty-body fix to AdminClient, split the rest to #69
All checks were successful
CI / pds-integration (pull_request) Successful in 18s
CI / build-and-test (pull_request) Successful in 47s
632cbb6a41
The empty-body defect reaches 26 call sites across the client API, but only
the seven in AdminClient belong to this PR: the managed-PDS work depends on
them and the live-PDS integration tests here cover them.

The other 19 — mute/unmute, PutPreferences, UpdateSeen, RegisterPush,
UpdateHandle, requestCrawl, and the Ozone mutators — move to #69 / PR #70,
so this PR stops changing the behaviour of clients it has nothing to do
with. The two sets are disjoint and can merge in either order.

Verified after the split: 1081 unit tests pass, and the nine PdsAdminTests
still pass against a live bluesky-social/pds container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator

I reviewed at 22aeb04, built and ran the suite myself, and probed two claims empirically.

Verified

  • dotnet test tests/ATProtoNet.Tests/1101 passed, 0 failed, 5 skipped (the manifest tests, gate off). Build clean.
  • Confirmed zero ProcedureAsync<…, object> remain in src/ — I checked every surviving two-type-arg call site by hand and all 35 name a real response type. EmptyResponseBodyTests is the right shape: an empty 200, not {}, driven through real AtProtoClient methods.
  • The parameter Replace helper does drop the superseded resource, and the run/publish split on PDS_DEV_MODE, PDS_HOSTNAME and the hex secrets reads correctly.
  • The transient/typed-client rewrite and the doc corrections are in place; Dispose correctly leaves a supplied HttpClient alone.

New findings

1. WithDataBindMount produces a container that can't start — the same failure class as the blobstore bug. (AtProtoPdsHostingExtensions.cs:113, :351)

AddAtProtoPds unconditionally adds .WithVolume($"{name}-data", "/pds"), and WithDataBindMount adds a bind mount at the same target. I ran it:

MOUNT type=Volume     source=pds-data      target=/pds
MOUNT type=BindMount  source=/tmp/pds-data target=/pds

Both annotations survive into the container spec. Docker and Podman both reject two mounts on one destination (Duplicate mount point: /pds), so builder.AddAtProtoPds("pds").WithDataBindMount("./pds-data") never comes up — and that's the exact line in docs/managed-pds.md:103, commented "host directory instead of a volume", which is precisely what it doesn't do. Aspire's own integrations treat WithDataVolume() / WithDataBindMount() as mutually exclusive opt-ins for this reason; here the volume is always on. Fix is to drop the volume annotation when a bind mount is supplied. I couldn't run a container in this box to show the daemon rejecting it, which is itself the point: the new pds-integration job doesn't touch Aspire, so nothing in CI would catch this either.

2. The CI hardening closed env-var drift but not filter drift. (.forgejo/workflows/ci.yml:41, :93)

ATPROTO_REQUIRE_INTEGRATION=1 correctly turns a missing prerequisite into a failure. But the other way these steps can match nothing is the filter itself, and I confirmed that still exits 0:

--filter "FullyQualifiedName~ThisClassDoesNotExistAnywhere"  → No test matches … EXIT=0

Rename AspireManifestTests or PdsAdminTests and both steps stay green having asserted nothing — the same failure mode, reached from a different direction. One flag fixes it, verified:

--filter "…DoesNotExist" -- RunConfiguration.TreatNoTestsAsError=true  → EXIT=1

3. Nits.

  • IntegrationGate.ThrowIfRequired (IntegrationGate.cs:44) is never called — AspireManifestTests.Resources() hand-rolls the throw instead.
  • EmptyResponseBodyTests._handler is assigned twice (field initializer at :22, then :28), so one handler is allocated and orphaned.
  • EveryVoidProcedureIsCovered asserts Count == 19, which only fires if someone edits that list — it can't detect a new void procedure added to a Lexicon client, which is the case it's described as guarding.
  • The HTTPS guard now runs at first resolution rather than at startup (the missing-key checks are still eager), so a plaintext-URL misconfiguration surfaces as a request-time exception. Worth a line in the XML docs, since the CHANGELOG says this API "throws at startup".
  • _httpClient.BaseAddress ??= baseUri silently wins over options.Url when both are set. Safe now that the guard validates the effective address, but an admin client quietly talking to a different server than configured could use a log warning.

#1 is the one I'd act on before merge — it's a documented method on a new public API that cannot work. Say the word and I'll push it with a test asserting a single mount at /pds, plus the TreatNoTestsAsError flag for #2. I pushed no changes; the branch is untouched at 22aeb04.


⏱ 5m 12s · 41 turns · tokens: 83.2k in / 18.0k out (+2674.1k cache-read)
📜 Full session transcript

I reviewed at `22aeb04`, built and ran the suite myself, and probed two claims empirically. ## Verified - `dotnet test tests/ATProtoNet.Tests/` → **1101 passed, 0 failed, 5 skipped** (the manifest tests, gate off). Build clean. - Confirmed **zero `ProcedureAsync<…, object>` remain in `src/`** — I checked every surviving two-type-arg call site by hand and all 35 name a real response type. `EmptyResponseBodyTests` is the right shape: an empty 200, not `{}`, driven through real `AtProtoClient` methods. - The parameter `Replace` helper does drop the superseded resource, and the run/publish split on `PDS_DEV_MODE`, `PDS_HOSTNAME` and the hex secrets reads correctly. - The transient/typed-client rewrite and the doc corrections are in place; `Dispose` correctly leaves a supplied `HttpClient` alone. ## New findings **1. `WithDataBindMount` produces a container that can't start — the same failure class as the blobstore bug.** (`AtProtoPdsHostingExtensions.cs:113`, `:351`) `AddAtProtoPds` unconditionally adds `.WithVolume($"{name}-data", "/pds")`, and `WithDataBindMount` adds a bind mount at the *same* target. I ran it: ``` MOUNT type=Volume source=pds-data target=/pds MOUNT type=BindMount source=/tmp/pds-data target=/pds ``` Both annotations survive into the container spec. Docker and Podman both reject two mounts on one destination (`Duplicate mount point: /pds`), so `builder.AddAtProtoPds("pds").WithDataBindMount("./pds-data")` never comes up — and that's the exact line in `docs/managed-pds.md:103`, commented "host directory instead of a volume", which is precisely what it doesn't do. Aspire's own integrations treat `WithDataVolume()` / `WithDataBindMount()` as mutually exclusive opt-ins for this reason; here the volume is always on. Fix is to drop the volume annotation when a bind mount is supplied. I couldn't run a container in this box to show the daemon rejecting it, which is itself the point: the new `pds-integration` job doesn't touch Aspire, so nothing in CI would catch this either. **2. The CI hardening closed env-var drift but not filter drift.** (`.forgejo/workflows/ci.yml:41`, `:93`) `ATPROTO_REQUIRE_INTEGRATION=1` correctly turns a missing prerequisite into a failure. But the *other* way these steps can match nothing is the filter itself, and I confirmed that still exits 0: ``` --filter "FullyQualifiedName~ThisClassDoesNotExistAnywhere" → No test matches … EXIT=0 ``` Rename `AspireManifestTests` or `PdsAdminTests` and both steps stay green having asserted nothing — the same failure mode, reached from a different direction. One flag fixes it, verified: ``` --filter "…DoesNotExist" -- RunConfiguration.TreatNoTestsAsError=true → EXIT=1 ``` **3. Nits.** - `IntegrationGate.ThrowIfRequired` (`IntegrationGate.cs:44`) is never called — `AspireManifestTests.Resources()` hand-rolls the throw instead. - `EmptyResponseBodyTests._handler` is assigned twice (field initializer at `:22`, then `:28`), so one handler is allocated and orphaned. - `EveryVoidProcedureIsCovered` asserts `Count == 19`, which only fires if someone edits that list — it can't detect a *new* void procedure added to a Lexicon client, which is the case it's described as guarding. - The HTTPS guard now runs at first resolution rather than at startup (the missing-key checks are still eager), so a plaintext-URL misconfiguration surfaces as a request-time exception. Worth a line in the XML docs, since the CHANGELOG says this API "throws at startup". - `_httpClient.BaseAddress ??= baseUri` silently wins over `options.Url` when both are set. Safe now that the guard validates the effective address, but an admin client quietly talking to a different server than configured could use a log warning. #1 is the one I'd act on before merge — it's a documented method on a new public API that cannot work. Say the word and I'll push it with a test asserting a single mount at `/pds`, plus the `TreatNoTestsAsError` flag for #2. I pushed no changes; the branch is untouched at `22aeb04`. --- ⏱ 5m 12s · 41 turns · tokens: 83.2k in / 18.0k out (+2674.1k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-73.md)
Author
Owner

Split done, per the review's offer to keep a clean boundary.

  • Issue #69 — the defect written up on its own: cause, all 19 call sites, why the existing tests couldn't see it, and impact on released versions.
  • PR #70 — the fix, branched off main: 19 call sites plus EmptyResponseBodyTests. 1418 unit tests pass.
  • This PR — reverted to the seven AdminClient sites only, which the managed-PDS work depends on and which the live-PDS integration tests here cover. It no longer changes the behaviour of GraphClient, ActorClient, NotificationClient, IdentityClient, SyncClient, or the Ozone clients.

The two sets of call sites are disjoint, so they can merge in either order; both touch CHANGELOG.md, which is the only conflict to expect.

Re-verified after the split: 1081 unit tests pass here, and the nine PdsAdminTests still pass against a live bluesky-social/pds container with ATPROTO_REQUIRE_INTEGRATION=1, so the admin surface remains covered end to end by its own seven fixes. CI green on both branches.

One consequence worth flagging: fix/empty-response-body is based on main, so it runs main's workflow — plain build-and-test, without the pds-integration job or the fail-open hardening, since both arrive with this PR. #70 is therefore verified by unit tests and a local run rather than by the container job. If this PR merges first, a rebase would put #70 under the fuller CI.

Split done, per the review's offer to keep a clean boundary. - **Issue #69** — the defect written up on its own: cause, all 19 call sites, why the existing tests couldn't see it, and impact on released versions. - **PR #70** — the fix, branched off `main`: 19 call sites plus `EmptyResponseBodyTests`. 1418 unit tests pass. - **This PR** — reverted to the seven `AdminClient` sites only, which the managed-PDS work depends on and which the live-PDS integration tests here cover. It no longer changes the behaviour of `GraphClient`, `ActorClient`, `NotificationClient`, `IdentityClient`, `SyncClient`, or the Ozone clients. The two sets of call sites are disjoint, so they can merge in either order; both touch `CHANGELOG.md`, which is the only conflict to expect. Re-verified after the split: **1081 unit tests** pass here, and the **nine `PdsAdminTests` still pass against a live `bluesky-social/pds` container** with `ATPROTO_REQUIRE_INTEGRATION=1`, so the admin surface remains covered end to end by its own seven fixes. CI green on both branches. One consequence worth flagging: `fix/empty-response-body` is based on `main`, so it runs `main`'s workflow — plain `build-and-test`, without the `pds-integration` job or the fail-open hardening, since both arrive with this PR. #70 is therefore verified by unit tests and a local run rather than by the container job. If this PR merges first, a rebase would put #70 under the fuller CI.
fix(aspire): WithDataBindMount produced a container that could not start
All checks were successful
CI / pds-integration (pull_request) Successful in 17s
CI / build-and-test (pull_request) Successful in 46s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 6s
478d2e2846
AddAtProtoPds always mounts a named volume at /pds, and WithDataBindMount
added a second mount on the same destination. Both annotations reached the
container spec, which the runtime rejects — confirmed with podman:

    Error: /pds: duplicate mount destination   (exit 125)

So the line documented in docs/managed-pds.md, commented "host directory
instead of a volume", never came up. Supplying a data mount now replaces
the default rather than adding to it, and WithDataVolume(name?) is added as
the counterpart.

Running the fixed single-mount container surfaced a second, separate trap:
on an SELinux host a bind-mounted directory needs the container label or
the PDS exits with "SqliteError: unable to open database file". Aspire
mounts without relabelling, so that is documented on the method and in the
guide rather than worked around.

Also from review:

- CI hardening closed env-var drift but not filter drift: `dotnet test
  --filter` also exits 0 when it matches nothing, so a renamed test class
  would leave a green step. Verified, and closed with
  TreatNoTestsAsError=true on both steps.
- Dropped IntegrationGate.ThrowIfRequired, which nothing called.
- The plaintext-HTTP guard runs at first resolution, not startup; said so
  on the API and corrected the CHANGELOG's "throws at startup".
- A supplied HttpClient's BaseAddress silently wins over the configured
  URL; now logged as a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

All fixed in 478d2e2, plus the two test nits in #70 (e6a3401). Both branches green.

1. WithDataBindMount — confirmed against a real runtime

You couldn't run a container to show the daemon rejecting it; I could, and it does:

$ podman run --rm -v dup-test-vol:/pds -v /tmp/pds-bind:/pds alpine true
Error: /pds: duplicate mount destination
exit=125

So AddAtProtoPds("pds").WithDataBindMount("./pds-data") — the exact line in docs/managed-pds.md — could never start. Supplying a data mount now replaces the default rather than adding to it, and WithDataVolume(name?) is added as the counterpart (rename the volume, or restore it after a bind mount). Both covered by tests asserting a single mount at /pds of the expected type.

Running the fixed version surfaced a second, independent trap. With one bind mount the container starts and then dies:

SqliteError: unable to open database file

Not the mount count — SELinux. On Fedora/RHEL a bind-mounted directory needs the container label, and Aspire's WithBindMount doesn't relabel. Identical container with -v …:/pds:Z comes up healthy. There's no SELinux option on Aspire's API, so it's documented on the method and in the guide (chcon -Rt container_file_t, or stay on the default volume, which the runtime labels for you) rather than papered over. Worth knowing that the default path — a named volume — was never affected.

2. Filter drift — verified and closed

--filter "…DoesNotExistAnywhere"                                    → exit 0
--filter "…DoesNotExistAnywhere" -- RunConfiguration.TreatNoTestsAsError=true → exit 1

Both steps now carry the flag. Your framing was the useful part: ATPROTO_REQUIRE_INTEGRATION closed missing prerequisite, and this closes matched nothing — same green-but-empty outcome, different route. The comment in the workflow now names both.

3. Nits

  • IntegrationGate.ThrowIfRequired — deleted; nothing called it.
  • _handler double assignment — fixed in #70.
  • EveryVoidProcedureIsCovered — you're right that it couldn't detect what its comment claimed. Replaced with EveryListedProcedureIsDispatchable, which checks what's actually verifiable (no unmapped or duplicated NSID) and says plainly that keeping the list current is manual. Fixed in #70.
  • HTTPS guard timing — correct, it runs at first resolution, not startup. Said so on the API and corrected the CHANGELOG's "throws at startup" claim.
  • BaseAddress ??= — now logs a warning when the supplied client's address differs from the configured URL.

Verification

Build clean, no new warnings. 1083 unit tests, 5 skipped. Manifest regenerated and asserted with the gate on and TreatNoTestsAsError: 6 passed. 9 integration tests against a live PDS, same flags: 9 passed. CI green on both branches.

All fixed in `478d2e2`, plus the two test nits in #70 (`e6a3401`). Both branches green. ## 1. `WithDataBindMount` — confirmed against a real runtime You couldn't run a container to show the daemon rejecting it; I could, and it does: ``` $ podman run --rm -v dup-test-vol:/pds -v /tmp/pds-bind:/pds alpine true Error: /pds: duplicate mount destination exit=125 ``` So `AddAtProtoPds("pds").WithDataBindMount("./pds-data")` — the exact line in `docs/managed-pds.md` — could never start. Supplying a data mount now replaces the default rather than adding to it, and `WithDataVolume(name?)` is added as the counterpart (rename the volume, or restore it after a bind mount). Both covered by tests asserting a *single* mount at `/pds` of the expected type. **Running the fixed version surfaced a second, independent trap.** With one bind mount the container starts and then dies: ``` SqliteError: unable to open database file ``` Not the mount count — SELinux. On Fedora/RHEL a bind-mounted directory needs the container label, and Aspire's `WithBindMount` doesn't relabel. Identical container with `-v …:/pds:Z` comes up healthy. There's no SELinux option on Aspire's API, so it's documented on the method and in the guide (`chcon -Rt container_file_t`, or stay on the default volume, which the runtime labels for you) rather than papered over. Worth knowing that the default path — a named volume — was never affected. ## 2. Filter drift — verified and closed ``` --filter "…DoesNotExistAnywhere" → exit 0 --filter "…DoesNotExistAnywhere" -- RunConfiguration.TreatNoTestsAsError=true → exit 1 ``` Both steps now carry the flag. Your framing was the useful part: `ATPROTO_REQUIRE_INTEGRATION` closed *missing prerequisite*, and this closes *matched nothing* — same green-but-empty outcome, different route. The comment in the workflow now names both. ## 3. Nits - `IntegrationGate.ThrowIfRequired` — deleted; nothing called it. - `_handler` double assignment — fixed in #70. - `EveryVoidProcedureIsCovered` — you're right that it couldn't detect what its comment claimed. Replaced with `EveryListedProcedureIsDispatchable`, which checks what's actually verifiable (no unmapped or duplicated NSID) and says plainly that keeping the list current is manual. Fixed in #70. - HTTPS guard timing — correct, it runs at first resolution, not startup. Said so on the API and corrected the CHANGELOG's "throws at startup" claim. - `BaseAddress ??=` — now logs a warning when the supplied client's address differs from the configured URL. ## Verification Build clean, no new warnings. **1083 unit tests**, 5 skipped. Manifest regenerated and asserted with the gate on and `TreatNoTestsAsError`: 6 passed. **9 integration tests** against a live PDS, same flags: 9 passed. CI green on both branches.
Author
Owner

@claude-bot Please do another review.

@claude-bot Please do another review.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/78); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-78.md) — updating every ~5 minutes while the task runs.
Collaborator

I reviewed at 478d2e2, installed the SDK, built and ran everything myself, and probed the new code empirically rather than reading it.

Verified

  • dotnet build -c Release0 errors. Distinct warning codes: CS1591 (the repo's XML-doc noise), plus CS1573 at XrpcClient.cs:597, CS0419 in PlcOperationBuilder.cs, RZ10012 in ServerIntegrationSample. All three non-CS1591 ones are pre-existing on main — the CS1573 traces to ea508ab (the proxyOverride parameter), not to this PR. No new warnings.
  • dotnet test tests/ATProtoNet.Tests/1083 passed, 0 failed, 5 skipped.
  • Published the manifest myself and ran the six AspireManifestTests with ATPROTO_REQUIRE_INTEGRATION=16 passed. Output matches what you posted byte for byte: both hex secrets with no default, pds-admin-password keeping its generate block, PDS_DEV_MODE absent, PDS_HOSTNAME as {pds-hostname.value}, PDS_BLOBSTORE_DISK_LOCATION present, and no AtProto__Pds__AllowInsecureHttp on api.
  • TreatNoTestsAsError does what you said — measured, not assumed: bogus filter → exit 1 with the flag, exit 0 without.
  • The mount fix holds at the model level: AddAtProtoPds("pds").WithDataBindMount("./pds-data") leaves exactly one annotation (BindMount → /pds) and PDS_BLOBSTORE_DISK_LOCATION still resolves to /pds/blocks. I also checked the ordering hazard the fix could have introduced — WithProductionMode() after AddAtProtoPds still resolves PDS_DEV_MODE=false in run mode, so the run-mode default doesn't win.
  • AdminClient's seven void methods are on ProcedureAsync<TRequest>; exactly 19 ProcedureAsync<…, object> remain, matching the split to #69. XrpcClient.Dispose() is a no-op and nothing anywhere mutates DefaultRequestHeaders, so the one-HttpClient-two-XrpcClients arrangement is sound.
  • No dangling ATProtoNet.Pds references outside the CHANGELOG's own history.

No container runtime in this box, so the nine PdsAdminTests remain verified by you and CI, not by me.

Findings

1. Replace also removes a caller-supplied parameter when an override is applied twice. (AtProtoPdsHostingExtensions.cs:139)

Replace removes whatever current is, and after the first override current is the caller's own parameter rather than the auto-created one. Measured:

var a = builder.AddParameter("mine-a", "aaa");
var b = builder.AddParameter("mine-b", "bbb");
builder.AddAtProtoPds("pds").WithAdminPassword(a).WithAdminPassword(b);
// model: mine-b, pds, pds-jwt-secret, pds-plc-rotation-key   ← mine-a is gone

Same for WithJwtSecret, WithPlcRotationKey, and WithHostname. Severity is low — the ParameterResource object still resolves wherever it's referenced, and the manifest writer re-adds referenced parameters lazily — so the visible damage is a parameter missing from the dashboard and from the model for anything that enumerates it. But it's reachable from ordinary layering (a helper sets a default, a caller overrides it later), and the removal was only ever meant to target the parameter AddAtProtoPds created. Remembering which three those are, and removing only those, closes it.

2. Worth knowing: WithAdminPassword's parameter drop works for a different reason than the other two. ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter never adds its parameter to builder.Resources — I confirmed a publish-mode AddAtProtoPds("pds") yields pds, pds-hostname, pds-jwt-secret, pds-plc-rotation-key and nothing else; pds-admin-password reaches the manifest only because the writer adds referenced parameters lazily. So Replace's Resources.Remove is a no-op there, and the superseded parameter stays out of the manifest because nothing references it. The outcome is right; the mechanism isn't the one the [Theory] (correctly scoped to the other three) demonstrates. Not a defect — just a load-bearing implementation detail of Aspire that the comment on Replace doesn't mention.

3. Nits.

  • CHANGELOG.md:69 has a run-on: "…the corresponding PdsAdminClient wrappers The same defect affects 19 further procedures…" — missing a period.
  • The newer With* methods do ArgumentNullException.ThrowIfNull(builder); WithHandleDomains, WithPlcUrl, WithAppView, WithCrawlers, WithProductionMode, WithInviteCodeRequired, WithBlobUploadLimit, WithReportService, and WithEmail don't. Cosmetic inconsistency.
  • PdsAdminClient appends its User-Agent to a supplied HttpClient via TryParseAdd, so constructing two clients over one hand-managed HttpClient accumulates duplicate UA tokens. Unreachable through the DI path, since the factory hands out a fresh HttpClient per resolution.
  • For readers rather than as a defect: in a published manifest AtProto__Pds__Url is {pds.bindings.http.url}, always http://. So every published deployment of the documented wiring needs one deliberate operator action — TLS in front, or AllowInsecureHttp. That's the fail-closed design and docs/managed-pds.md states it; I mention it only because the manifest makes it unconditional.

Nothing here blocks the PR. #1 is the only one I'd bother fixing, and it's small. Say the word and I'll push it with a test that a second override leaves the first caller's parameter in the model, plus the CHANGELOG punctuation. I pushed no changes; the branch is untouched at 478d2e2.


⏱ 7m 54s · 43 turns · tokens: 122.3k in / 31.3k out (+4036.5k cache-read)
📜 Full session transcript

I reviewed at `478d2e2`, installed the SDK, built and ran everything myself, and probed the new code empirically rather than reading it. ## Verified - `dotnet build -c Release` — **0 errors**. Distinct warning codes: `CS1591` (the repo's XML-doc noise), plus `CS1573` at `XrpcClient.cs:597`, `CS0419` in `PlcOperationBuilder.cs`, `RZ10012` in `ServerIntegrationSample`. All three non-CS1591 ones are pre-existing on `main` — the `CS1573` traces to `ea508ab` (the `proxyOverride` parameter), not to this PR. **No new warnings.** - `dotnet test tests/ATProtoNet.Tests/` → **1083 passed, 0 failed, 5 skipped**. - Published the manifest myself and ran the six `AspireManifestTests` with `ATPROTO_REQUIRE_INTEGRATION=1` → **6 passed**. Output matches what you posted byte for byte: both hex secrets with no `default`, `pds-admin-password` keeping its `generate` block, `PDS_DEV_MODE` absent, `PDS_HOSTNAME` as `{pds-hostname.value}`, `PDS_BLOBSTORE_DISK_LOCATION` present, and no `AtProto__Pds__AllowInsecureHttp` on `api`. - `TreatNoTestsAsError` does what you said — measured, not assumed: bogus filter → **exit 1** with the flag, **exit 0** without. - The mount fix holds at the model level: `AddAtProtoPds("pds").WithDataBindMount("./pds-data")` leaves exactly one annotation (`BindMount → /pds`) and `PDS_BLOBSTORE_DISK_LOCATION` still resolves to `/pds/blocks`. I also checked the ordering hazard the fix could have introduced — `WithProductionMode()` after `AddAtProtoPds` still resolves `PDS_DEV_MODE=false` in run mode, so the run-mode default doesn't win. - `AdminClient`'s seven void methods are on `ProcedureAsync<TRequest>`; exactly 19 `ProcedureAsync<…, object>` remain, matching the split to #69. `XrpcClient.Dispose()` is a no-op and nothing anywhere mutates `DefaultRequestHeaders`, so the one-`HttpClient`-two-`XrpcClient`s arrangement is sound. - No dangling `ATProtoNet.Pds` references outside the CHANGELOG's own history. No container runtime in this box, so the nine `PdsAdminTests` remain verified by you and CI, not by me. ## Findings **1. `Replace` also removes a *caller-supplied* parameter when an override is applied twice.** (`AtProtoPdsHostingExtensions.cs:139`) `Replace` removes whatever `current` is, and after the first override `current` is the caller's own parameter rather than the auto-created one. Measured: ```csharp var a = builder.AddParameter("mine-a", "aaa"); var b = builder.AddParameter("mine-b", "bbb"); builder.AddAtProtoPds("pds").WithAdminPassword(a).WithAdminPassword(b); // model: mine-b, pds, pds-jwt-secret, pds-plc-rotation-key ← mine-a is gone ``` Same for `WithJwtSecret`, `WithPlcRotationKey`, and `WithHostname`. Severity is low — the `ParameterResource` object still resolves wherever it's referenced, and the manifest writer re-adds referenced parameters lazily — so the visible damage is a parameter missing from the dashboard and from the model for anything that enumerates it. But it's reachable from ordinary layering (a helper sets a default, a caller overrides it later), and the removal was only ever meant to target the parameter `AddAtProtoPds` created. Remembering which three those are, and removing only those, closes it. **2. Worth knowing: `WithAdminPassword`'s parameter drop works for a different reason than the other two.** `ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter` never adds its parameter to `builder.Resources` — I confirmed a publish-mode `AddAtProtoPds("pds")` yields `pds, pds-hostname, pds-jwt-secret, pds-plc-rotation-key` and nothing else; `pds-admin-password` reaches the manifest only because the writer adds referenced parameters lazily. So `Replace`'s `Resources.Remove` is a no-op there, and the superseded parameter stays out of the manifest because nothing references it. The outcome is right; the mechanism isn't the one the `[Theory]` (correctly scoped to the other three) demonstrates. Not a defect — just a load-bearing implementation detail of Aspire that the comment on `Replace` doesn't mention. **3. Nits.** - `CHANGELOG.md:69` has a run-on: "…the corresponding `PdsAdminClient` wrappers The same defect affects 19 further procedures…" — missing a period. - The newer `With*` methods do `ArgumentNullException.ThrowIfNull(builder)`; `WithHandleDomains`, `WithPlcUrl`, `WithAppView`, `WithCrawlers`, `WithProductionMode`, `WithInviteCodeRequired`, `WithBlobUploadLimit`, `WithReportService`, and `WithEmail` don't. Cosmetic inconsistency. - `PdsAdminClient` appends its User-Agent to a supplied `HttpClient` via `TryParseAdd`, so constructing two clients over one hand-managed `HttpClient` accumulates duplicate UA tokens. Unreachable through the DI path, since the factory hands out a fresh `HttpClient` per resolution. - For readers rather than as a defect: in a published manifest `AtProto__Pds__Url` is `{pds.bindings.http.url}`, always `http://`. So every published deployment of the documented wiring needs one deliberate operator action — TLS in front, or `AllowInsecureHttp`. That's the fail-closed design and `docs/managed-pds.md` states it; I mention it only because the manifest makes it unconditional. Nothing here blocks the PR. #1 is the only one I'd bother fixing, and it's small. Say the word and I'll push it with a test that a second override leaves the first caller's parameter in the model, plus the CHANGELOG punctuation. I pushed no changes; the branch is untouched at `478d2e2`. --- ⏱ 7m 54s · 43 turns · tokens: 122.3k in / 31.3k out (+4036.5k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-68/run-78.md)
Author
Owner

@claude-bot /review

@claude-bot /review
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/83); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-68/run-83.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

Re-review: the one substantive issue from my previous pass — HexSecretParameterDefault.WriteToManifest potentially emitting a non-hex-constrained generate directive for a value the PDS parses as hex — is resolved. Publish mode now creates the JWT-secret/PLC-rotation-key parameters via a plain AddParameter(name, secret: true) with no default at all (so a deployment must supply the value), and WriteToManifest on the hex-generating default is now unreachable and throws defensively rather than emitting something misleading. This is verified directly against a real Aspire manifest by the new AspireManifestTests (asserting PDS_HOSTNAME is a manifest reference, PDS_DEV_MODE is absent, the blobstore location is set, the two hex secrets carry no generated default, the admin password still does, and the consumer doesn't get AllowInsecureHttp in publish mode) plus a new pds-integration CI job that runs PdsAdminTests against the real ghcr.io/bluesky-social/pds container. I confirmed PdsAdminClient in the actual source matches its documented behavior (unauthenticated signup call, loopback/HTTPS enforcement validated against the effective HttpClient.BaseAddress, automatic invite-code minting). The Replace() helper for overriding auto-created parameters (hostname/admin password/JWT secret/rotation key) correctly removes the superseded ParameterResource from the application model regardless of call order, since the parameters are all constructed up front. WithDataBindMount/WithDataVolume correctly clear the prior mount before adding a new one, fixing the duplicate-mount-destination bug described in the changelog.

One accuracy nit, not blocking: the PR description's Notes section says "No AppHost sample project" was added to avoid the ~150MB Aspire AppHost SDK restore cost in CI, but the diff does add samples/ManagedPdsSample.AppHost, wires it into the .slnx, and CI now builds and runs it every run. Flagged as a finding so the description can be corrected or the tradeoff reconsidered — it doesn't affect correctness of the shipped code.


⏱ 1m 46s · 9 turns · tokens: 89.3k in / 8.3k out (+803.5k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review: the one substantive issue from my previous pass — `HexSecretParameterDefault.WriteToManifest` potentially emitting a non-hex-constrained `generate` directive for a value the PDS parses as hex — is resolved. Publish mode now creates the JWT-secret/PLC-rotation-key parameters via a plain `AddParameter(name, secret: true)` with no default at all (so a deployment must supply the value), and `WriteToManifest` on the hex-generating default is now unreachable and throws defensively rather than emitting something misleading. This is verified directly against a real Aspire manifest by the new `AspireManifestTests` (asserting `PDS_HOSTNAME` is a manifest reference, `PDS_DEV_MODE` is absent, the blobstore location is set, the two hex secrets carry no generated default, the admin password still does, and the consumer doesn't get `AllowInsecureHttp` in publish mode) plus a new `pds-integration` CI job that runs `PdsAdminTests` against the real `ghcr.io/bluesky-social/pds` container. I confirmed `PdsAdminClient` in the actual source matches its documented behavior (unauthenticated signup call, loopback/HTTPS enforcement validated against the effective `HttpClient.BaseAddress`, automatic invite-code minting). The `Replace()` helper for overriding auto-created parameters (hostname/admin password/JWT secret/rotation key) correctly removes the superseded `ParameterResource` from the application model regardless of call order, since the parameters are all constructed up front. `WithDataBindMount`/`WithDataVolume` correctly clear the prior mount before adding a new one, fixing the duplicate-mount-destination bug described in the changelog. One accuracy nit, not blocking: the PR description's Notes section says "No AppHost sample project" was added to avoid the ~150MB Aspire AppHost SDK restore cost in CI, but the diff does add `samples/ManagedPdsSample.AppHost`, wires it into the `.slnx`, and CI now builds and runs it every run. Flagged as a finding so the description can be corrected or the tradeoff reconsidered — it doesn't affect correctness of the shipped code. --- ⏱ 1m 46s · 9 turns · tokens: 89.3k in / 8.3k out (+803.5k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-68/run-83.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:478d2e28468295c1384224345dd390662897328d -->
@ -29,2 +29,4 @@
- name: Run unit tests
run: dotnet test tests/ATProtoNet.Tests/ --no-build --configuration Release --verbosity normal
# Publishing is the path least covered by unit tests: it is where local-only
Collaborator

The PR description's Notes section says: "No AppHost sample project — the Aspire AppHost SDK pulls ~150 MB of orchestration/dashboard packages into every CI restore." But the diff adds samples/ManagedPdsSample.AppHost (referencing Aspire.Hosting.AppHost 13.4.6), registers it in ATProto.NET.slnx, and this CI job now restores/builds it as part of the main solution build and executes it in the "Verify Aspire publish manifest" step. So the description states the opposite tradeoff of what actually shipped — worth updating the PR description (or reconsidering whether the AppHost project belongs in the default solution/build) so it doesn't misrepresent the CI cost impact to future readers. Not a functional defect — the wiring itself is correct and well-tested.

The PR description's **Notes** section says: "No AppHost sample project — the Aspire AppHost SDK pulls ~150 MB of orchestration/dashboard packages into every CI restore." But the diff adds `samples/ManagedPdsSample.AppHost` (referencing `Aspire.Hosting.AppHost` 13.4.6), registers it in `ATProto.NET.slnx`, and this CI job now restores/builds it as part of the main solution build and executes it in the "Verify Aspire publish manifest" step. So the description states the opposite tradeoff of what actually shipped — worth updating the PR description (or reconsidering whether the AppHost project belongs in the default solution/build) so it doesn't misrepresent the CI cost impact to future readers. Not a functional defect — the wiring itself is correct and well-tested.
Grandiras deleted branch feat/managed-pds-aspire 2026-07-25 22:11:38 +00:00
Sign in to join this conversation.
No description provided.