Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
158575 stories
·
33 followers

Why Readiness Should Be a Habit, Not a Final Gate

1 Share
Why Readiness Should Be a Habit, Not a Final Gate

When does quality actually get decided on your team?

Most people would say it's decided at the end - the final testing pass, the pre-release bug bash, the last sprint before launch.

I don't think that's true.

The teams I've seen ship the smoothest launches had already decided their quality months earlier. Not through some grand initiative. Through a hundred small habits, built up long before anyone was thinking about a release date.

The problem with treating quality as a phase

Here's the pattern I keep seeing. A team builds a feature, it works, everyone moves on. Weeks later, something breaks it - a network drops, a user signs out mid-flow, a session gets interrupted - and suddenly there's a "quality bug" to fix before launch.

Except it usually isn't a bug. Call it that, and you're saying the behaviour was defined somewhere and someone just built it wrong. Most of the time, nobody defined it at all. What happens if a session drops mid-flow was never written down - it was just assumed, the same way everyone assumes a bridge won't collapse without anyone putting "please don't collapse" in the blueprint.

Why Readiness Should Be a Habit, Not a Final Gate

That distinction matters more than it sounds. "Fix this bug" and "we never actually decided what should happen here" are two different conversations - and only one of them is a five-minute ticket. Calling it a bug is just the easier thing to say. It skips the harder admission: that this behaviour, whatever you want to call it, was never part of the spec in the first place.

That's the trap with treating quality as a phase instead of a habit. By the time you're looking for these problems, you're also under the most pressure to ship - exactly the wrong moment to be having a "wait, was this ever actually defined?" conversation. Every found issue becomes a fire, not a finding.

Think in systems, not features

When you build a feature, it's natural to focus on what it's supposed to do. A login flow logs people in. A save function saves data. A sync service syncs.

But most of the interesting failures live outside that primary purpose. What happens if the network disconnects mid-save? If the laptop lid closes halfway through? If two systems try to sync the same data at once? If someone revokes an API token mid-request?

None of these are exotic. They're the ordinary reality of software running on real machines, on real networks, used by real people who don't behave the way your test script does.

The shift that helps: stop asking "does this feature work?" Start asking "what happens around this feature when things go wrong?" That one question surfaces more real issues than almost anything else I know.

Failure handling is a feature, not an afterthought

A user will never notice a save system that works perfectly. They will absolutely notice one that loses their progress.

The things that go right are invisible and the things that go wrong are the only things anyone remembers. Failure handling isn't a lesser cousin of the "real" feature - it's often the thing that decides whether people trust your product at all.

Building this in doesn't need to be dramatic. Add "what if this fails halfway through?" to your definition of done, right next to "does this meet the acceptance criteria?"

Quality is not one team's job

I get why quality often ends up owned by one group - a QA team, a dedicated tester, a release manager. Someone has to be accountable, and it's tidy to draw the line that way.

But the best outcomes come from readiness being shared, not delegated. Designers think about the journeys users actually take, not just the ones in the spec. Engineers think about what breaks under load. Ops thinks about what happens when a dependency goes down. Everyone brings a different failure mode to the table, and together they cover more ground than any single team could alone.

Why Readiness Should Be a Habit, Not a Final Gate

When quality is "someone else's job," problems surface late - right before a deadline, when there's the least time to fix them properly. When it's everyone's habit, they surface early, while they're still cheap.

"This only works if you have a big team"

I know what some people are thinking - this sounds reasonable for a large, well-resourced org, but not realistic for a small team stretched thin across a dozen priorities.

Fair point, but these habits don't scale with headcount - they scale with attention. A team of three can still ask "what happens if this fails?" in code review. A team of three can still spend twenty minutes a sprint on a lightweight checklist instead of skipping it under pressure. The habit is cheap. It's the absence of it, compounding silently for months, that gets expensive.

Get feedback earlier than feels comfortable

One of the more counterintuitive things I've learned is that teams get the most value from external feedback - a code review, a security scan, a beta test - when they ask for it earlier than feels natural. Most wait until they believe something is "ready." By then, the feedback just confirms what you suspected, instead of reshaping your priorities while there's still time to act.

Whatever your version of a final review looks like, treat an early pass at it as a debugging tool, not a pass/fail exam. The earlier you know where the gaps are, the cheaper they are to close.

A lightweight checklist beats no checklist

Before anything goes out the door, it's worth running a short, boring checklist rather than trusting memory:

  1. Deploy it cold. Install or deploy exactly the way it'll happen in production - on a clean machine or fresh container, not your dev box with months of workarounds baked in.
  2. Hand it to a stranger. Give it to someone with zero context and no instructions. If they can't reach the core experience without you standing over their shoulder, that's a real finding, not an edge case.
  3. Check the paper trail. Make sure the version number, changelog, and docs describe what's actually shipping - not what shipped last time.
  4. Retest, don't just re-close. Go back to previously reported bugs and confirm they're actually fixed - and add a regression test for each one, so it can't quietly come back. A ticket marked "resolved" isn't the same as a bug that's gone. Using AI agents for this is a perfect example of how to reduce churn.

None of this is glamorous and all of it prevents avoidable churn later.

The real takeaway

Over the years, I've learned that the teams who dread their final review are usually the ones who've been avoiding the hard questions the whole way through. The teams who walk in relaxed aren't lucky - they've just already asked "what happens when this breaks?" a hundred times before anyone official asked it for them.

That's really the whole idea. The final check was never where quality got decided. It was just where you found out.

So next time you're reviewing a feature, don't just ask if it works. Ask what happens when it doesn't. Do that enough, and your "final" check stops being something you might fail - and starts being a formality that tells you what you already knew. 🙂

Read the whole story
alvinashcraft
43 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

John Bristowe: Octopus Catchup - Episode 413

1 Share

https://clearmeasure.com/developers/forums/

John Bristowe brings over 20 years of experience in technology to his role as Developer Advocate at Octopus Deploy. His career includes time at Progress and Microsoft, where he developed his skills in management and technology. Known for a straightforward and engaging style, John often speaks at industry conferences, sharing practical tips and insights. John is a HashiCorp Ambassador and part of the Progress Champions program.

He enjoys creating content such as articles, webinars, and podcasts, aiming to help others in the tech community. In his free time, John likes to stay informed about the latest tech developments and shares his learning with an online audience. His approach is always about sharing knowledge and learning together.

 LinkedIn- https://www.linkedin.com/in/jbristowe/ 
Sessionize Link - https://sessionize.com/john-bristowe/
Octopus Roadmap - roadmap.octopus.com
Octopus Blog - https://octopus.com/blog/code-review-is-theater-now
I Love Lucy Reference - https://www.youtube.com/watch?v=AnHiAWlrYQc

Previous Appearances on the Azure & DevOps Podcast:
https://azuredevopspodcast.clear-measure.com/john-bristowe-the-latest-from-octopus-deploy-episode-368

Want to Learn More?
Visit AzureDevOps.Show for show notes and additional episodes.





Download audio: https://traffic.libsyn.com/clean/secure/azuredevops/Episode_413_1.mp3?dest-id=768873
Read the whole story
alvinashcraft
44 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Connecting Octopus Cloud to your internal systems — without opening a single port

1 Share

For many enterprise teams, the move to Octopus Cloud can run into the same limitation: their artifact repositories, source control servers, and other internal tooling live behind a network perimeter that was never designed to accept connections from the outside world. Opening inbound firewall ports isn't an option—not when security and compliance teams have spent considerable effort locking those down.

So teams make a pragmatic choice: stay on Octopus Server and deal with the cloud migration later. But by extension then they continue to manage Octopus upgrades and operating system patching too; and have to accept they won't have access to new Octopus features as they come out.

We've watched this play out with a number of large enterprises. One of them is a major global financial services institution in the middle of a significant undertaking: migrating over a decade's worth of legacy deployment infrastructure to Octopus. Thousands of pipelines and years of accumulated configuration. A migration that is touching nearly every team in the organization.

Going straight to Octopus Cloud is the obvious goal — less infrastructure to manage, no platform to maintain. But it's being blocked by a straightforward problem: Octopus Cloud has no way to reach the systems that matter most—their internal Artifactory instance, and their GitHub Enterprise server and other internal systems. In a regulated environment, asking the security team to open inbound ports to a third-party SaaS platform isn't a conversation that team wants to have.

Enter the Octopus Connection Agent, available from July 2026.

How it works

The Connection Agent is a lightweight Docker container you run inside your own network. When it starts, it opens a secure, outbound-only connection to your Octopus Cloud instance over port 443—the same port used for standard HTTPS traffic.

:::figure Connection Agent architecture :::

Because the connection is initiated from inside your network, nothing needs to change on your firewall. No inbound rules. No VPN. No publicly accessible endpoints for your internal systems. The agent authenticates using a private key, and Octopus proxies traffic through it to reach whatever internal resources you need.

The financial services institution in this story ran the Connection Agent as a Kubernetes pod inside their own cluster—fitting naturally into the container infrastructure they already operated. After completing the registration step, the logs confirmed what they'd been waiting for:

[10:55:05 INF] Registering Connection Agent with Octopus instance URL: https://{instance}.octopus.app/api/connectionagent/registrations
[10:55:05 INF] Successfully registered Connection Agent

Within a single maintenance window, their Octopus Cloud instance was reprovisioned, and traffic began flowing through the agent to their internal GitHub Enterprise server. Artifactory followed shortly after. The only firewall edit required: outbound port 443 access to their Octopus Cloud instance and to Octopus's authentication service.

From blocker to enabler

Once the Connection Agent is running, they can get on with populating Octopus Cloud, skipping an entire phase of infrastructure work—requesting and configuring firewall ports—and instead focusing on what actually matters, getting thousands of pipelines migrated and teams deploying to production.

That's not a small thing as every migration carries risk. And adding yet more company infrastructure configuration steps into the mix—in this case extra firewall rules—doesn't actually move the migration forwards, it only further compounds the risk. By using the Connection Agent, the company removes that risk entirely.

Security by design

For teams in regulated industries, security isn't a checkbox—it's a constraint that shapes every decision. The Connection Agent was designed with that in mind.

All connections are outbound-only. The agent initiates the connection; Octopus never reaches into your network. Authentication uses JWT-signed private keys. You can configure ALLOWED_IP_RANGES to lock down exactly which internal hosts the agent can access, so you define the boundary explicitly rather than leaving it open-ended.

At this early stage, we support the publicly accessible Certificate Authorities (CAs). Though if your internal systems use certificates issued by an internal certificate authority—common in financial services—then for now you can use those internal certificates and CAs by setting --ignore-certificate-validation-errors for a given internal domain.

So if you need to pin specific CAs or certificates, let us know!

What's more, if you need redundancy, you can run multiple agents across different Kubernetes clusters.

What's supported

The Connection Agent currently supports:

  • Git repositories (e.g. GitHub Enterprise, Bitbucket)
  • SMTP servers
  • External feeds:
    • Artifactory Generic Feed
    • Azure Container Registry
    • Docker Container Registry
    • GitHub Repository Feed
    • Helm Feed
    • Maven Feed
    • NPM Feed
    • NuGet Feed
    • OCI Container Registry

Support for additional resource types is in active development. Early adopters are working directly with the team to shape what comes next, which means real customer use cases are driving the roadmap.

Action: We would love to hear what other connectors would help. You can share them with us here.

Getting started

The Connection Agent is available as a Docker image on Docker Hub and is limited to a maximum of 5 connections i.e. 5 internal resources as of this writing.

Full documentation can be found here.

If you're on Octopus Server and a connectivity gap is what's been keeping you from moving to cloud, or if you're already on Octopus Cloud and working around the lack of access to your internal systems, talk to your account team as this is the piece that was missing.

Happy deployments!

Read the whole story
alvinashcraft
44 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

AsyncAPI Code Generation with Corvus: Custom Transports

1 Share

At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at testing patterns with the in-memory transport.

Throughout this series, we've used transports from the built-in packages - NATS, Kafka, AMQP, MQTT, Azure Service Bus, and the in-memory transport for testing. But what if your organisation uses a broker that isn't on that list? Perhaps you're running Redis Streams, Amazon SQS, Google Cloud Pub/Sub, or a proprietary internal messaging system. The transport layer is designed to be extended.

The interface contract

The entire transport abstraction is a single interface with four methods:

public interface IMessageTransport : IAsyncDisposable
{
    ValueTask PublishAsync<TPayload>(
        ReadOnlyMemory<byte> channelUtf8,
        in TPayload payload,
        in JsonElement headers = default,
        CancellationToken cancellationToken = default)
        where TPayload : struct, IJsonElement<TPayload>;

    ValueTask SubscribeAsync<TPayload>(
        ReadOnlyMemory<byte> channelUtf8,
        Func<TPayload, JsonElement, CancellationToken, ValueTask> handler,
        CancellationToken cancellationToken = default)
        where TPayload : struct, IJsonElement<TPayload>;

    ValueTask UnsubscribeAsync(
        ReadOnlyMemory<byte> channelUtf8,
        CancellationToken cancellationToken = default);

    ValueTask<(TReply Payload, JsonElement Headers)> RequestAsync<TRequest, TReply>(
        ReadOnlyMemory<byte> requestChannelUtf8,
        ReadOnlyMemory<byte> replyChannelUtf8,
        TRequest request,
        ReadOnlyMemory<byte> correlationIdUtf8,
        JsonElement headers = default,
        CancellationToken cancellationToken = default)
        where TRequest : struct, IJsonElement<TRequest>
        where TReply : struct, IJsonElement<TReply>;

    ValueTask DeadLetterAsync(
        ReadOnlyMemory<byte> deadLetterChannelUtf8,
        ReadOnlyMemory<byte> originalChannelUtf8,
        in JsonElement payload,
        in JsonElement headers,
        Exception exception,
        CancellationToken cancellationToken = default);
}

That's it. The generated producers call PublishAsync and RequestAsync. The generated consumers call SubscribeAsync, UnsubscribeAsync, and DeadLetterAsync. Everything else - schema validation, channel address construction, error policies, handler middleware - lives in the generated code and the runtime library, not in the transport.

What the transport is responsible for

The transport is a low-level messaging pipe. Its responsibilities are deliberately narrow:

For publishing: take the typed payload, serialize it to bytes (via WriteTo(Utf8JsonWriter)), and deliver those bytes to the broker on the specified channel. The channel address arrives as UTF-8 bytes in a ReadOnlyMemory<byte>. If your broker's client library needs a string, convert at the outermost boundary with Encoding.UTF8.GetString().

For subscribing: register with the broker to receive messages on the specified channel pattern, parse incoming bytes into a typed payload (via ParsedJsonDocument<T>.Parse()), and call the handler delegate with the parsed payload and any message headers as a JsonElement.

For request/reply: combine publish and subscribe. Send the request, listen for a correlated reply on the appropriate channel, and return the typed response. If your broker doesn't natively support request/reply, you can implement it with a temporary subscription filtered by correlation ID (the same approach the NATS core transport uses).

For unsubscribe: stop receiving messages on the specified channel and clean up any broker-side subscription state.

A skeleton implementation

Here's the structure of a custom transport. I'll use Redis Streams as an illustrative example, but the pattern applies to any broker:

using System.Text;
using System.Text.Json;
using Corvus.Text.Json;
using Corvus.Text.Json.AsyncApi;

public sealed class RedisStreamTransport : IMessageTransport, IAsyncDisposable
{
    private readonly ConnectionMultiplexer _redis;
    private readonly ConcurrentDictionary<string, CancellationTokenSource> _subscriptions = new();

    public RedisStreamTransport(ConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    public async ValueTask PublishAsync<TPayload>(
        ReadOnlyMemory<byte> channelUtf8,
        TPayload payload,
        JsonElement headers = default,
        CancellationToken cancellationToken = default)
        where TPayload : struct, IJsonElement<TPayload>
    {
        // 1. Serialize the payload to bytes
        using var buffer = new ArrayBufferWriter<byte>();
        using var writer = new Utf8JsonWriter(buffer);
        payload.WriteTo(writer);
        await writer.FlushAsync(cancellationToken);

        // 2. Convert channel to string (at the boundary)
        string channel = Encoding.UTF8.GetString(channelUtf8.Span);

        // 3. Deliver to your broker
        var db = _redis.GetDatabase();
        await db.StreamAddAsync(
            channel,
            [new NameValueEntry("payload", buffer.WrittenMemory.ToArray())],
            flags: CommandFlags.FireAndForget);
    }

    public async ValueTask SubscribeAsync<TPayload>(
        ReadOnlyMemory<byte> channelUtf8,
        Func<TPayload, JsonElement, CancellationToken, ValueTask> handler,
        CancellationToken cancellationToken = default)
        where TPayload : struct, IJsonElement<TPayload>
    {
        string channel = Encoding.UTF8.GetString(channelUtf8.Span);
        var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        _subscriptions[channel] = cts;

        // Start a background loop reading from the stream
        _ = Task.Run(async () =>
        {
            var db = _redis.GetDatabase();
            string lastId = "0-0";

            while (!cts.Token.IsCancellationRequested)
            {
                var entries = await db.StreamReadAsync(channel, lastId, count: 10);

                foreach (var entry in entries)
                {
                    lastId = entry.Id!;
                    byte[] payloadBytes = (byte[])entry["payload"]!;

                    // Parse into the typed payload
                    using var doc = ParsedJsonDocument<TPayload>.Parse(payloadBytes);
                    TPayload typed = doc.RootElement;

                    // Call the handler (headers as empty JsonElement if none)
                    await handler(typed, default, cts.Token);
                }

                if (entries.Length == 0)
                {
                    await Task.Delay(100, cts.Token);
                }
            }
        }, cts.Token);
    }

    public ValueTask UnsubscribeAsync(
        ReadOnlyMemory<byte> channelUtf8,
        CancellationToken cancellationToken = default)
    {
        string channel = Encoding.UTF8.GetString(channelUtf8.Span);

        if (_subscriptions.TryRemove(channel, out var cts))
        {
            cts.Cancel();
            cts.Dispose();
        }

        return ValueTask.CompletedTask;
    }

    public ValueTask<(TReply Payload, JsonElement Headers)> RequestAsync<TRequest, TReply>(
        ReadOnlyMemory<byte> requestChannelUtf8,
        ReadOnlyMemory<byte> replyChannelUtf8,
        TRequest request,
        ReadOnlyMemory<byte> correlationIdUtf8,
        JsonElement headers = default,
        CancellationToken cancellationToken = default)
        where TRequest : struct, IJsonElement<TRequest>
        where TReply : struct, IJsonElement<TReply>
    {
        // Implement correlation-based request/reply
        // or throw NotSupportedException if your broker doesn't support it
        throw new NotSupportedException(
            "Redis Streams does not natively support request/reply. " +
            "Use produce/consume patterns instead.");
    }

    public async ValueTask DeadLetterAsync(
        ReadOnlyMemory<byte> deadLetterChannelUtf8,
        ReadOnlyMemory<byte> originalChannelUtf8,
        JsonElement payload,
        JsonElement headers,
        Exception exception,
        CancellationToken cancellationToken = default)
    {
        string dlqChannel = Encoding.UTF8.GetString(deadLetterChannelUtf8.Span);
        string originalChannel = Encoding.UTF8.GetString(originalChannelUtf8.Span);

        var db = _redis.GetDatabase();
        await db.StreamAddAsync(
            dlqChannel,
            [
                new NameValueEntry("payload", JsonSerializer.SerializeToUtf8Bytes(payload)),
                new NameValueEntry("originalChannel", originalChannel),
                new NameValueEntry("error", exception.Message),
                new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToString("O")),
            ]);
    }

    public async ValueTask DisposeAsync()
    {
        foreach (var cts in _subscriptions.Values)
        {
            cts.Cancel();
            cts.Dispose();
        }

        _subscriptions.Clear();
    }
}

This is intentionally simplified. A production implementation would handle consumer groups, acknowledgement, error recovery, and connection management. But it illustrates the core contract: serialize on publish, parse on subscribe, dead-letter on failure, and let the generated code handle everything else.

Key implementation considerations

Channel addresses are UTF-8 bytes

The ReadOnlyMemory<byte> channel parameter contains a UTF-8 encoded channel address that the generated code has already resolved (substituting any channel parameters like {streetlightId} with actual values). Your transport receives the final address. If your broker API accepts strings, convert once at the boundary. If it accepts byte spans natively (as NATS does), you can avoid the string allocation entirely.

Payload serialization uses WriteTo

The TPayload type constraint (IJsonElement<TPayload>) guarantees that the payload has a WriteTo(Utf8JsonWriter) method. This is the canonical way to serialize. Write to a Utf8JsonWriter backed by whatever buffer strategy suits your transport. The generated types write directly from their internal pooled representation, so there's no intermediate string or byte array.

Payload parsing uses ParsedJsonDocument

On the subscribe side, incoming bytes from the broker are parsed via ParsedJsonDocument<T>.Parse(). This gives you a typed view over the JSON with the same pooled-memory semantics as the rest of V5. The handler receives the parsed payload and any message headers (as a JsonElement - pass default if your broker doesn't have a header concept).

MessageContext carries bindings

The MessageContext parameter on PublishAsync contains channel, operation, and message bindings as raw JSON bytes. These come from the AsyncAPI spec's bindings section. If your broker uses specific metadata (partition keys, routing keys, priority levels), inspect the relevant binding JSON in your transport. If your broker doesn't use bindings, you can safely ignore the context.

Dead-letter routing

DeadLetterAsync is a first-class method on IMessageTransport. The generated consumer calls it directly when the error policy returns MessageErrorAction.DeadLetter. Your implementation receives the dead-letter channel address (already computed by the generated consumer), the original channel, the raw payload and headers, and the exception that caused the failure:

public async ValueTask DeadLetterAsync(
    ReadOnlyMemory<byte> deadLetterChannelUtf8,
    ReadOnlyMemory<byte> originalChannelUtf8,
    JsonElement payload,
    JsonElement headers,
    Exception exception,
    CancellationToken cancellationToken = default)
{
    string dlqChannel = Encoding.UTF8.GetString(deadLetterChannelUtf8.Span);
    string originalChannel = Encoding.UTF8.GetString(originalChannelUtf8.Span);

    var db = _redis.GetDatabase();

    // Store with error metadata for debugging
    await db.StreamAddAsync(
        dlqChannel,
        [
            new NameValueEntry("payload", JsonSerializer.SerializeToUtf8Bytes(payload)),
            new NameValueEntry("originalChannel", originalChannel),
            new NameValueEntry("error", exception.Message),
            new NameValueEntry("errorType", exception.GetType().Name),
            new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToString("O")),
        ]);
}

The advantage of having DeadLetterAsync as a dedicated method (rather than just re-publishing) is that your transport can attach error metadata alongside the failed message. This makes dead-letter queues much easier to inspect and debug. If your broker has a native dead-letter mechanism (as RabbitMQ does with dead-letter exchanges, or Azure Service Bus does with dead-letter subqueues), your implementation can delegate to that native facility and preserve any broker-specific metadata it provides.

Implementing request/reply

The RequestAsync method on IMessageTransport is optional in the sense that not all brokers support it naturally. If your broker has a native request/reply mechanism (as NATS does with its inbox pattern), implement it directly. If it doesn't, you have two choices.

The first is to throw NotSupportedException, which means your transport can't be used with AsyncAPI operations that define a reply object. This is honest and clear. Your transport supports produce/consume but not request/reply.

The second is to implement correlation-based request/reply yourself, using a temporary subscription. Notice that the interface design helps here. The generated producer code handles correlation ID generation (formatting a GUID directly into a pooled byte[36]) and passes the correlation ID, request channel, and reply channel as separate parameters. Your transport just needs to wire them together:

  1. Subscribe to the reply channel, filtered by the correlation ID
  2. Publish the request to the request channel
  3. Wait for the reply to arrive (with timeout via the cancellation token)
  4. Unsubscribe from the reply channel
  5. Return the typed reply

Here's a sketch:

public async ValueTask<(TReply Payload, JsonElement Headers)> RequestAsync<TRequest, TReply>(
    ReadOnlyMemory<byte> requestChannelUtf8,
    ReadOnlyMemory<byte> replyChannelUtf8,
    TRequest request,
    ReadOnlyMemory<byte> correlationIdUtf8,
    JsonElement headers = default,
    CancellationToken cancellationToken = default)
    where TRequest : struct, IJsonElement<TRequest>
    where TReply : struct, IJsonElement<TReply>
{
    var tcs = new TaskCompletionSource<(TReply, JsonElement)>();

    // Subscribe to the reply channel, matching on correlation ID
    await SubscribeAsync<TReply>(
        replyChannelUtf8,
        (reply, replyHeaders, ct) =>
        {
            // Check correlation ID in headers matches ours
            if (CorrelationMatches(replyHeaders, correlationIdUtf8.Span))
            {
                tcs.TrySetResult((reply, replyHeaders));
            }
            return ValueTask.CompletedTask;
        },
        cancellationToken);

    // Publish the request
    await PublishAsync(requestChannelUtf8, in request, in headers, cancellationToken);

    // Await the reply (cancellation token handles timeout)
    var result = await tcs.Task.WaitAsync(cancellationToken);

    await UnsubscribeAsync(replyChannelUtf8, cancellationToken);

    return result;
}

This is more complex than the other methods, and it's why the built-in transports handle it for you. But if you need request/reply over a broker that doesn't support it natively, the pattern is well-established and the correlation matching is straightforward.

Acknowledgement and at-least-once delivery

If your broker supports acknowledgement (most durable brokers do), your SubscribeAsync implementation should acknowledge messages after the handler delegate completes successfully. If the handler throws, don't acknowledge. Let the broker redeliver.

The simplest approach is to wrap the handler call:

try
{
    await handler(typed, headers, cancellationToken);
    await AcknowledgeAsync(message); // broker-specific ack
}
catch
{
    // Don't ack - broker will redeliver after visibility timeout
    throw; // Let the generated consumer's error policy handle it
}

The generated consumer catches this exception and routes it through the IMessageErrorPolicy. If the policy returns Skip, the consumer swallows the error and moves on (you should then ack the message). If it returns DeadLetter, the consumer dead-letters and then acks. If it returns Abort, the consumer stops without acknowledging, so the message will be redelivered when the consumer restarts.

To support this fully, your subscribe loop needs to distinguish between "handler succeeded" and "error policy resolved the failure." The cleanest approach is to not ack inside SubscribeAsync at all, but instead accept an Action<MessageAcknowledgement> callback from the generated consumer. The built-in transports use this pattern internally. Look at the AMQP or Kafka transport source for reference.

Adding health check support

If you want your custom transport to work with the ASP.NET Core health check integration from Corvus.Text.Json.AsyncApi.HealthChecks, implement IHealthCheckableTransport:

public sealed class RedisStreamTransport
    : IMessageTransport, IHealthCheckableTransport, IAsyncDisposable
{
    public bool IsConnected => _redis.IsConnected;

    public string MessagingSystem => "redis-streams";

    public async ValueTask<bool> PingAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            var db = _redis.GetDatabase();
            await db.PingAsync();
            return true;
        }
        catch
        {
            return false;
        }
    }
}

The health check extension calls IsConnected for a quick check and PingAsync for an active probe. MessagingSystem is a label that appears in health check results.

Adding telemetry without extra code

You don't need to implement telemetry in your transport. The InstrumentedMessageTransport decorator works with any IMessageTransport:

IMessageTransport raw = new RedisStreamTransport(redis);
IMessageTransport transport = new InstrumentedMessageTransport(raw, "redis-streams");

This gives you distributed tracing, metrics, and W3C trace context propagation. All of this comes from the decorator, with no changes to your transport implementation.

Publishing transport-specific telemetry

The decorator handles the standard messaging telemetry (messages sent, messages consumed, processing duration, dead-letters). But your transport often has its own operational metrics that are worth surfacing - connection state transitions, reconnection attempts, broker-specific error codes, queue depth, or partition rebalancing events.

The AsyncApiTelemetry static class exposes the shared ActivitySource and Meter along with convenience recording methods for common transport events:

using Corvus.Text.Json.AsyncApi;

// Record a transport state transition (connected ↔ disconnected)
AsyncApiTelemetry.TransportStateTransitions.Add(
    1,
    new KeyValuePair<string, object?>("messaging.system", "redis-streams"),
    new KeyValuePair<string, object?>("state", "connected"));

// Record a dead-letter that happened inside your transport's error handling
AsyncApiTelemetry.RecordDeadLetter(
    channel: "orders.created",
    messagingSystem: "redis-streams",
    reason: "deserialization_failure");

// Record a dead-letter that itself failed (message was dropped)
AsyncApiTelemetry.RecordDeadLetterFailure(
    channel: "orders.created",
    messagingSystem: "redis-streams",
    reason: "dlq_full",
    exception: ex);

For transport-specific metrics that don't fit the standard model, create your own Meter in your transport package and document it for users to subscribe to:

public sealed class RedisStreamTransport : IMessageTransport, IAsyncDisposable
{
    private static readonly Meter TransportMeter = new("Corvus.AsyncApi.RedisStreams");

    private static readonly Counter<long> ReconnectionAttempts =
        TransportMeter.CreateCounter<long>(
            "corvus.asyncapi.redis.reconnection_attempts",
            description: "Number of reconnection attempts to Redis");

    private static readonly Histogram<double> StreamLag =
        TransportMeter.CreateHistogram<double>(
            "corvus.asyncapi.redis.stream_lag_ms",
            unit: "ms",
            description: "Lag between last delivered and last pending message");

    // Use in your implementation:
    private async Task ReconnectAsync()
    {
        ReconnectionAttempts.Add(1,
            new KeyValuePair<string, object?>("server", _serverAddress));

        // ... reconnection logic
    }
}

Users then subscribe to both the standard meter and your transport meter:

services.AddOpenTelemetry()
    .WithMetrics(b => b
        .AddMeter(AsyncApiTelemetry.MeterName)           // standard
        .AddMeter("Corvus.AsyncApi.RedisStreams"));       // transport-specific

This approach keeps transport-specific telemetry separate from the standard messaging metrics, while following the same zero-cost-when-idle pattern. If nobody subscribes to your transport meter, the counters and histograms are no-ops.

Implementing ITransportOptions

The ITransportOptions interface provides the shared configuration contract that the generated consumer code expects. By implementing it on your options class, you allow users to configure error policies, handler middleware, and heartbeat monitoring consistently with the built-in transports:

public sealed class RedisStreamTransportOptions : ITransportOptions
{
    // Transport-specific configuration
    public string ConnectionString { get; set; } = "localhost:6379";
    public string ConsumerGroup { get; set; } = "default";
    public string ConsumerName { get; set; } = Environment.MachineName;
    public int BatchSize { get; set; } = 10;
    public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(100);
    public TimeSpan ClaimTimeout { get; set; } = TimeSpan.FromMinutes(5);
    public string DeadLetterSuffix { get; set; } = "dlq:";

    // ITransportOptions - shared resilience configuration
    public IMessageErrorPolicy? ErrorPolicy { get; set; }
    public MessageHandlerMiddleware? HandlerMiddleware { get; set; }
    public ProcessingLoopHeartbeat? Heartbeat { get; set; }
}

The three ITransportOptions properties serve specific roles:

ErrorPolicy is the IMessageErrorPolicy instance that determines what happens when a message permanently fails processing. Your transport passes this to the generated consumer at construction time. If not set, the consumer uses a sensible default (dead-letter on deserialization and handler errors, abort on transport errors).

HandlerMiddleware is a MessageHandlerMiddleware delegate that wraps every handler invocation. This is where Polly resilience pipelines plug in. Your transport passes it to the generated consumer, which calls through it for every message.

Heartbeat is the ProcessingLoopHeartbeat tracker. If provided, your subscribe loop should call heartbeat.Tick(channel) on every iteration so the liveness monitor can detect stalled consumers.

Here's how your transport uses these in the subscribe loop:

public async ValueTask SubscribeAsync<TPayload>(
    ReadOnlyMemory<byte> channelUtf8,
    Func<TPayload, JsonElement, CancellationToken, ValueTask> handler,
    CancellationToken cancellationToken = default)
    where TPayload : struct, IJsonElement<TPayload>
{
    string channel = Encoding.UTF8.GetString(channelUtf8.Span);

    _ = Task.Run(async () =>
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            // Tick the heartbeat so liveness monitoring knows we're alive
            _options.Heartbeat?.Tick(channel);

            var entries = await ReadFromStreamAsync(channel, _options.BatchSize);

            foreach (var entry in entries)
            {
                using var doc = ParsedJsonDocument<TPayload>.Parse(entry.PayloadBytes);

                // If middleware is configured, invoke through it
                if (_options.HandlerMiddleware is { } middleware)
                {
                    await middleware(
                        async ct => await handler(doc.RootElement, entry.Headers, ct),
                        cancellationToken);
                }
                else
                {
                    await handler(doc.RootElement, entry.Headers, cancellationToken);
                }
            }

            if (entries.Length == 0)
            {
                await Task.Delay(_options.PollInterval, cancellationToken);
            }
        }
    }, cancellationToken);
}

The key points: tick the heartbeat on every loop iteration, wrap handler calls through the middleware delegate if one is provided, and let exceptions propagate to the generated consumer's error policy.

Packaging conventions

If you're building a transport for others to consume, follow the naming convention of the existing packages:

  • Package: Corvus.Text.Json.AsyncApi.YourBroker
  • Transport class: YourBrokerMessageTransport
  • Options class: YourBrokerTransportOptions : ITransportOptions

The ITransportOptions interface carries the shared ErrorPolicy and HandlerMiddleware properties, so consumers can configure error handling and resilience consistently regardless of transport.

When to build a custom transport

The built-in transports cover the most common brokers. You'd implement a custom transport when:

  • Your organisation uses a broker not covered by the existing packages (Redis Streams, Amazon SQS/SNS, Google Cloud Pub/Sub, Apache Pulsar, ZeroMQ)
  • You need to integrate with a proprietary internal messaging system
  • You want a specialised transport for a specific deployment constraint (perhaps an embedded broker for edge devices, or a file-based transport for offline-first scenarios)

The interface is deliberately minimal - five methods plus disposal - so a basic implementation is straightforward. The complexity lives in making it production-ready: connection management, reconnection, acknowledgement semantics, and graceful shutdown. But those are broker-specific concerns that you'd need to solve regardless of whether you're using Corvus or writing raw messaging code.

For the complete IMessageTransport API reference, see the API documentation on corvus-oss.org.



Read the whole story
alvinashcraft
44 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Building bbx - A Bitbucket Cloud CLI as a .NET Global Tool

1 Share

Overview

GitHub has gh. Bitbucket has nothing. Atlassian never shipped a first-party command line tool for Bitbucket Cloud, so anything you want to automate ends up as a pile of curl calls with a hand-rolled Authorization header and a jq filter to make sense of the answer.

I built bbx to close that gap. It is a .NET global tool that wraps the Bitbucket Cloud API v2, prints JSON on stdout, keeps human messages on stderr, and exits non-zero when something fails. That last part sounds obvious. It was the single worst bug in the project, and I will come back to it.

This post covers the whole build: what the tool does, how it is put together, the four bugs that only appear when you point the thing at a live API, and the release pipeline that puts it on NuGet. I have written about .NET global tools and .NET 10 upgrades before, so I have skipped the ground those posts already cover and spent the space on what was new here.

The source is on GitHub at solrevdev/solrevdev.bbx, and the package is on NuGet as solrevdev.bbx.

What bbx Is 🎯

bbx is gh for Bitbucket Cloud. One command per thing you would otherwise open a browser tab for:

bbx repo list -w myworkspace --limit 10
bbx pr list -w myworkspace -r myrepo --state OPEN
bbx pr view 42 -w myworkspace -r myrepo
bbx pipeline list -w myworkspace -r myrepo --limit 5

Coverage runs to twelve command groups: repositories, pull requests, branches, tags, commits, source files, downloads, pipelines, snippets, workspaces, projects and users. Underneath that there are 150 feature handlers, one per verb.

Set a default workspace once and you can drop -w from everything after it:

bbx auth set-workspace myworkspace
bbx pr list -r myrepo --state OPEN

What It Is Useful For 🔧

Three things, in the order I actually use them.

Answering a question without leaving the terminal. “Did the last pipeline pass?” is a browser tab, two clicks and a wait. Or:

bbx pipeline list -r myrepo --limit 1 \
  | jq -r '.pipelines[0].state | "\(.name) \(.result // "")"'

Scripting against Bitbucket in CI. Every command answers with JSON and reports failure through its exit code, so set -e and if ! do what you expect:

if ! prs=$(bbx pr list -r myrepo --state OPEN); then
  echo "lookup failed" >&2
  exit 1
fi

Giving an LLM agent a way to read Bitbucket. This is the one that shaped the design. An agent calling a CLI needs a stable contract, not human prose it has to scrape. bbx promises three things and the whole tool is built to keep them:

  1. JSON on stdout, everything else on stderr, so a pipe stays clean.
  2. Exit 0 on success and 1 on any failure, so the agent knows without parsing.
  3. Pretty-printed by default, single-line on request via --json-compact or BBX_JSON_COMPACT=1, because an agent paying per token wants the compact form.

There is a fourth flag for the same audience. BBX_NO_INTERACTIVE=1 guarantees the tool never stops to prompt, and fails with a clear message instead:

BBX_NO_INTERACTIVE=1 bbx repo list -w myworkspace

The repository ships a separate guide written for agents rather than people, which turned out to be a more useful artefact than I expected. Point an agent at that file and it stops guessing at argument shapes.

How It Was Built, Start to Finish 🏗️

The first commit landed on 31 December 2025. Version 1.0.0 went to NuGet on 2 August 2026. That is a long calendar gap and a short amount of actual work: 57 commits, most of them in two bursts.

The first burst produced something that worked and was badly organised. Argument parsing, HTTP calls and output formatting all lived in the same files. Adding a command meant touching four places and hoping.

The second burst, in May 2026, was a rewrite that kept the command surface and replaced everything behind it. That is where the shape below comes from.

Project Layout 📁

src/Bbx/
  Program.cs        entry point; builds DI once, owns the exit-code contract
  Api/              BitbucketClient, JsonElementExtensions
  Auth/             ICredentialStore, CredentialManager, IAuthProvider, AuthGate
  Commands/         System.CommandLine wiring only, no business logic
  Features/<Area>/<Verb>/   Request record + Handler, co-located
  Composition/      ServiceRegistration (DI), JsonOptions
tests/Bbx.Tests/    fake HTTP handler, in-memory credential store, no network

One rule holds the whole thing together: files in Commands/ parse arguments and nothing else. They describe the CLI, then hand off. All the logic lives in a handler under Features/.

A handler is small and boring on purpose. Here is the one behind bbx repo list, in full:

public sealed class ListReposHandler(BitbucketClient client, CredentialManager credentials)
{
    public async Task<object> HandleAsync(ListReposRequest request, CancellationToken ct)
    {
        var workspace = Resolve.Workspace(credentials, request.Workspace,
            "Error: Workspace required. Use --workspace or run: bbx auth set-workspace <workspace>");

        var endpoint = $"/repositories/{workspace}";
        if (!string.IsNullOrEmpty(request.Query))
            endpoint += $"?q={Uri.EscapeDataString(request.Query)}";

        var repos = new List<object>();
        var count = 0;
        await foreach (var repo in client.GetPaginatedAsync<JsonElement>(endpoint, ct))
        {
            repos.Add(new
            {
                name = repo.TryGetProperty("name", out var n) ? n.GetString() : null,
                slug = repo.TryGetProperty("slug", out var s) ? s.GetString() : null,
                full_name = repo.TryGetProperty("full_name", out var fn) ? fn.GetString() : null,
                is_private = repo.TryGetProperty("is_private", out var ip) && ip.GetBoolean(),
                scm = repo.TryGetProperty("scm", out var scm) ? scm.GetString() : null,
                description = repo.TryGetProperty("description", out var d) ? d.GetString() : null,
                updated_on = repo.TryGetProperty("updated_on", out var u) ? u.GetString() : null,
                size = repo.TryGetProperty("size", out var sz) ? sz.GetInt64() : 0
            });
            if (++count >= request.Limit) break;
        }

        return new
        {
            workspace,
            count = repos.Count,
            repositories = repos,
        };
    }
}

It resolves the workspace, walks a paginated endpoint, reshapes each item, and returns an anonymous object. It never serializes anything and never writes to the console. CommandRunner does that, in one place, so the output contract cannot drift between commands.

Adding a command is now five steps with no thinking involved: write the request record, write the handler, register it in ServiceRegistration, wire it in the matching Commands/ file, and test it against the fake HTTP handler.

Not Multi-Targeting This Time 🎚️

My .NET 10 upgrade post was all about multi-targeting: keep net8.0 and net9.0 alongside net10.0 so nobody gets locked out. bbx targets net10.0 and nothing else, which is the opposite call, so it is worth saying why.

SeedFolder had users on older SDKs already. bbx had none, because it had never shipped. And the calendar was against the old frameworks: .NET 8 and .NET 9 both reach end of support on 10 November 2026. Multi-targeting a brand new package would have meant shipping build assets for runtimes that expire within months of release, for the benefit of zero existing users.

The compatibility problem multi-targeting solves is handled by one property instead:

<TargetFramework>net10.0</TargetFramework>

<!-- Run on a newer major runtime than the asset was built for, so the tool
     still works on a machine that has only the latest .NET installed. -->
<RollForward>Major</RollForward>

RollForward covers the direction that actually matters for a global tool. Someone who upgrades to .NET 11 keeps a working bbx without me shipping anything.

The rest of the packaging is the familiar global tool block, with a few additions worth calling out:

<PackAsTool>true</PackAsTool>
<ToolCommandName>bbx</ToolCommandName>
<PackageId>solrevdev.bbx</PackageId>
<AssemblyName>bbx</AssemblyName>

<PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
<PackageIcon>icon.png</PackageIcon>

<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>

AssemblyName is set to bbx deliberately. System.CommandLine 2.0 derives the program name in help and usage text from the assembly and no longer lets you set RootCommand.Name, so if the assembly is called Bbx then every usage line reads Bbx pr list. Naming the assembly after the command fixes it.

PackageReadmeFile points at a PACKAGE.md rather than the repository README.md. NuGet.org strips raw HTML, and the GitHub readme leans on <details> blocks for its command reference, so on NuGet it would have rendered as a wall of unformatted text.

The symbol properties mean a stack trace from a published build maps back to real source lines. Cheap to turn on, and worth it the first time a user reports a crash.

System.CommandLine 2.0 Broke Every Command File 🧨

This was the biggest surprise of the rewrite, and the thing I would most want to know before starting.

System.CommandLine 2.0 removed the strongly-typed SetHandler(handler, symbols…) family. In its place there is a single callback that hands you a ParseResult and leaves you to pull your own values out:

command.SetAction((parseResult, cancellationToken) =>
{
    var workspace = parseResult.GetValue(workspaceOption);
    var repo = parseResult.GetValue(repoOption);
    var state = parseResult.GetValue(stateOption);
    var limit = parseResult.GetValue(limitOption);
    return handler(workspace, repo, state, limit);
});

That is fine once. Across 150 commands it turns every command file from a description of the CLI into a pile of GetValue calls, and it throws away the compile-time check that a bound symbol matches the handler parameter it feeds.

So I wrote a small shim to keep the declarative shape. The awkward part is that GetValue has two overloads, one for Option<T> and one for Argument<T>, with no common base carrying the value type. A tiny struct closes over whichever applies:

internal readonly struct Bound<T>
{
    private readonly Func<ParseResult, T> _read;

    private Bound(Func<ParseResult, T> read) => _read = read;

    public T From(ParseResult parseResult) => _read(parseResult);

    public static implicit operator Bound<T>(Option<T> option) => new(pr => pr.GetValue(option)!);

    public static implicit operator Bound<T>(Argument<T> argument) => new(pr => pr.GetValue(argument)!);
}

The implicit conversions are what make it pleasant to use. A command passes an Option<T> or an Argument<T> interchangeably, and the compiler still checks that each one lines up with its handler parameter:

internal static class CommandBinding
{
    public static void SetHandler<T1, T2, T3, T4>(
        this Command command, Func<T1, T2, T3, T4, Task> handler,
        Bound<T1> b1, Bound<T2> b2, Bound<T3> b3, Bound<T4> b4)
        => command.SetAction((pr, _) => handler(
            b1.From(pr), b2.From(pr), b3.From(pr), b4.From(pr)));
}

Overloads run up to eight parameters, which covers every command in the tool. Command files went back to reading like a description of the CLI.

One thing the shim cannot restore: a broken command definition still compiles. Registering an option on the wrong command, or forgetting to register it at all, is a runtime problem now. That is a real bug I shipped and I will get to it shortly. The answer was a test class that asserts against parse behaviour rather than against handler output.

The Four Bugs You Only Find Against a Live API 🐛

Unit tests against a fake HTTP handler catch logic errors. They do not catch a wrong assumption about what the other end actually sends. These four all passed their tests and all failed in real use.

1. Every failed command exited 0.

The worst one, and the least visible. Handlers catch their own errors, print to stderr and set Environment.ExitCode = 1. The tool printed the right error. It exited 0 anyway, so every script and every agent saw success.

The cause is a rule that is easy to forget: a value returned from Main overrides whatever you put in Environment.ExitCode. Returning the invocation result alone discarded it, because System.CommandLine reports 0 whenever a handler returned normally.

var parseResult = rootCommand.Parse(args);

int exitCode;
try
{
    exitCode = await parseResult.InvokeAsync();
}
catch (Exception ex)
{
    // System.CommandLine 2.0 dropped the built-in exception handler, so
    // report the message here rather than printing a stack trace.
    Console.Error.WriteLine($"Error: {ex.Message}");
    return 1;
}

// A value returned from Main overrides Environment.ExitCode, and the
// invocation reports 0 whenever a handler returned normally. Handlers catch
// their own errors and set Environment.ExitCode, so returning the invocation
// result alone made every failed command exit 0.
return exitCode != 0 ? exitCode : Environment.ExitCode;

Keep the invocation’s non-zero result when there is one, because that is a parse failure. Otherwise fall through to whatever the handler set. If you take one thing from this post, take this one.

2. HttpClient drops your credentials when it follows a redirect.

bbx pr diff and bbx pr patch always failed with “You may not have access to this repository”. Both endpoints answer with a 302 to the underlying commit-range diff. HttpClient follows it, and strips the Authorization header on the way, because it cannot know the redirect target is trustworthy. The authenticated request arrives anonymous, and Bitbucket says you have no access.

The fix is to turn automatic redirects off and follow them yourself, re-applying credentials only when the target is on the same origin:

var hops = 0;
while (IsRedirect(response) && method == HttpMethod.Get && hops++ < MaxRedirects)
{
    var location = response.Headers.Location;
    if (location is null) break;

    var target = location.IsAbsoluteUri ? location : new Uri(current, location);
    response.Dispose();

    // Re-apply credentials only when staying on the same origin, so a
    // redirect out to storage cannot leak them.
    var sameOrigin = Uri.Compare(target, current, UriComponents.SchemeAndServer,
        UriFormat.UriEscaped, StringComparison.OrdinalIgnoreCase) == 0;

    current = target;
    response = await SendOnceAsync(HttpMethod.Get, target.AbsoluteUri, null, ct, accept,
        applyAuth: sameOrigin);
}

The same-origin check is not decoration. Downloads redirect out to object storage, and re-attaching a Bitbucket credential to that request would hand it to a third party. Only GET is followed, because other verbs would need their body re-sent and nothing we call redirects them.

3. TryGetProperty returns true for a JSON null.

bbx branch tag list crashed on any repository with a lightweight tag. Bitbucket sends "tagger": null for those, rather than leaving the property out. TryGetProperty reports success, hands back an element whose ValueKind is Null, and reading a property off it throws.

public static bool TryGetObject(this JsonElement element, string name, out JsonElement value)
{
    if (element.ValueKind == JsonValueKind.Object
        && element.TryGetProperty(name, out var candidate)
        && candidate.ValueKind == JsonValueKind.Object)
    {
        value = candidate;
        return true;
    }

    value = default;
    return false;
}

One tag crashed. The same pattern was wrong at 41 other nested lookups that had simply not met a null yet. Worth grepping for if you are reading someone else’s JSON.

4. A default Accept header caused an HTTP 406.

bbx pipeline logs returned 406 Not Acceptable for every pipeline. The client sends Accept: application/json as a default header, which is right for the ninety-odd endpoints that return JSON. The step log endpoint serves application/octet-stream and refuses to fall back. Setting Accept: */* on the calls that expect something other than JSON suppresses the default for that request.

Small fix, and completely invisible to a test that fakes the response.

Errors That Tell You What To Do 💬

Bitbucket answers a scope failure with a bare 403 and a detail object naming the scopes it wanted. Passing “HTTP 403 Forbidden” to the user throws that away and leaves them guessing. So the client unpacks it:

$ bbx repo deploy-keys list -w myworkspace -r myrepo
Error: Your credentials lack one or more required privilege scopes. (HTTP 403 Forbidden)
Missing token scopes: admin:repository:bitbucket. Re-issue your token with those
scopes at https://id.atlassian.com/manage-profile/security/api-tokens

Getting that link right took two goes. The first release pointed at bitbucket.org/account/settings/api-tokens/, which looks obvious and returns a 404: API tokens live on the Atlassian account, not under Bitbucket’s own settings. An error message that sends you to a dead page is worse than one that says nothing, because you assume you mistyped something.

There is a trap in the deserialization. error.detail is an object for scope failures and a string everywhere else. Typing it as string makes the whole payload fail to deserialize, which silently reduces every 403 back to the bare status. It has to stay a JsonElement:

public class BitbucketErrorDetail
{
    public string? Message { get; set; }

    /// <summary>
    /// Free-form. A string for most errors, but an object carrying
    /// <c>required</c> and <c>granted</c> arrays for scope failures.
    /// </summary>
    public JsonElement? Detail { get; set; }

    public string? Id { get; set; }
    public BitbucketErrorData? Data { get; set; }
}

The same routine prints error.data.announcement_url when there is one, so a deprecation error carries the changelog entry that explains it.

Auth: One Token, One File 🔐

bbx authenticates with an Atlassian API token and nothing else. Getting there took a detour worth describing, because the obvious answer is wrong.

I built OAuth first. It works, and I deleted it before release. Bitbucket’s OAuth is bring-your-own-consumer: every user has to create a private OAuth consumer in workspace settings, set a callback URL, and copy out a key and a secret before they can log in once. That is more setup than pasting a token, and it needs workspace admin rights a contributor may not have. App passwords were not an option either, since Bitbucket retires them on 9 June 2026.

So the flow is one command:

bbx auth login

It prompts for your Atlassian account email and the token, checks the pair against /2.0/user, and saves them to ~/.config/bbx/config.json with mode 0600. Two details that matter: Atlassian API tokens are basic auth with email:token, not bearer tokens, and the email is the Atlassian account email rather than the Bitbucket username.

An AuthGate runs before every non-auth command. On a terminal it prompts for a credential on first use. Without a terminal it fails with a message telling you what to run, and BBX_NO_INTERACTIVE=1 forces that behaviour even when a terminal is present. In CI you skip the prompt entirely by writing the config file:

- name: Configure bbx
  run: |
    mkdir -p ~/.config/bbx
    cat > ~/.config/bbx/config.json <<'JSON'
    { "AuthMethod": "api-token",
      "Username": "${{ secrets.BITBUCKET_EMAIL }}",
      "ApiToken": "${{ secrets.BITBUCKET_API_TOKEN }}",
      "DefaultWorkspace": "myworkspace" }
    JSON
    chmod 600 ~/.config/bbx/config.json

Commands that change or remove something confirm before they act, and take --yes to skip the prompt in a script. The prompt goes to stderr, so it cannot end up in a JSON pipe.

Endpoints Bitbucket Has Withdrawn 🚫

Some things cannot be built no matter how you write the client, and it saves time to know which.

bbx workspace list and bbx user permissions workspaces|repositories return HTTP 410 Gone. Atlassian removed the cross-workspace discovery endpoints under CHANGE-2770. There is no replacement. You name the workspace, or you set a default with bbx auth set-workspace. I spent a while assuming I had the URL wrong.

Usernames are no longer valid user selectors either; user lookups need an account UUID or account ID.

And Bitbucket Issues shut down on 20 August 2026. The issue command group is in the tool, works today, prints a warning to stderr, and goes away with the API. Building against something with a published end date is a strange feeling, but leaving it out would have been worse for anyone still using it this month.

Testing Without a Network 🧪

The test project runs 205 tests against a fake HttpMessageHandler and an in-memory credential store. Nothing touches the network, so the suite is fast and runs the same in CI as it does locally.

Three things I would repeat:

Tests do not run in parallel. Several capture Console or read Environment.ExitCode. Both are process-global, so parallel tests corrupt each other in ways that look like flakiness. One line in AssemblyInfo.cs disables it.

Parse behaviour needs its own tests. Because a broken command definition still compiles under System.CommandLine 2.0, handler tests prove nothing about wiring. A separate test class parses argument strings and asserts on the result. That is what caught --project-key being bound in the handler but never registered as an option, which made three workspace project subcommands impossible to use.

Check the licence on your assertion library. The tests use AwesomeAssertions, an Apache-2.0 fork of FluentAssertions 7. FluentAssertions 8 moved to the Xceed Community licence, which requires a paid commercial licence for use by or for a revenue-earning organisation. The API is identical and the namespace is AwesomeAssertions, so the switch is a find and replace. An automated dependency bump to version 8 would quietly create a licensing problem in a repository that looks fine.

The Release Pipeline 🤖

The workflow builds, tests, packs, publishes to NuGet, tags the commit and opens a GitHub release. My ytx post walks through that shape in detail, so here are only the parts that are different.

Pull requests validate, they do not publish. Two jobs guarded by the event type, sharing the same env block. A PR gets restore, build, test and pack. It never sees the NuGet key.

jobs:
  validate:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-dotnet@v5
        with:
          global-json-file: global.json
      - run: dotnet restore "$PROJECT_DIR" && dotnet restore "$TEST_PROJECT"
      - run: dotnet build "$PROJECT_DIR" --configuration Release --no-restore
      - run: dotnet test "$TEST_PROJECT" --configuration Release --no-restore
      - run: dotnet pack "$PROJECT_DIR" --configuration Release --no-build

  publish:
    if: github.event_name != 'pull_request'
    permissions:
      contents: write
    runs-on: ubuntu-latest

Note where permissions sits. The workflow default is contents: read, and only the publish job widens it to write. A pull request from a fork cannot get a token that can push.

The SDK comes from global.json. Rather than listing versions in the workflow, setup-dotnet reads the same pin the repository uses locally:

- uses: actions/setup-dotnet@v5
  with:
    global-json-file: global.json

CI and a local build cannot drift apart, because there is only one place the version is written.

The first release ships the version already in the csproj. Every bump script has an awkward first run. If it always bumps, your 1.0.0 in source publishes as 1.0.1 and the repository never matches the package. Checking for release tags handles it:

# The very first release ships the version already in the csproj. Every run
# after that bumps, so a later run can never try to push a version NuGet
# already has.
if git ls-remote --exit-code --tags origin 'refs/tags/v*' >/dev/null 2>&1; then
  case "$BUMP" in
    major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
    minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
    patch) PATCH=$((PATCH + 1)) ;;
  esac
  VERSION="$MAJOR.$MINOR.$PATCH"
else
  VERSION="$CURRENT"
  echo "No release tags yet; publishing the initial version as-is."
fi

It refuses to publish a stale commit. A push to master starts a run against a specific SHA. If another push lands while that run is packing, the job would tag and publish code that is no longer the tip of master. So before publishing, it checks:

REMOTE_MASTER=$(git ls-remote origin refs/heads/master | cut -f1)
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
  echo "Tag $TAG already exists; this is a safe retry."
  echo "tag_exists=true" >> "$GITHUB_OUTPUT"
else
  if [[ "$REMOTE_MASTER" != "$GITHUB_SHA" ]]; then
    echo "origin/master moved from $GITHUB_SHA to $REMOTE_MASTER; refusing to publish." >&2
    exit 1
  fi
  echo "tag_exists=false" >> "$GITHUB_OUTPUT"
fi

An existing tag is treated as a retry rather than an error, so re-running a failed job after a network blip does the right thing instead of exploding. The version and tag push together atomically:

git push --atomic origin HEAD:refs/heads/master "refs/tags/$TAG"

--atomic means the branch and the tag both move or neither does. Without it, a failure between the two pushes leaves a tagged version that is not on master.

The pack step verifies its own output. dotnet pack can succeed while producing a file named something other than what you expect, usually because a version property resolved differently than you thought. Checking for the exact filename before pushing turns that into a clear failure instead of a confusing nuget push error:

PACKAGE="$NUPKG_DIR/solrevdev.bbx.$VERSION.nupkg"
if [[ ! -f "$PACKAGE" ]]; then
  echo "Expected package was not created: $PACKAGE" >&2
  exit 1
fi

Try It 📦

dotnet tool install -g solrevdev.bbx
bbx auth login
bbx auth set-workspace myworkspace
bbx repo list --limit 10

Create the API token first from your Atlassian account security settings. Note that the page offers two buttons, and you want Create API token with scopes rather than the plain one: bbx reports missing scopes by name, which only works on a token that carries them. Grant the least you need, and read-only scopes are enough for everything above. Atlassian documents the process in manage API tokens for your Atlassian account.

Tokens live on your Atlassian account, not under Bitbucket’s own settings, which is worth knowing because it is not where you would think to look.

A few more read-only commands to get a feel for the output:

bbx pr list -r myrepo --state OPEN --limit 5
bbx branch list -r myrepo --limit 10
bbx commit list -r myrepo --limit 5
bbx src ls --ref main -r myrepo
bbx src cat --ref main README.md -r myrepo

Everything above prints JSON except src cat, which prints the file. pr diff, pr patch, commit diff and commit patch also print raw text, because that is the useful form for a diff.

To build from source:

git clone https://github.com/solrevdev/solrevdev.bbx.git
cd solrevdev.bbx

dotnet build src/Bbx/Bbx.csproj
dotnet test tests/Bbx.Tests/Bbx.Tests.csproj

# run without installing
dotnet run --project src/Bbx/Bbx.csproj -f net10.0 -- repo list -w myworkspace

What I Took Away 💡

  1. Test the exit code. It is the part of a CLI nobody looks at and every script depends on. Mine was wrong for months while the tool printed perfect error messages.
  2. Read the real responses, not the documentation. Explicit JSON nulls, a redirect that eats your auth header, an endpoint that refuses Accept: application/json. None of these are in the API docs and none survive contact with a mock.
  3. A wrapper is only as good as its error messages. Turning a bare 403 into “here is the scope you are missing and here is where to re-issue the token” took an afternoon and removed the most common support question before anyone asked it.
  4. Pick the boring architecture. One folder per verb, handlers that return objects, one place that serializes. Adding the last thirty commands took no design thought at all, which is the point.
  5. Check the licence, not just the version. The FluentAssertions 8 relicence is the kind of thing a dependency bot walks straight into.
  6. Write the agent documentation separately. A guide written for an LLM looks different from one written for a person: pattern tables instead of prose, argument shapes instead of narrative. Keeping them apart made both better.

What Is Next 🔮

  • A --fields flag to trim the JSON to what the caller wants, which matters when an agent is paying per token.
  • Shell completions for bash and zsh.
  • Retiring the issue group when Bitbucket retires the API on 20 August 2026.

The repository is at github.com/solrevdev/solrevdev.bbx, the package is at nuget.org/packages/solrevdev.bbx, and issues and pull requests are welcome.

Success! 🎉

Read the whole story
alvinashcraft
45 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

AI Mistakes at Work: Who Never Gets Fooled

1 Share

Who catches AI mistakes at work? Not the smartest person in the room. It has never once been the smartest person in the room.

AI Mistakes at Work: Who Never Gets Fooled who-is-awake-at-2am

Here is the pattern I keep running into with AI mistakes at work, and it took me an embarrassingly long time to see it. The person who catches the bad output is almost never the most experienced person on the team. It is not the senior architect. It is not the one with the certifications and the strong opinions about naming conventions. It is whoever is going to get the phone call at 2am when the thing falls over. Same room, same screen, same words on it. Completely different reading.

A quick note. The people and situations below are composites, blended and reshaped so nobody is identifiable. If you think one of them is you, it is not, though I understand why you would think so.

Why the Sharp People Get Fooled Fastest

This sounds backwards, so let me lay it out.

Expertise is mostly a very fast pattern matcher. Twenty years in, you do not evaluate things from first principles anymore. You glance, and something in the back of your head says yes, this is the shape of a correct answer, and you move on. That reflex is the entire payoff of a long career. It is why you are worth what you are worth.

Now consider what the machine is actually best at. Not being right. Producing things that have the shape of being right. Correct structure, confident tone, plausible numbers, sensible headings, the right vocabulary in the right places.

So the machine’s specialty is a perfect key cut for the expert’s lock.

The beginner has to read the whole thing because they do not recognize any of it. The expert recognizes it immediately, which is exactly how they miss it.

The junior takes forty minutes and asks three dumb questions, one of which turns out to be the whole problem. The expert takes ninety seconds and says looks fine. Both of them are behaving completely rationally given what they know. Only one of them just shipped the bug.

Two People, One Screen, Ninety Seconds Apart

I have watched this happen so many times that I have stopped calling it a coincidence.

A migration script comes back from the machine on a Thursday afternoon. Beautifully formatted. Better commented than anything a human being has ever written at five o’clock. It goes to the architect first, a man with fourteen years on that system who is, and I want to be clear about this, genuinely excellent. He scrolls. He nods. He says it looks clean and he goes to lunch. Ninety seconds, start to finish.

Then it goes to the woman who has the pager that weekend.

She does not scroll. She stops somewhere around line forty and says, out loud, to nobody in particular, what does this do to the archive table. Twenty minutes later four of us are standing at a whiteboard and lunch is a distant memory.

He is the better engineer. I would hire him twice. He is simply not the one whose Saturday was sitting on the table.

Nobody packs a parachute more carefully than the person who is about to jump with it.

Distance Is the Actual Problem

AI Mistakes at Work: Who Never Gets Fooled someone-else-will-catch-it

Watch where a piece of work sits in a building. If you wrote it, it is yours, and you will defend it in a meeting like it is your own child. If a colleague wrote it, it is theirs, and some quiet part of your brain clocks off without asking your permission first.

Generated output sits in neither place. That is the whole trouble.

Nobody wrote it, so nobody is defending it. Nobody built it, so nobody has that protective twitch you get about your own work. It arrives from nowhere, wearing a nice suit, and every single person in the room assumes somebody else already looked at it properly.

Then it goes to production, and the ownership question gets answered very fast and very unpleasantly at 2am.

The output nobody wrote is the output nobody owns, and the output nobody owns is the output nobody reads.

Six weeks after one of these, I sat on a call with a team who had not slept much, and somebody said the sentence I have now heard a hundred times. It looked fine when we tested it.

It did look fine. That was the problem. Looking fine is the product.

Before You Turn This Into a Blame Machine

I want to be careful here, because there is a bad version of this idea and it spreads faster than the good one.

The bad version is: make people afraid, and they will check more carefully. That is wrong, and I have watched it fail in real buildings. Frightened people do not review better. They review defensively. They approve things quietly so as not to be the one holding it, they stop asking questions that might make them look slow, and the good ones update their resume.

Accountability and punishment are not the same thing, and organizations confuse them constantly.

Accountability is: you will be there to see how this turns out. Punishment is: you will pay for how this turns out. The first one sharpens attention. The second one destroys it, because the rational move under punishment is to touch nothing.

What you actually want is consequence without cruelty. You want people to live with their decisions, not to be prosecuted for them.

Four Things That Actually Change the Outcome

Ask who is awake at 2am. Before anything ships, out loud, in the room: if this is wrong, who gets the call? If the answer is nobody, or if it is somebody who is not in this meeting, you do not have a reviewer. You have a spectator with opinions.

Give the reviewer the pager. Whoever approves it carries it for the first two weeks. That is the entire intervention, and it costs nothing. Watch how the quality of the review changes when the reviewer knows their own weekend is on the table.

Make somebody re-derive the important number. Not review it. Re-derive it, separately, and then compare. Reviewing a number means checking that it looks like a number. Producing it yourself means actually understanding where it came from.

Let the sharpest person go last. Genuinely. Have the least experienced person read it first, out loud, and let them ask the dumb questions before the expert’s pattern matcher fires and closes the conversation. That reflex is worth a fortune and it is also a door slamming shut. Slam it later.

The Good News, Because There Is Some

AI Mistakes at Work: Who Never Gets Fooled i-checked-it-myself

The thing that protects you is not intelligence, and that is genuinely good news, because intelligence is mostly issued at the factory and you cannot order more.

Caring is available to anyone. Being the person who has to live with the answer is a position you can choose to stand in, on purpose, even when nobody assigned it to you. You can decide to be the one who opens the plan. You can decide to be the one who asks the dumb question at four o’clock on a Friday when everyone else has a coat on.

That is not a talent. That is a decision, and it is available on a Tuesday to a person with no certifications at all.

This is one of the threads running through my book AI: Nobody’s in There. But we’re still in here. It is thirty short essays on judgment, learning, and work. One of them is called Why the Smartest People Get Fooled Fastest, though at the time I wrote it I had not yet worked out that the defense is not being smarter. It is standing close enough to the outcome that you cannot look away from it. Every essay is free to read at pinaldave.com, and there is a paperback on Amazon.

The machine will keep producing things that look right, because looking right is the entire trick and it is very good at it. Somebody in your building has to be close enough to the consequences to feel the itch. Might as well be you.

The best reviewer in the building is not the smartest person there. It is the one who has to be there when it breaks.

Reference: Pinal Dave (https://blog.sqlauthority.com/), X

First appeared on AI Mistakes at Work: Who Never Gets Fooled

Read the whole story
alvinashcraft
45 minutes ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories