From a1b39c236545c50d580eb5751ff06763c8ecfa93 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Wed, 28 Oct 2026 23:30:28 +0100 Subject: [PATCH] docs: record how Bakhyard runs on the laptop deploy-apps landed in the homelab repo. Decision 170 records what runs there and what its first day taught, marks deploy-k3s done and updates the README and the open questions. --- README.md | 4 ++-- docs/plan.md | 32 ++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 01b1cff..9414ece 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A personal dashboard for one user, hosted at home: Obsidian notes, work todos and pull requests, GitHub, games, and a Copilot planner, in one place. It's reached over Tailscale and needs a Google sign-in. It works on a Mac and on an iPhone, where it installs as a Home Screen app. -It's early. Today Bakhyard has its app shell, the sign-in, read-only notes, todos linked to Jira, open pull requests from Bitbucket, and a Home that gathers what needs the user from the todos and the pull requests. The modules come one small step at a time, and [the plan](docs/plan.md) has the steps, their order and the decisions behind them. It runs on a Mac for now, and later moves to an always-on Linux laptop with k3s. +It's early. Today Bakhyard has its app shell, the sign-in, read-only notes, todos linked to Jira, open pull requests from Bitbucket, and a Home that gathers what needs the user from the todos and the pull requests. The modules come one small step at a time, and [the plan](docs/plan.md) has the steps, their order and the decisions behind them. It runs at home on an always-on Linux laptop with k3s, deployed from a private homelab repo, and on a Mac for development. ## How it's built -Each feature is a Go service of its own, in `services/`, behind a gateway that checks who's asking and passes each request on. The app is a Svelte single-page app, in `apps/web`, which the `web` service embeds and serves. The gateway keeps its sessions, and todos its todos, in PostgreSQL, and process-compose runs the services. [§4 of the plan](docs/plan.md#4-system-architecture-the-microservices-experiment) has the details. +Each feature is a Go service of its own, in `services/`, behind a gateway that checks who's asking and passes each request on. The app is a Svelte single-page app, in `apps/web`, which the `web` service embeds and serves. The gateway keeps its sessions, and todos its todos, in PostgreSQL. The services run on k3s on the laptop, and in process-compose on a Mac. [§4 of the plan](docs/plan.md#4-system-architecture-the-microservices-experiment) has the details. ## Requirements diff --git a/docs/plan.md b/docs/plan.md index 038879d..692ce34 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -150,13 +150,14 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `home-fanout` | The Home lists what needs me for real: home asks each peer in its own `BAKHYARD_PEERS`, todos and pulls, for its items over the attention contract, all at once and each with 1.5 s to answer, and sorts them. A peer without the contract lists nothing; one that fails or is late is left out and logged, while the rest still show. An item without an outside source gets "Bakhyard" as its source. The demo items and the demo week are gone, and every entry of `BAKHYARD_PEERS` is now checked. The fifth of `home-feed`'s steps (decision 167) | Done (#133) | | `sonar-fixes` | Clears the eleven issues that SonarQube Cloud's run after `home-fanout` found, one of which turned its gate red: home builds its peers' addresses as the gateway and nudge build theirs; the nudge service's doors and the `nudge` tool's reporter take the context they stop with as an argument, or a `Close`, rather than in a field; the Bitbucket client's nested structs get names; notify's one-method interface becomes `Adder`; nudge's end-to-end tests assert what they check; and the app's push code reads the key's bytes as code points. Nothing changes on screen or on the wire (decision 168) | Done (#134) | | `home-sources` | The Home says when some of its items are missing: home's answer lists how asking each of its peers went, `ok` or `unavailable`, in `BAKHYARD_PEERS`' order, and while online the top bar reads "Online · 1 source unavailable" or "Online · 2 sources unavailable". A peer that fails or doesn't answer within 1.5 s is unavailable; one that doesn't serve the contract is ok. The sixth and last of `home-feed`'s steps (decision 169) | Done (#135) | +| `deploy-facts` | The plan records how Bakhyard runs on the laptop now that `deploy-apps` is in, in the homelab repo: the pods' security settings and the Secrets' mode, the databases and their TLS, git-sync's settings, the registry credential that stays, the image automation's timing, Jira and Bitbucket there, and what the laptop started with. `deploy-apps`, and with it `deploy-k3s`, is done (decision 170) | Done (#136) | | `nudge-merge` | Nudge, the user's phone-questions tool for agents (`codedogapp/nudge`), moves into Bakhyard: one app, not two (decision 157). The nudge session does the work, one small PR per step, from its plan, [`bakhyard-merge.md`](https://github.com/codedogapp/nudge/blob/main/docs/bakhyard-merge.md); this session doesn't stop its own steps for it | In review | After these: the product phases (§8.2), each split into steps. **Deferred until a step needs it**: TypeScript code from the contracts and `buf breaking` (decision 158), NATS and events, OpenTelemetry, Prometheus metrics, streaming, unix sockets, a shared kit, per-service Go modules and `go.work`, the git hook, most mise tasks, and process-compose's detached mode and API server (§4.9). -**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Three components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow: Home's "Needs you", the Todos page's groups and its Jira tickets; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). +**Ideas**: `sonar-issues` could page past 200 issues, or warn when it cuts them off. Longer task scripts could move into files that `check` shellchecks; today only the workflow's `run:` scripts are checked. A gate self-test could lint known-bad fixtures and expect every gate to fire, so a config typo can't switch one off silently (§10). pnpm's `savePrefix` setting could be empty, so `pnpm add` pins exact versions without `-E`. `//apps/web:check` could run `pnpm dedupe --check`, so a lockfile that a `pnpm add` by hand leaves with an older copy of a package fails too; Renovate's updates are deduped already (decision 101). The Home's components each style their own big numbers and mono labels (the app counts, the week's hours, the section head's note); shared type styles could replace the copies. The header's nav marks an app's link `aria-current="page"` on the app's deeper pages too (`/notes/x`); ARIA and GOV.UK's service navigation keep "page" for the link's own page and use "true" for a section the page is in, as the tab bar's More does (decision 52). Screen readers aren't told when a page changes in place; a polite live region that reads out the new title after each navigation could tell them, as SvelteKit's route announcer does. Each service's port is written twice in `process-compose.yaml`, in its `BAKHYARD_LISTEN` and its probe, and the gateway's `BAKHYARD_PEERS` repeats the other services'; a check could keep them in step. Nothing checks that every address is on the loopback, though nothing should listen on the LAN (§3). Pushover could join Web Push as a second notification channel, for alerts that repeat until acknowledged (§2, decision 64). After signing out, Back may bring back the signed-in page from the browser's back-forward cache, data included; the app could check its session again when a page is restored (`pageshow` with `persisted`). The web app's dev server (`//apps/web:dev`) proxies `/api` to the gateway on the loopback, which has answered 403 there since `auth-tailscale` (decision 77), so it shows the app without data; local development needs a way through the lock. The Notes filter matches the notes' paths only; a full-text search could look inside them too. A note with a dot in its name gets the web service's 404 when its page is reloaded (decision 94); the service could serve the app for every missing path under `/notes/`. Back and Forward can't put a note's page where it was left: the browser restores the scroll position once the page shows, before the note has loaded; the page could restore it itself once the note is in (the Navigation API's `scroll: 'manual'` and `event.scroll()`). Footnote links in a note go nowhere: rehype-sanitize prefixes the footnotes' ids but not the links to them, which GitHub fixes with a script (the vault has no footnotes yet). A task's checkbox has no accessible name, as on GitHub; it could take its item's text. These link forms wait until the vault uses them (decision 97): a note shown inline, where `![[Note]]` is a link today; heading and block links into other notes, which open the note at its top; links in a list property; links to files that aren't notes; relative paths (`../`); and links that match a note's name in another case. An image embed's size waits the same way (decision 98): `![[x.png|300]]` and `![[x.png|300x200]]` show the image at its own size today. So do embeds of other files, such as audio, video and PDFs, Markdown images of the vault's files (`![](x.png)`), and a way to see an image larger. Callouts, `> [!note]`, wait until the vault has one (decision 99). KaTeX's red for a formula that doesn't parse, `#cc0000`, has about 3.2:1 contrast on the dark paper, under WCAG AA's 4.5:1; a colour of our own could replace it. Every note's page loads KaTeX with the renderer, though one note in the vault has math; KaTeX could load only for a note that has some. The build also ships KaTeX's woff and ttf fonts, which a browser loads only when it can't read woff2, the first format KaTeX's stylesheet lists. Svelte's docs advise against the `class:` directive since 5.16, when the `class` attribute began to take an object or an array; `todos-groups` uses the attribute, while `AppCards` and the Notes `Tree` still use the directive. No test runs a migration's Down; a test could take each service's database down a migration and up again, so a Down that doesn't undo its Up fails. A todo dragged near the screen's top or bottom edge could scroll the page, so a todo on a long list can go further than the screen shows; today the user scrolls first, or uses the editor's moves (decision 120). Escape could cancel a drag. A dragged todo's row could follow the pointer while the other rows slide aside to make room, as iOS lists do, rather than a line marking the place; across groups, the cards would have to grow and shrink as it passes (decision 120). A todo could be dropped into a priority that has no todos, which has no section to drop it in; the editor's priority does that (decision 117). Three components copy the card's style, a 1 px `--rule` border on the `--surface` with the offset shadow: Home's "Needs you", the Todos page's groups and its Jira tickets; a shared class could replace the copies, as `list.css` did for the rows (decision 124). The end-to-end tests keep no trace of a failed test, so a flaky one can't be looked into afterwards; `trace: 'retain-on-failure'` would keep one. In `todos-ticket`, the Move test's five-second wait for the second move ran out twice in `mise run check`, on battery in Low Power Mode, though the move did land just after; it passed in 660 runs alone or in the whole suite. A linked todo's badge could link to its ticket in Jira; it sits in the box's label, where a tap ticks the todo, so the link needs a target of its own, at least 24 px and apart from the label (WCAG 2.2 SC 2.5.8, decision 126). The Jira inbox keeps a dismissal for good, so a dismissed ticket that leaves my open tickets and comes back later, reassigned to me or reopened, stays out of the inbox; the service could forget a dismissal once a search that succeeds no longer finds its ticket (decision 128). An inbox row could link to its ticket in Jira, as the Jira section's rows do; the row isn't a link, since it holds an Add button, so the link needs a target of its own, as the badge's does (decision 129). The Notes page's Sync could also make Gitea mirror the vault at once, through Gitea's `POST /repos/{owner}/{repo}/mirror-sync`, so a note that the Mac pushes shows on the laptop within a minute rather than up to ten (decision 151). The images workflow builds and tags all six images on every change, so Flux restarts every service each time (decision 152); it could build only the images that a change touches. GitHub's CI could build the images on each PR too, without pushing them, so a Dockerfile that no longer builds fails before the merge rather than in Gitea after it. The five Go services' Dockerfiles differ only in the service's name; one Dockerfile with the name as a build argument could replace them (decision 153). The Jira inbox's rule lives in two places, the Todos page's `inboxTickets` and todos' attention lister (decision 160); the page could take the inbox from todos through a JSON route, so the rule lives in one. The services talk in plaintext inside the cluster, gRPC over h2c and HTTP/1.1, behind the NetworkPolicies (decisions 137 and 158); mutual TLS between them, from a service mesh or from cert-manager's certificates, would encrypt that traffic. SonarQube Cloud's rule for clear-text protocols, S5332, looks for an `http://` literal, so it doesn't see these calls (decision 168). The services' connections to Postgres are encrypted but don't check the server's certificate (`sslmode=require`); with CloudNativePG's CA mounted and `sslmode=verify-full`, they would (decision 170). pulls could find my Bitbucket workspaces itself, through `GET /2.0/user/workspaces`, which needs the scope `read:workspace:bitbucket` that today's token lacks, and drop `BAKHYARD_BITBUCKET_WORKSPACES`. ## 1. Goal and scope @@ -174,7 +175,7 @@ Inputs from the user: - Devices: iPhone and MacBook; the host is an old Linux laptop, which is also the TV PC. - No paid Obsidian Sync; the vault syncs through iCloud (Desktop & Documents), and obsidian-git on the Mac pushes it to a private GitHub repo, which the host's Gitea mirrors and notes syncs from (decisions 84 and 151). Notes must render wikilinks, embeds and LaTeX; callouts wait until the vault has one (decision 99). Viewing them is enough for now; editing comes later (decision 69). -- Jira Cloud and Bitbucket Cloud are reachable without a VPN. Work tokens on the personal Mac are OK. Time is logged on the Jira tickets as standard Jira worklogs; Clockwork writes them too (decision 46). +- Jira Cloud and Bitbucket Cloud are reachable without a VPN. Work tokens on the personal Mac and on the laptop are OK (decision 170). Time is logged on the Jira tickets as standard Jira worklogs; Clockwork writes them too (decision 46). - Notifications go to the phone (push), the Mac and the browser. - Reminder rule: a PR open for more than 24 h without the required approvals → remind at 10:00 and 15:00 on workdays, with snooze. - Copilot Business sits on a separate work GitHub account, on a `*.ghe.com` host (GitHub Enterprise Cloud with data residency). The employer has agreed to its use for the planner (decision 72). @@ -1093,12 +1094,12 @@ Every item is pending unless it says done. The service an item lands in is in br - `app-icon`: done (#53). The app's own icon as the favicon and the Home Screen icon, and a web app manifest, with a `theme-color` per scheme. - `theme-toggle`: done (#54). A header toggle, System → Light → Dark, that overrides the OS's scheme on this device; `theme-color` follows it (decision 90). - The service worker moved to `notifications` (decision 89). -- `deploy-k3s`. Replaces `deploy-launchd` (decision 83). The laptop's cluster is the homelab repo's (decision 136), so Bakhyard's part is five steps, which come before any new feature (decision 150): +- `deploy-k3s`: done (codedogapp/homelab#18). Replaces `deploy-launchd` (decision 83). The laptop's cluster is the homelab repo's (decision 136), so Bakhyard's part is five steps, which come before any new feature (decision 150): - `deploy-images`: done (#121). A multi-stage Dockerfile for each service, a `.dockerignore`, and a workflow in `.gitea/workflows/` that builds the images with the cluster's BuildKit and pushes them to Gitea's registry as `codedogapp/bakhyard/` (decisions 138 and 153), each tagged `main` and `main--`, a tag that Flux can sort (decision 152); `images-registry` keeps only `main` (decision 156). - `secrets-files`: done (#122). A reader in the root `internal/`, `internal/secrets`, beside `internal/keychain`, for Secrets mounted as files, through which each service reads on the laptop what it reads from the Keychain on the Mac (decisions 140 and 154). - `notes-reopen`: done (#123). Each scan of the vault opens its root again, so a scan after git-sync has swapped its link reads the new checkout, and a file is read through the root of the scan that listed it (decisions 141, 151 and 155). - `images-registry`: done (#124). The images workflow reads the registry's host from Gitea's organisation variable `REGISTRY` and pushes each image with one tag, `main`, whose digest Flux follows (decision 156). - - `deploy-apps`: needs `deploy-images`, `secrets-files`, `notes-reopen` and `images-registry`. In the homelab repo: a Deployment for each service, notes' with a git-sync sidecar that syncs the vault from Gitea's mirror and has notes scan again after each sync (decision 151); the gateway's Tailscale Ingress, `bakhyard..ts.net`, and default-deny NetworkPolicies (decision 137); the database and role of each service that keeps data, in the shared CloudNativePG cluster (decision 139); the Secrets, from the homelab's secrets tool (decision 140), each service's mounted in the folder that its `BAKHYARD_SECRETS` names, as files with the names that decision 154 lists; the Google OAuth client's callback on the new name; and Flux's image automation, which rolls out each new image: an ImagePolicy per image that follows the digest of its `main` tag, and an ImageUpdateAutomation that commits each new digest to the homelab repo with a deploy key of its own that can write, since Flux's own key only reads (decisions 152 and 156). It may split into smaller steps there. + - `deploy-apps`: done (codedogapp/homelab#16, then codedogapp/homelab#17 and codedogapp/homelab#18). In the homelab repo: a Deployment for each service, notes' with a git-sync sidecar that syncs the vault from Gitea's mirror and has notes scan again after each sync (decision 151); the gateway's Tailscale Ingress, `bakhyard..ts.net`, and default-deny NetworkPolicies (decision 137); the database and role of each service that keeps data, in the shared CloudNativePG cluster (decision 139); the Secrets, from the homelab's secrets tool (decision 140), each service's mounted in the folder that its `BAKHYARD_SECRETS` names, as files with the names that decision 154 lists; the Google OAuth client's callback on the new name; and Flux's image automation, which rolls out each new image: an ImagePolicy per image that follows the digest of its `main` tag, and an ImageUpdateAutomation that commits each new digest to the homelab repo with a deploy key of its own that can write, since Flux's own key only reads (decisions 152 and 156). Decision 170 records how it turned out. - `home-feed` [home, pulls, todos, web]. Needs `auth-signin`, `frontend-shell`. Comes right after `deploy-k3s` (decision 150). The Home's "Needs you today" lists what the services have that needs me, through the attention contract, in six steps and a change in the homelab repo (decisions 158 and 167). Each later service answers the contract as it lands; live updates wait, so the app polls (decision 49). - `attention-contract`: done (#126). The contract `bakhyard.attention.v1`, its generated Go code and its gates; gRPC over h2c in every service's server; pulls answers `ListItems` with my open pull requests. - `todos-attention`: done (#128). todos answers `ListItems` with its todos left to do that are High or whose Jira ticket is done, with Jira as the source of those that name a ticket and no source for the rest (decision 159). @@ -1430,6 +1431,22 @@ Made on 2026-10-23: - While online, the top bar adds " · 1 source unavailable" or " · N sources unavailable" after "Online", with §7's separator, in the same polite live region, so each change is announced once. It says how many rather than which, so it fits beside the clock at 320 px. Offline, it says nothing of them, since "as of" already says the whole Home is old. - The app takes `sources` as optional, so it still reads an older home's answer during a rollout. The end-to-end tests' demo Home stays without it, so it reads "Online". - Rejected: naming the unavailable sources in the top bar, a note in "Needs you today", and a message per source, which wait for a screen that needs them. +170. **How Bakhyard runs on the laptop**: the lead's choices (2026-10-28) in `deploy-apps`, in the homelab repo (codedogapp/homelab#16, then codedogapp/homelab#17 and codedogapp/homelab#18), for the user to review, and the user's where they say so. They record what runs there and what its first day taught. + - **The pods**: each service runs as one replica in the namespace `bakhyard`, as distroless's `nonroot` user and group, 65532, which is also the pod's `fsGroup`. Each runs with seccomp's default profile, no privilege escalation, a read-only root file system, every capability dropped and no service account token. + - **The secrets**: each service that has secrets reads them from one Secret of its own, `-secrets`, whose keys are the file names that decision 154 lists, mounted read-only in `/secrets`, the folder that `BAKHYARD_SECRETS` names. The files have mode 0440, so only the pod's group reads them, which amends 154's 0644. The settings that name the work's Jira site, account and Bitbucket workspaces come from Secrets too, as environment variables, so they stay out of git as the tokens do. + - **The databases**: the gateway and todos, and notify and nudge since nudge's move (decision 157), each have a role and a database of their own, `bakhyard_`, in the shared CloudNativePG cluster (decision 139), made by its `DatabaseRole` and `Database` objects. Each role's password is 24 random bytes, hex-encoded, generated once by the homelab's secrets tool, which writes it into the Secret that CloudNativePG reads and into the service's own. + - **TLS to Postgres**: a service reaches its database at `postgres-rw.postgres.svc:5432` with `sslmode=require`, so the connection is encrypted. In that mode pgx, like libpq, doesn't check the server's certificate unless it's given a root certificate (`sslrootcert`), and CloudNativePG signs that certificate with a CA of its own, which the services don't have. + - **The first start**: a service's pod can start before CloudNativePG has made its database. The service then exits on SQLSTATE 3D000, and Kubernetes restarts it until the database is there, as the gateway and todos did once. Accepted. + - **The vault's sync**: notes' git-sync sidecar (decision 151) is v4.8.0 and checks the mirror every minute. Its first clone retries every 10 seconds for 5 minutes; later, after 10 failed syncs in a row, it exits, the kubelet restarts it, and notes keeps the vault it has. Its startup probe asks git-sync's HTTP endpoint every 2 seconds, for up to 10 minutes. git-sync's home folder is an `emptyDir`, since the root file system is read-only, and notes mounts the checkout read-only. + - **`safe.directory`**: git-sync v4.8.0 no longer marks every folder as safe for git (kubernetes/git-sync#993), and root owns the `emptyDir` it clones into, so git refused that folder for its "dubious ownership" and notes never started. `--git-config-add=safe.directory:/git` now marks that folder alone (codedogapp/homelab#17). + - **The vault's tokens**: Gitea's mirror reads the vault's GitHub repository with a fine-grained token for that repository alone, whose only permission is to read its contents, and git-sync reads the mirror with a Gitea deploy token without write access (decision 151), so neither can change the vault. + - **The registry's credential**: each ImageRepository reads Gitea's registry with the read-only token that k3s pulls with, in a Secret of type `kubernetes.io/dockerconfigjson`, `gitea-registry`, which the homelab's secrets tool now makes. Gitea serves the organisation's images to anyone on the tailnet today, but the homelab's docs treat its packages as private, so the credential stays: the user's choice. Amends 156, whose ImageRepositories needed none. + - **The rollout's timing**: each ImageRepository lists its tags every 5 minutes, and each ImagePolicy, which keeps the tag `main`, reads its digest every minute. The ImageUpdateAutomation runs every 30 minutes, and 5 minutes after the first of the ImagePolicies labelled `app.kubernetes.io/part-of: bakhyard` picks a new digest (`policyChangeDelay`), so a build's images go into one commit, as far as Flux can batch them. It commits as `fluxcdbot`, "chore(bakhyard): roll out new images", and pushes to the homelab repo's `main`, so whoever pushes there pulls first. The first merge timed, `home-sources` (#135), ran on the laptop 24 minutes after it merged: from 21:08 UTC to its new pods at 21:32. + - **Renovate**: in the homelab repo, Renovate leaves the images in Gitea's registry alone, since Flux rolls Bakhyard's out and Renovate's cloud can't reach the tailnet. + - **The mirror's owner**: Bakhyard's mirror in Gitea must belong to the `codedogapp` organisation, since only its repositories see its variable `REGISTRY` and secret `REGISTRY_TOKEN`. A mirror under the user's own account fails at the images workflow's "Check the registry" (decision 156), and Gitea's New Migration form picks the signed-in user as the owner unless it's opened from the organisation. + - **Jira and Bitbucket on the laptop**: the user's choice (2026-10-28), which answers §12's question about work tokens there (codedogapp/homelab#18). todos gets `BAKHYARD_JIRA_SITE`, `BAKHYARD_JIRA_API`, which is Atlassian's gateway URL with the site's cloud ID, as a scoped token needs (decision 103), and `BAKHYARD_JIRA_EMAIL` from the Secret `todos-jira`, and its token as the file `jira-token`. pulls gets `BAKHYARD_BITBUCKET_API`, `https://api.bitbucket.org/2.0`, from its manifest, `BAKHYARD_BITBUCKET_EMAIL` and `BAKHYARD_BITBUCKET_WORKSPACES` from the Secret `pulls-bitbucket`, and its token as the file `bitbucket-token`. + - **A change that adds Secrets** merges first, and the user makes its Secrets after the merge, from the homelab repo's map of them. Meanwhile the new pods wait for them (`CreateContainerConfigError`) while the old ones serve, then start by themselves. + - **What the laptop started with**: no todos, since the Mac's weren't moved (§12); a new pin of the Google account at the first sign-in (decision 87), in the gateway's new database; and no `BAKHYARD_NAME`, so the Home greets "there" until the user names it. ## 10. Risks @@ -1500,13 +1517,12 @@ A rough estimate, made on 2026-10-18, in focused working days. It assumes an age ## 12. Open questions -- Do the todos in the Mac's Postgres move to the laptop's, with a `pg_dump` and a restore, or does the laptop start empty (decision 139)? +- The laptop's todos started empty (decision 170): do the todos in the Mac's Postgres move there, with a `pg_dump` and a restore, or stay behind (decision 139)? - How do the laptop's alerts reach Bakhyard (decision 142): from vmalert straight to an endpoint that speaks Alertmanager's API, or through an Alertmanager's webhook? And how does a request from inside the cluster, which carries no Tailscale identity and no session, get past the gateway's two locks? - How do edits (Phase 6) get back to the Mac, now that the laptop syncs from Gitea's read-only mirror (decision 151): as commits pushed to the GitHub repo, which obsidian-git pulls, with a credential that can write? -- Are work tokens OK on the laptop too? The user's OK covers the personal Mac (§1). - Can Bitbucket's required approvals be detected automatically, or are they configured per repo by hand? -- Does the work site let a token in from home, or does an IP allowlist or a token policy block it? And does `assignee = currentUser() AND statusCategory != Done` list just my open tickets there (decision 121), and does the bulk fetch find the tickets that my todos name (decision 133)? Checked once the user's token is in. -- Which workspaces hold my work PRs on Bitbucket, and does the listing by my uuid find just my open PRs there (decision 143)? Checked once the user's token is in. +- The work site lets the token in from home: todos polls Jira from the laptop without a failure (decision 170). Does `assignee = currentUser() AND statusCategory != Done` list just my open tickets there (decision 121), and does the bulk fetch find the tickets that my todos name (decision 133)? For the user to check on the Todos page. +- The user has named the Bitbucket workspaces that hold my work PRs, and pulls polls them from the laptop without a failure (decision 170). Does the listing by my uuid find just my open PRs there (decision 143)? For the user to check on the PRs page. ## 13. Sources