From Dashboard to Architecture: Building, Debugging, and Correcting a Distributed S3 Test Cluster
Introduction
In the span of a single extended coding session, an AI assistant and a human architect transformed a single-node S3-compatible storage system into a horizontally scalable distributed architecture, built a real-time monitoring dashboard to observe it, constructed a Docker Compose-based test cluster to run it, and then—crucially—discovered and corrected a fundamental architectural error that had undermined the entire design. This is not a story of smooth, linear progress. It is a story of iterative refinement, of assumptions tested against reality, of permission errors that led to database initialization bugs that led to container detection failures that led, ultimately, to a complete redesign of the test cluster topology.
This article synthesizes the work across the entire segment, weaving together three distinct but deeply interconnected efforts: the implementation of the distributed S3 architecture itself, the creation of a comprehensive cluster monitoring dashboard, and the construction and debugging of a test cluster infrastructure. The thread that connects all three is the tension between architectural design and operational reality—the gap between how a system is supposed to work and how it actually behaves when you try to run it. That tension, and the discoveries it produces, is the true subject of this article.
The Architecture That Sparked It All
The session began with a user message that was itself a miniature architecture specification [1]. The requirements were precise and demanding: horizontally scalable S3-compatible storage, simple with no replication, purely for performance. All nodes should expose S3 endpoints with independent RIBS blockstores. A shared CQL database should track which object lives on which node. Stateless frontend proxy nodes should round-robin PUT requests and perform directed reads by querying the database. Multipart uploads must be assembled from parts distributed across multiple nodes. Kuri storage nodes should see minimal changes. And the system must deliver a read-after-write guarantee.
The user's message was remarkable for its density of architectural thinking. In a few paragraphs, it outlined a complete distributed systems design: the separation of concerns between frontend and storage layers, the use of a shared database for coordination, the round-robin distribution strategy, the directed read pattern, and the link-based multipart assembly optimization. The user was not just requesting code—they were testing whether the AI assistant could understand and extend an existing architecture.
The assistant's response demonstrated a sophisticated approach to architectural planning. Rather than diving directly into implementation, the assistant broke down the user's requirements into discrete points, then delegated parallel exploration tasks to understand the existing codebase. Each exploration task targeted a different subsystem: the S3 implementation, the CQL/YCQL schema, the multipart upload handling, and the blockstore interface. This delegation strategy was crucial—it allowed the assistant to build a comprehensive understanding of the codebase before making any changes.
The exploration revealed that the existing system was monolithic: a single Kuri node handled all S3 requests, stored all data in its local RIBS blockstore, and used YCQL only for multihash-to-group mappings. There was no concept of node identity, no object placement tracking, and no load balancing layer. Everything the user had requested had to be built from scratch.
The Architecture Plan: Blueprint and Hidden Flaw
The assistant synthesized the exploration findings into a comprehensive architecture plan organized into eight sections covering architecture overview, component details, data flows, read-after-write consistency, testing, implementation phases, design decisions, and success metrics. The plan included ASCII architecture diagrams, Go struct definitions, CQL table schemas, and detailed data flow descriptions for PUT, GET, and multipart operations.
The three-layer architecture was clean and elegant. At the top, stateless S3 frontend proxy nodes handle authentication, load balancing, and request routing. These proxies are completely stateless—they can be scaled up or down arbitrarily without data migration. In the middle, Kuri storage nodes maintain independent RIBS blockstore data, each storing different objects with no replication between them. At the bottom, a shared YCQL database tracks object placement, enabling the frontend to route read requests to the correct storage node.
The plan specified five YCQL tables, with S3ObjectsV2 as the core innovation—it maps (bucket, key) to node_id, enabling the frontend to locate any object with a single database query. But the plan contained a significant architectural error that would only become visible later. The assistant assumed that Kuri storage nodes should expose their own S3 APIs, with the frontend proxy forwarding requests to these S3 endpoints. The ASCII diagram showed "S3 API" listed under each Kuri node, and the data flow diagrams showed the frontend proxying to Kuri S3 interfaces. This was incorrect—the architecture roadmap specified that S3 frontend proxies are a separate stateless node type, and Kuri nodes are storage backends that should not expose S3 endpoints directly. This error would require a complete redesign of the test cluster infrastructure later in the session.## Phase 1: Wiring Node Identity into the Storage Layer
With the architecture plan approved and the user's feedback incorporated—"no schema migration, just change to existing" and "clarify that the merge op only needs links and not whole object data"—the assistant began implementation. The first step was modifying the existing Kuri node codebase to support node identification.
The assistant started by reading the S3Object interface in iface/s3.go to understand its current structure before making changes. The reasoning was explicit: the interface needed to be understood before it could be modified. The actual modification added NodeID and ExpiresAt fields to the S3Object interface. The NodeID field was the cornerstone of the distributed architecture—it enables the system to track which specific Kuri storage node holds each object. The ExpiresAt field provides object lifecycle management, enabling garbage collection of incomplete multipart upload parts and temporary objects.
This change was deceptively simple—it was a struct field addition, the kind of change senior developers make dozens of times per week. But in the context of this conversation, it represented the first domino in a chain of changes that would ultimately transform a single-node S3 storage system into a horizontally scalable distributed architecture. The assistant's choice to start with the data model rather than the more glamorous frontend proxy or routing logic reflected a methodical, foundation-first approach to software engineering.
The change was then extended to the persistence layer. The assistant updated the CQL implementation in object_index_cql.go with a three-part strategy: update the Put method to write node_id and expires_at columns, update the scanS3Object function to read node_id from query results, and update all SELECT queries to include the new column. This three-part approach demonstrated a thorough understanding of the read-write cycle in database-backed applications: data enters through writes, is stored in rows, is retrieved through queries, and is deserialized into application objects. Each step had to be updated consistently.
The assistant then propagated the node identity through the bucket and region layers. The bucket.go file was modified to set NodeID on PUT and multipart operations, wiring the environment-configured node identity into the actual object storage path. The scanS3Object function was updated to read the new fields. A nodeID field was added to the Region struct in region.go, ensuring the region layer could provide node identity to buckets. And finally, fx.go was updated to read the FGW_NODE_ID environment variable and pass it through the dependency injection chain.
Each of these changes flowed directly from the interface modification. The assistant worked through a clear dependency chain: interface → CQL persistence → bucket operations → region configuration → dependency injection. This systematic approach ensured that node identity was available at every layer of the storage stack.
Phase 2: Creating the Stateless Frontend Proxy
With Phase 1 complete, the focus shifted to Phase 2: building the S3 frontend proxy server. The moment of creation came when the assistant ran mkdir -p /home/theuser/gw/server/s3frontend to establish the directory for the new package. This directory creation was more significant than it appeared. By placing the frontend proxy in its own package at server/s3frontend/, the assistant committed to the architectural separation of concerns that the roadmap demanded. The frontend proxy was not a modification of the existing S3 server—it was a new entity with its own responsibilities and its own codebase.
The main server file was created with the FrontendServer struct containing an Authenticator for SigV4 validation, a BackendPool for managing connections to Kuri nodes, and an atomic counter for round-robin write distribution. The server implemented a handlePut method that selected a backend node using the round-robin counter and proxied the request, and a handleGet method that—for now—also used round-robin (Phase 3 would upgrade this to YCQL-based directed routing).
The BackendPool was created as a pool of backend Kuri node connections with health checking. The pool maintained a list of backend URLs, a round-robin index, and health check logic that periodically verified backend availability. This was a production-oriented feature that anticipated the need for fault tolerance: if a Kuri node went down, the frontend should stop routing requests to it.
The assistant encountered a minor but instructive issue during this phase: a duplicate logger declaration. Both server.go and backend_pool.go declared the same logger variable. The fix was simple—removing the duplicate declaration from backend_pool.go and using the logger from server.go—but it revealed the iterative nature of development. Even in AI-assisted coding, the first pass at a new package often contains minor inconsistencies that must be cleaned up.
A significant milestone was reached when the build succeeded. The assistant ran go build ./server/s3frontend/... and the compilation passed, confirming that the Phase 1 modifications and Phase 2 additions were structurally sound. The assistant updated the todo list, marking Phases 1 and 2 as completed and setting Phase 3 to "in_progress."
The assistant then provided a comprehensive implementation summary listing all modified files (five in Phase 1) and new files (three in Phase 2), describing key changes, and outlining the remaining phases. This summary served multiple purposes: it was a handoff to the user, a verification of architectural coherence, a documentation of design decisions, and a subtle request for continued direction.
The user's response was a masterclass in concise project management: "Continue with remaining phases, maybe also test what was done so far at least lightly in unit/integration tests." In fourteen words, the user approved prior work, authorized continued development, introduced a new priority (testing), set expectations about effort level, and delegated judgment to the implementer. The "maybe" qualifier was particularly interesting—it suggested testing was not originally a hard requirement, but the user recognized its value and introduced it mid-stream.
Phase 3: Implementing YCQL Read Routing
Phase 3 was the most technically challenging part of the implementation. The goal was to upgrade the frontend proxy from round-robin reads to directed reads: when a GET request arrived, the frontend should query the shared YCQL database to determine which specific Kuri node held the requested object, then route the request to that node.
The implementation of Phase 3 was marked by an extended sequence of iterative debugging. The assistant created a new router.go file containing an ObjectRouter struct, then worked through a cascade of LSP (Language Server Protocol) errors:
- The router was created, but
cqldb.NewYugabyteDBwas undefined—the assistant had used an incorrect API call. - The API call was fixed, but then
db.Sessionwas undefined because theDatabaseinterface didn't expose a Session field. - The assistant switched to using
r.dbdirectly, but old references tor.sessionremained inLookupObjectNodeandClose. LookupObjectNodewas fixed to user.db, butClosestill referencedr.session.Closewas finally fixed.server.gowas updated to add the router field toFrontendServer.NewFrontendServerandhandleGetwere updated to use the router.handleGetwas further updated, but now LSP reported: "not enough arguments in call to NewFrontendServer."- The
Startfunction was updated to accept and use the router. - The
fx.godependency injection wiring was updated to create and pass the router. This cascade of errors was not a failure—it was the natural rhythm of iterative development. Each error revealed a dependency that had to be updated: the constructor signature change required updating all callers, the interface change required updating all implementations, the new component required wiring through the dependency injection layer. The LSP errors served as a checklist, guiding the assistant through the necessary updates. What was remarkable was not that the assistant made mistakes—every developer makes these mistakes—but that the assistant worked through them systematically. Each error was identified, understood, and fixed before proceeding. The assistant never backtracked or lost context. It followed the dependency chain from component creation through full integration, letting the compiler be its guide.## Building the Dashboard: Making the Invisible Visible After completing the core implementation phases, the user requested something different—not more features, not bug fixes, but observability: "Design a UI with a live cluster and data flow overview, including useful performance charts." This request marked a transition from building infrastructure to making it visible. The assistant's response was methodical and architecture-aware. Rather than jumping into React components, it first explored the existing codebase—the RIBSWeb application—to understand the tech stack (React 18.2.0, Recharts 2.7.2, Create React App 5.0.1), the real-time communication pattern (WebSocket RPC), and the existing component architecture. This exploration ensured that the new dashboard would integrate seamlessly rather than feeling bolted-on. The design that emerged was comprehensive. It specified four major React components: a ClusterTopology SVG-based diagram showing the hierarchical relationship between load balancers, proxies, and storage nodes with color-coded health status and animated bezier curves; Performance Charts using Recharts for throughput (multi-line), latency (stacked area with p50/p95/p99 and SLA reference lines), and error rates (horizontal bar charts); NodeStatistics tables separating frontend proxy metrics from storage node metrics with sortable, expandable rows; and a DataFlowOverview with real-time animated counters breaking down request routing into reads (YCQL routing), writes (round-robin), and multipart (coordinating). The design also specified five new backend RPC methods—ClusterTopology,RequestThroughput,LatencyDistribution,ErrorRates, andActiveRequests—each with stub implementations that would need to be wired to actual metrics collection. A tiered polling strategy was defined: 100ms for active requests (to feel real-time), 1s for throughput and latency (smooth chart updates), 5s for topology and node stats (slower-changing data), and real-time push for events. A color scheme using CSS custom properties with semantic names (healthy green, degraded orange, unhealthy red) completed the design specification. What followed was a systematic implementation effort. The assistant created each component file in sequence—ClusterTopology.jsx,RequestThroughputChart.jsx,LatencyDistributionChart.jsx,ErrorRateChart.jsx,ActiveRequestsPanel.jsx,NodeStatistics.jsx,DataFlowOverview.jsx,RecentEventsTimeline.jsx—along with their corresponding CSS files, a barrel export (index.js), a navigation link in the existing routing structure, and integration into the RIBSWeb application. Each file write was accompanied by a build verification step, and when imports failed or components couldn't be found, the assistant methodically traced the errors to their source—often discovering that a file had been created with a slightly different name than what was imported, or that a barrel export was missing a re-export. The monitoring UI implementation was, in many ways, a model of systematic development. The assistant worked from a clear design specification, created files in a logical order, verified each step with build checks, and fixed issues as they arose. By the end of this arc, the RIBSWeb application had a new/clusterroute with a fully functional monitoring dashboard—at least in terms of UI components and stubbed backend data. But there was a problem. The dashboard had no cluster to monitor.
Building the Cluster: When Infrastructure Meets Reality
The second major arc began with a deceptively simple user question: "How do I run a test cluster with 2x2 nodes, data in /data/fgw-test/?" This question was a pivot point. The assistant had just completed an elaborate monitoring UI for a system that did not yet exist as a deployable test environment. The dashboard was a cockpit with no aircraft, a beautiful instrument panel with no engine to connect it to.
The user's question demanded that the architecture become operational. The assistant responded by creating a complete test cluster infrastructure: a Docker Compose configuration with two Kuri storage nodes, a shared YugabyteDB instance, and a suite of shell scripts—start.sh, stop.sh, init-data.sh, logs.sh, test.sh—all parameterized with a data directory argument. The Docker Compose file defined the service topology: Kuri nodes with environment variables for node identification, database connection strings, and API bind addresses; YugabyteDB with volume mounts for persistent storage; and a db-init service to create the required PostgreSQL database and CQL keyspace before the Kuri nodes attempted to connect.
But the infrastructure did not work on first try. What followed was a cascade of debugging that revealed the gap between configuration and reality.
The database that wasn't there. When the user ran ./start.sh /data/fgw2, both Kuri nodes crashed immediately with a deeply nested dependency injection error traceback. At the bottom of the chain: pq: database "filecoingw" does not exist. The YugabyteDB container was running and healthy, but the PostgreSQL database that the Kuri nodes expected to connect to had never been created. The environment variables YSQL_DB: filecoingw and YCQL_KEYSPACE: filecoingw were set on the YugabyteDB container, but these do not automatically create the database—they only configure what the YugabyteDB process would use if it were creating databases on first startup, which is a different mechanism entirely. The fix was to add a db-init service: a one-shot container that runs after YugabyteDB is healthy, executes the SQL commands to create the database and keyspace, and then exits.
The dependency dilemma. Even with the db-init service in place, there was a question of timing. The assistant initially trusted Docker Compose's depends_on with condition: service_completed_successfully to ensure that Kuri nodes would wait for database initialization. But the user, when asked, chose to add an explicit wait loop in start.sh—a defensive approach that made the script's behavior visible and debuggable. This decision reflected a preference for explicit error handling over implicit orchestration, born from operational experience with timing-dependent failures.
The port conflict that wasn't. The user raised a sharp-eyed concern: "Is the :9010 cluster mon not going to conflict with :9010 on individual kuri nodes?" The assistant's first instinct was to explain that Docker containers are isolated—internal container ports don't conflict with host-mapped ports. But the assistant quickly recognized the deeper issue: while there was no technical conflict, there was a usability and clarity problem. If someone wanted to debug an individual Kuri node by accessing its web UI directly, they couldn't, because those ports weren't mapped to the host. This triggered a cascade of fixes: disabling the web UI on individual Kuri nodes, adding explanatory comments, updating the README with a troubleshooting entry, and documenting how to re-enable individual node web UIs if needed.
The flag assumptions. Throughout the infrastructure work, the assistant repeatedly assumed that the Kuri daemon accepted certain command-line flags—--s3-api, --webui—that simply did not exist. The actual configuration mechanism was through environment variables like RIBS_S3API_BINDADDR, and the web UI started automatically on port 9010 with no flag to disable it. Each assumption had to be verified by reading the Kuri source code, and each discovery required a cascade of corrections across the Docker Compose file, the shell scripts, and the documentation.## The Permission Problem: When Containers Write as Root
The debugging chain began with a seemingly simple problem. The user ran the test cluster startup script and was greeted by over a hundred lines of chmod: Operation not permitted errors. Every file under the YugabyteDB data directory—configuration files, RocksDB data, write-ahead logs, consensus metadata—triggered a permission violation.
The root cause was straightforward: YugabyteDB runs its processes as root inside the Docker container. When it writes data to mounted volumes, the files are created with root ownership (UID 0). The init-data.sh script, written by the assistant, attempted to set permissions with chmod -R 755, but the user theuser was not root and did not own these files. The chmod command failed for every file.
This bug exposed several assumptions baked into the assistant's infrastructure code. First, the assumption that the data directory would be empty or newly created—but in practice, data directories persist across container restarts. Second, the assumption that the user running the script would have root-level access. Third, the assumption that permission changes would be idempotent and harmless even on pre-existing files.
The fix was pragmatic: suppress the permission errors gracefully rather than attempting to force ownership changes. The script was updated to use chmod ... 2>/dev/null || true, acknowledging that some files would remain root-owned and that this was acceptable for the test cluster's purposes. This "graceful failure" approach—accepting that certain operations will fail and handling that failure without cascading errors—is a hallmark of robust infrastructure code.
But the permission fix was only the first layer. As the user ran the script again to verify the fix, a new problem emerged.
The Database That Wouldn't Be Created
With the permission errors suppressed, the startup script progressed further but then hit a new failure: the db-init container exited with an error. The user, demonstrating disciplined debugging methodology, immediately ran docker-compose logs db-init and discovered the root cause: PostgreSQL reported database "filecoingw" already exists.
This was a classic idempotency problem. The db-init service was designed to create the PostgreSQL database and CQL keyspace needed by the Kuri storage nodes. But it used a simple CREATE DATABASE command without checking whether the database already existed. On the first run, this worked. On subsequent runs—or when the database state persisted from a previous attempt—it failed.
The assistant's fix was to add error suppression to the SQL command, using PostgreSQL's IF NOT EXISTS equivalent or simply redirecting errors to /dev/null with a message. This made the initialization script resilient to repeated runs, a fundamental requirement for any infrastructure automation.
But the user noticed something else wrong. The startup script was hanging, printing "Waiting for db-init... (1/30)" through "(8/30)" and beyond, even though Docker Compose had already reported the container as "Exited." The script was polling for a container that had already finished its job.
The Invisible Container: Docker Compose's Hidden Default
The user's observation—"seem to incorrectly wait for db-init where it was already waited on and correctly(?) exited"—identified a subtle bug in the startup script's wait logic. The script used docker-compose ps db-init to check whether the container had exited. But docker-compose ps, by default, only shows running containers. Exited containers are invisible unless you use the -a (or --all) flag.
This is a classic Docker Compose gotcha. The assistant had assumed that an exited container would appear in the ps output with a status like "Exited (0)." In reality, once a container exits, it disappears from the default listing entirely. The script's grep pattern was looking for a string that could never appear because the container producing it was already gone.
The fix was simple but critical: change docker-compose ps to docker-compose ps -a in the wait loop. This ensured that exited containers remained visible and could be detected by the status-checking logic. The user's sharp observation—noticing that the script was polling through multiple iterations while the container had clearly already finished—was the key insight that led to this fix.
But fixing the wait loop only revealed the next problem. With the database initialization now working correctly and the wait logic properly detecting completion, the Kuri storage nodes themselves began to fail.
The Architecture Correction: When Kuri Nodes Were Not S3 Endpoints
The Kuri nodes were failing to start with a cryptic error: "constructing the node: could not build arguments... failed to build iface.RIBS: trying to initialize external offload: no external module configured." The error propagated through the dependency injection framework, revealing that the RIBS blockstore could not be constructed because the CAR file staging module—a required component—was not configured.
The immediate fix would have been to add configuration for the external CAR staging endpoint. But the user recognized that this error was a symptom of a deeper problem. Looking at the test cluster configuration, the user noticed a fundamental architectural flaw: the assistant had configured Kuri nodes to expose S3 APIs directly, with a single shared configuration file, treating them as public-facing S3 endpoints rather than internal storage nodes.
The user pointed to the project's scalable-roadmap.md document, which clearly specified a three-layer architecture: stateless S3 frontend proxies routing to independent Kuri storage nodes, which in turn connect to a shared YugabyteDB database. The frontend proxies were supposed to be a separate node type—stateless, handling authentication and routing—while Kuri nodes were internal storage backends with their own independent configurations and CAR file staging endpoints.
The assistant had made several incorrect assumptions. First, that Kuri nodes could share a single configuration file—in reality, each node needs its own identity, its own EXTERNAL_LOCALWEB_URL for CAR file staging, and its own port allocations. Second, that Kuri nodes function as S3 endpoints—the roadmap clearly specifies that S3 APIs are exposed by the frontend proxy layer, not by storage nodes directly. Third, that the external CAR staging module was optional or could be configured later—in fact, it is a required component of the Kuri node initialization process.
The user's insight was articulated in a single, dense sentence: "start should do initial gwcfg, then transplant settings.env to the other node and only ask for second LocalWeb endpoint." This captured the entire correct workflow: configure the first node, generate its settings, transplant the configuration to the second node with modifications, and ensure each node has its own external endpoint.
The Per-Node Configuration Epiphany
The architecture correction triggered a complete redesign of the test cluster's configuration system. The assistant created gen-config.sh, a script that generates separate settings.env files for each Kuri node, each with distinct EXTERNAL_LOCALWEB_URL values and port allocations. The Docker Compose file was restructured into a proper three-layer hierarchy: an S3 proxy on port 8078 routing to Kuri storage nodes internally, which in turn connect to the shared YugabyteDB.
This redesign addressed all four incorrect assumptions. The Kuri nodes became internal storage nodes with independent configurations. The S3 frontend proxy became a separate stateless service. The external CAR staging module was properly configured for each node. And the configuration generation was integrated into the startup workflow, ensuring that each node had its own identity from the moment it was created.
The user's question—"Is there just one config? there needs to be one http endpoint per kuri node no?"—was the catalyst for this realization. The assistant's reasoning process shows a structured analysis moving from situation assessment through validation, exploratory what-if analysis, self-correction, and finally to a concrete implementation plan. This is a model of how to respond to architectural feedback: acknowledge the mistake, analyze the current state, explore alternatives, and produce a structured plan.
The Three-Layer Architecture: Stateless Proxies, Independent Storage, Shared Database
The corrected architecture that emerged from this debugging session is worth examining in detail. The system now has three distinct layers:
Layer 1: S3 Frontend Proxy (Stateless). This is the public-facing entry point, exposing an S3-compatible API on port 8078. The proxy handles authentication, request routing, and load balancing. It is stateless, meaning it can be scaled horizontally without data migration concerns. When a client sends a PUT request, the proxy selects a Kuri node (round-robin for writes) and forwards the request. For GET requests, the proxy looks up the object's location in the shared YCQL database and routes to the correct node.
Layer 2: Kuri Storage Nodes (Independent). Each Kuri node is an independent storage backend with its own RIBS blockstore, its own CAR file staging endpoint (LocalWeb), and its own configuration. Nodes do not replicate data between them—each node manages a subset of objects. This design prioritizes performance over redundancy, achieving scalability through parallelism rather than replication.
Layer 3: Shared YCQL Database (YugabyteDB). The YugabyteDB cluster tracks object placement metadata, mapping (bucket, key) pairs to the Kuri node that stores each object. This enables the frontend proxy to route GET requests to the correct node without scanning all storage nodes.
This architecture is fundamentally different from what the assistant had initially implemented. The original test cluster had Kuri nodes exposing S3 APIs directly, with no frontend proxy layer and no per-node configuration. The corrected architecture properly separates concerns, allows independent scaling of each layer, and provides a foundation for the remaining implementation phases.
The Interplay Between Observability and Infrastructure
What makes this segment particularly rich is the way the two major efforts—building the monitoring dashboard and building the test cluster—interacted and revealed each other's assumptions.
The monitoring dashboard was designed for an idealized architecture: stateless proxies routing to storage nodes, with clean data flows and well-defined metrics. But the test cluster, when actually built, revealed that the architecture was not yet correctly implemented. The dashboard's topology diagram would have shown proxies that didn't actually route requests. The throughput charts would have displayed data from Kuri nodes acting as direct S3 endpoints, not from the stateless proxy layer. The data flow overview would have misrepresented the actual request path.
In other words, the monitoring UI, if deployed against the flawed test cluster, would have displayed a system that did not match reality. It would have shown operators a clean three-layer architecture when the actual infrastructure was a flat, misconfigured topology. This is a profound risk in distributed systems monitoring: dashboards can lie, not because the data is wrong, but because the underlying architecture is different from what the dashboard assumes.
The test cluster, by contrast, forced the assistant to confront the gap between design and implementation. Every operational failure—the missing database, the incorrect flags, the permission issues, the port conflicts—was a test of whether the architecture could actually work. The user's practical question—"How do I run this?"—created the pressure that exposed the divergence between the roadmap and the code.
This interplay reveals a deeper truth about distributed systems development: observability and infrastructure are not sequential phases but parallel, mutually-reinforcing concerns. Building the dashboard forces you to specify what metrics matter, which in turn forces you to instrument the infrastructure correctly. Building the infrastructure forces you to confront the operational reality of your architecture, which in turn reveals what your dashboard should actually display. The two efforts are a feedback loop, not a linear sequence.
Lessons in Distributed Systems Development
Several lessons emerge from this segment that apply broadly to distributed systems engineering.
Configuration is architecture. The Docker Compose file was not just a convenience for developers—it was the concrete expression of the system's topology. By getting the configuration wrong—by making Kuri nodes direct S3 endpoints instead of routing through proxies—the assistant had effectively built a different system than the one described in the roadmap. No matter how elegant the code, no matter how thorough the design document, if the configuration that wires everything together doesn't match the architecture, the system will not behave as intended.
Assumptions must be verified against source code. The assistant repeatedly assumed that the Kuri daemon accepted certain command-line flags, only to discover through source code inspection that the actual configuration mechanism was through environment variables. Each assumption required a verification cycle—reading the code, discovering the discrepancy, and correcting all the files that depended on the wrong assumption. This pattern is universal in distributed systems: the documentation, the configuration, and the actual behavior of a service are three different things, and only the source code tells the truth.
Health checks are only as good as their criteria. The YugabyteDB health check verified that the database process was running and accepting connections, but it did not verify that the specific database "filecoingw" existed. The container passed its health check while still being unable to serve the application's needs. This is a classic pitfall: a health check that verifies "is the process running?" is very different from one that verifies "is the required resource available?"
Explicit beats implicit in operational scripts. The user's choice to add an explicit wait loop for db-init rather than trusting Docker Compose's dependency mechanism reflected a preference for debuggability over elegance. In distributed systems testing, where failures are often silent and timing-dependent, explicit error handling can save hours of troubleshooting.
Bugs are layered; each fix reveals the next. The permission fix revealed the database idempotency problem. The database fix revealed the container detection bug. The container detection fix revealed the CAR staging configuration error. And that error revealed the fundamental architectural misunderstanding. Each fix peeled back a layer of abstraction, exposing the next issue beneath.
The architecture document is the source of truth. The assistant had written the scalable-roadmap.md document but then drifted from its specifications during implementation. The user's act of referencing the document—literally calling it up and reading it—forced a confrontation between the documented design and the actual implementation.
Per-node identity is fundamental in distributed systems. The assumption that two nodes could share a single configuration file is a common mistake. Each node in a distributed system needs its own identity, its own network endpoints, and often its own cryptographic keys. Configuration must be generated per-node, not shared.
Small questions can reveal big problems. The user's initial question—"Is there just one config?"—was about configuration files, but it led to the discovery of a fundamental architectural error. Following a thread of inquiry even when it seems like a minor detail can uncover deep issues.
Conclusion: The Shape of AI-Assisted Distributed Systems Development
This segment of conversation captures a microcosm of what distributed systems development looks like in the age of AI assistance. It is not a smooth, linear progression from requirements to working code. It is iterative, error-prone, and dependent on human guidance at critical junctures. The assistant makes mistakes—using incorrect API calls, forgetting to update all callers after a signature change, misunderstanding the separation between frontend proxies and storage nodes—but each mistake is caught and corrected through the combination of LSP tooling and human oversight.
What emerges is a model of collaboration where the AI handles the heavy lifting of code generation, exploration, and implementation, while the human provides the architectural vision, domain expertise, and corrective feedback that keeps the implementation aligned with the design intent. Neither could produce the result alone. The human's vision without the AI's implementation would remain abstract. The AI's implementation without the human's vision would produce a system that violates its own architectural principles.
The most important insight came not from a log file or a stack trace, but from a user who looked at the test cluster configuration and recognized that it didn't match the documented architecture. The user's simple "Wait" and reference to the roadmap document was the pivot point that transformed a series of operational fixes into an architectural correction.
In the end, the test cluster that emerged from this session was architecturally sound: stateless frontend proxies routing to independently-configured Kuri storage nodes, all coordinated through a shared metadata database. The failures that preceded this correction were not wasted effort—they were necessary steps in the learning process that ultimately produced a better design. Each permission error, each database conflict, each invisible container was a teacher, and the lesson was always the same: in distributed systems, the architecture is not optional. Implement it faithfully, or let the bugs teach you why you should.
References
[1] "From Blueprint to Cluster: Building a Horizontally Scalable S3 Architecture for the Filecoin Gateway" — Detailed analysis of the architecture planning and implementation phases, covering the exploration of the existing codebase, the five-phase implementation plan, and the systematic changes to the data model, persistence layer, frontend proxy, and read routing.
[2] "From Dashboard to Cluster: The Two Faces of Building a Distributed S3 System" — Analysis of the monitoring dashboard implementation and the initial test cluster infrastructure, covering the React component design, WebSocket polling strategy, Docker Compose configuration, and the cascade of debugging fixes.
[3] "From Permission Errors to Architecture Correction: Debugging a Distributed S3 Test Cluster" — Analysis of the debugging session that uncovered the fundamental architectural error, covering the permission problem, database idempotency, container detection bug, CAR staging configuration, and the complete redesign of the test cluster topology.