Architecture

System Context

Woodrat is the file cataloging and archiving layer for the joel.holmes.haus platform: it records file metadata, stores the bytes in object storage, and publishes lifecycle events over Kafka. Files are cataloged either through a cmd/woodrat CLI client or directly against the ConnectRPC API. Downstream services subscribe to woodrat.v1.file_pushed by name โ€” the service currently recognizes owl-papers and owl-stacks as live subscribers and accepts owl-books as a target even though no consumer exists for it yet (see lib/woodrat/push/service.go).

C4Context title System Context โ€” Woodrat within joel.holmes.haus Person(admin, "Admin / Operator", "Catalogs, archives, and pushes files via the woodrat CLI or API") Boundary(platform, "joel.holmes.haus Platform") { System(cli, "woodrat CLI", "Go / Cobra โ€” cmd/woodrat: catalog, archive, push, folder, import, reconcile") System(woodrat, "Woodrat", "File cataloging, archiving, and distribution service") System(downstream, "Downstream services", "owl-papers, owl-stacks (live) ยท owl-books (accepted, no consumer yet)") } SystemDb(postgres, "PostgreSQL", "File and folder metadata") SystemDb(minio, "MinIO / S3", "Blob storage for file contents") SystemQueue(kafka, "Kafka", "FileCatalogedEvent ยท FileArchivedEvent ยท FilePushedEvent") Rel(admin, cli, "Uses") Rel(cli, woodrat, "ConnectRPC (FileService / FolderService)") Rel(woodrat, postgres, "Reads / writes metadata") Rel(woodrat, minio, "Stores / retrieves blobs") Rel(woodrat, kafka, "Publishes lifecycle events") Rel(kafka, downstream, "FilePushedEvent, by target name")

Note: go.mod also lists github.com/maxence-charriere/go-app/v10 (a Go WASM SPA framework), but no code in this repo imports it โ€” there is no admin web UI implemented here today; it may be vestigial from initial scaffolding.

Container Diagram

C4Container title Woodrat โ€” Internal Containers Boundary(woodrat, "Woodrat Service") { Container(cli, "cmd/woodrat", "Go / Cobra", "CLI client โ€” catalog, archive, push, folder, import, reconcile subcommands") Container(api, "cmd/api", "Go / ConnectRPC", "Serves FileService and FolderService over HTTP/2 (h2c) on :9000") Container(worker, "cmd/worker", "Go", "Standalone process, currently a no-op placeholder โ€” no Kafka consumers implemented (per code comment: \"MVP\")") Container(catalogSvc, "catalog.Service", "Go", "CatalogFile ยท GetFile ยท ListFiles โ€” creates file records, checksums, MIME sniffing via gabriel-vasile/mimetype") Container(archiveSvc, "archive.Service", "Go", "ArchiveFile โ€” reads bytes from local disk and stores to object storage") Container(pushSvc, "push.Service", "Go", "PushFile โ€” validates target, marks file as pushed, publishes FilePushedEvent") Container(folderSvc, "folder.Service", "Go", "CRUD for Folders; AddFilesToFolder; ArchiveFolder / PushFolder โ€” implements its own per-file upload + publish, reusing only push.ValidateTarget") Container(reconcileSvc, "reconcile.Service", "Go", "ReconcileUnprocessed โ€” backfills File records for objects already in the bucket (e.g. a raw mc mirror / aws s3 sync) that were never cataloged") ContainerDb(fileRepo, "lib/repo.FileRepo", "PostgreSQL / squirrel", "files table โ€” uuid, name, checksum, mime_type, origin, folder_uuid, cataloged_at/archived_at") ContainerDb(folderRepo, "lib/repo.FolderRepo", "PostgreSQL / squirrel", "folders table") } SystemDb(postgres, "PostgreSQL", "Persistent metadata store") SystemDb(minio, "MinIO / S3", "Blob store (gocloud.dev/blob)") SystemQueue(kafka, "Kafka", "Event bus") Rel(cli, api, "ConnectRPC") Rel(api, catalogSvc, "delegates (via catalog.NewCombinedService, which also wraps archiveSvc/pushSvc/reconcileSvc for FileService RPCs)") Rel(api, folderSvc, "delegates") Rel(catalogSvc, fileRepo, "CRUD") Rel(archiveSvc, fileRepo, "reads / updates") Rel(archiveSvc, minio, "PutObject") Rel(pushSvc, fileRepo, "updates state") Rel(pushSvc, kafka, "FilePushedEvent") Rel(folderSvc, folderRepo, "CRUD") Rel(folderSvc, fileRepo, "reads / updates, for bulk archive+push") Rel(folderSvc, minio, "Upload, per file in ArchiveFolder") Rel(folderSvc, kafka, "FileArchivedEvent / FilePushedEvent, per file") Rel(reconcileSvc, fileRepo, "creates records for unmatched objects") Rel(reconcileSvc, minio, "lists / reads unprocessed/ prefix") Rel(reconcileSvc, kafka, "FileCatalogedEvent") Rel(fileRepo, postgres, "SQL") Rel(folderRepo, postgres, "SQL") Rel(catalogSvc, kafka, "FileCatalogedEvent") Rel(archiveSvc, kafka, "FileArchivedEvent")

Note: worker (cmd/worker/main.go) starts, logs “no consumers in MVP”, and blocks on a signal โ€” it does not currently consume from Kafka or call into catalogSvc. All event publishing today happens synchronously from cmd/api’s request handlers.

The repository also contains a root main.go + cmd/ package (a separate, older Cobra scaffold using raw gRPC) and cmd/form; these are unfinished generator boilerplate โ€” most subcommands are unimplemented stubs (see cmd/model_cmd.go: “This file is a placeholder”) โ€” and are not part of the working CLI. The real, working CLI is cmd/woodrat.

File Lifecycle

stateDiagram-v2 [*] --> Cataloged : CatalogFile RPC\n(metadata recorded, no bytes yet) Cataloged --> Archived : ArchiveFile RPC\n(bytes written to MinIO/S3) Archived --> Pushed : PushFile RPC\n(FilePushedEvent published to Kafka) Pushed --> [*] Cataloged --> Pushed : PushFile RPC\n(no server-side guard requires prior archiving โ€”\nsee lib/woodrat/push/service.go PushFile)

Data Model

Reflects lib/repo/migrations/00000001_model.up.sql. The files table has no state column โ€” lifecycle is inferred from archived_at being null/non-null (there’s no separate “pushed” flag persisted; pushes are fire-and-forget events).

erDiagram folders { text uuid PK text name text description timestamptz created_at } files { text uuid PK text name text mime_type bigint size_bytes text checksum text storage_path jsonb origin timestamptz cataloged_at timestamptz archived_at "nullable" text_array tags text description text folder_uuid FK } folders ||--o{ files : contains

The migration also generates fileorigins, filecatalogedevents, filearchivedevents, and filepushedevents tables with full CRUD methods in lib/repo/model.go. These appear to be leftover scaffolding from a code generator โ€” no service in lib/woodrat/ calls them; origin is stored inline as JSONB on files, and events are published to Kafka rather than persisted. They’re omitted above as dead weight.

Environment Variables

VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string
KAFKA_BROKERSYesKafka broker address(es)
WOODRAT_BUCKET_URLNogocloud.dev/blob URL (e.g. s3://woodrat?endpoint=minio:9000). If unset, cmd/api starts with archiving disabled โ€” ArchiveFile/ArchiveFolder/ReconcileUnprocessed are unavailable rather than the service failing to start
OTEL_EXPORTER_OTLP_ENDPOINTNoOpenTelemetry collector; telemetry is a noop if unset
WOODRAT_API_URLNoUsed only by the cmd/woodrat CLI to locate the API; defaults to http://localhost:9000