Projects / recap
Aros/Session recap/
Sprint 4

Sprint 4 — Discovery, Notification Pipeline, and the CI Gate That Almost Was Not

Inventory + cross-adapter linker land. The notification pipeline dedups, correlates, and severity-routes to ntfy. Four ADRs accepted, two ai-context contracts, and the C3 observability cluster authored. The close-out hit a five-round CI integration iteration and a live discovery defect — the recap walks both.

Multi-session, autonomous + operator-gated cycles spanning weekend backlog + CI-gate stream + verify-and-close
cross-adapter linker: 6337694 · discovery defects fix: 1c29bee · frontend conventions: a1790f1 · grafana-pg patroni-raft: a464e66 · test-auth doc: b4a73c0 · Playwright smoke: e1732bb · integration on real PG: 3d91daa · migration single-head guard: 5b1d2ec · post-deploy smoke: cc8512c · CI iteration round 5: d623f12 · close-out verification: aa941a5
inventory
discovery
cross-adapter-linker
proxmox-adapter
notification-pipeline
dedup-correlation
patroni-raft
grafana-pg
msp-foundation
platform-tenant
force-row-security
rls-audit
ci-gate
playwright
integration-tests
post-deploy-smoke
adversarial-review
verify-and-close
the hero

Eyes, ears, and a gate

Sprint 4 is three threads in one. Discovery and inventory give Aros eyes — the proxmox and docker adapters populate inventory.entity, and the cross-adapter linker stitches container into VM into PVE host so the rollup actually reflects the physical tree. The notification pipeline gives Aros ears — alerts come in from Alertmanager, get dedup-correlated-severity-routed, and exit via ntfy when configured. And the CI gate work raises the bar for what merging means — a green PR now has to pass lint plus types plus unit plus integration-on-real-PG plus migration round-trip plus Playwright smoke, and its deploy has to pass commit-match plus a synthetic create-and-read against the seeded admin identity.

What landed

From Sprint 3 platform self-awareness to Sprint 4 discovery + a real CI gate

Sprint 3 made the platform able to see itself via the health adapters. Sprint 4 extends the same adapter framework to discovery adapters that materialise an inventory, layers a notification pipeline on top, authors the observability cluster IaC, and tightens the CI gate around the lot.

Migrations
3

0010 inventory + platform · 0011 admin seed · 0012 notification

Tenant-scoped FORCE-RLS tables
7

audit · tasks · policy · approval · inventory · alert · remediation

New ADRs
4

0027 amendment · 0028 notification · 0029 remediation · 0030 host-aligned MinIO

CI-gate pieces shipped
6

test-auth · Playwright · integration PG · single-head · smoke · template validate

The narrative

Four phases shipped, close-out iterated five rounds on the integration step

Phase A laid the inventory model. Phase B brought up the discovery adapters and the cross-adapter linker. Phase C2 was the notification pipeline. Phase C3 authored the observability cluster IaC. Close-out then ran the CI gate stream, found a live discovery defect, and iterated the integration step five rounds before it stood up.

  1. Phase A

    Inventory + discovery infrastructure

    Migration 0010 ships inventory.entity with FORCE ROW LEVEL SECURITY and the tenant_isolation policy keyed on current_setting(app.tenant_id). The platform tenant (UUID ending 0001) is distinct from the default tenant — platform owns infrastructure-tier entities, default is the fallback for OIDC users with no explicit membership. Discovery scheduler runs a Dramatiq actor on a 5-minute APScheduler interval inside a dedicated worker container.
  2. Phase B

    Discovery adapters + cross-adapter linker

    proxmox adapter discovers PVE nodes + QEMU VMs through PVEAuditor token (read-only, no write capability — that is a Sprint 5 action-framework concern). docker_adapter discovers containers per docker-host. Then the cross-adapter linker (6337694): the proxmox adapter pulls each VM ipconfigN from /qemu/{vmid}/config into metadata.ip_addresses; a post-discovery pass matches each container host IP to the VM that owns it and sets the container parent. Tree becomes container -> VM -> PVE node. Pure resolver, skips ambiguous IPs, never orphans.
  3. Phase B read-side

    Membership-gated tenant selection + akadmin seed

    Migration 0011 seeds the platform-admin identity (PLATFORM_ADMIN_USERNAME, PLATFORM_ADMIN_EMAIL — case-sensitive match) and the membership row binding it to the platform tenant. tenant_middleware now honors X-Aros-Tenant only when the user is a member of the requested slug; non-member selection returns 403 with a generic message so the response cannot be used to enumerate tenant slugs. Two-tenant membership test pinned the isolation invariant (RLS does the actual filtering; the middleware does the authorisation).
  4. Phase C2

    Notification service: dedup, correlate, severity-route

    Migration 0012 ships notification.alert + notification.remediation_candidate with FORCE RLS. The pipeline is four stages: deduplication by alert fingerprint, correlation that suppresses dependent alerts when a parent fires, severity routing that maps to ntfy priority bands, and the remediation_candidate hook that emits a row for Sprint-5 remediation logic to consume. ntfy endpoint is configurable; an unset endpoint is a no-op (alerts still persist and correlate, just no terminal push). Alertmanager webhook receiver plus the Aros-event consumer feed the same pipeline.
  5. Weekend backlog — ADRs

    Three new ADRs + ADR-0027 amendment

    ADR-0027 amended to lock in the three-physical-host failure-domain invariant: every Aros active site has exactly three Proxmox hosts; every resilient service spreads across them via anti-affinity; every service tolerates losing one of three; no quorum service requires more than five nodes, default three. ADR-0028 documents the notification intelligence pipeline. ADR-0029 documents the remediation engine outline for Sprint 5. ADR-0030 ships host-aligned MinIO so storage failure domains match compute failure domains.
  6. Weekend backlog — C3 obs cluster

    Observability cluster IaC authored, apply gated on operator

    Eight VMs across the three physical PVE hosts per the locked placement: pve1 hosts Prometheus-1, Thanos sidecar+query+store, Alertmanager-1, Grafana-1, grafana-pg-1; pve2 hosts Prometheus-2, Thanos compactor, Alertmanager-2, Grafana-2, grafana-pg-2; pve3 hosts Alertmanager-3, ntfy, grafana-pg-3. pve1 VMs prefer the SSD-R10 storage; pve2/pve3 use local-lvm. No Proxmox HA groups (no shared storage to restart onto, resilience is app-level). grafana-pg runs the postgres-patroni role in a new RAFT variant (no external etcd, pysyncobj-backed self-contained per-host quorum on port 2222). Apply gate: operator doubles pve3 RAM before running terraform apply.
  7. Weekend backlog — ai-context

    Frontend conventions + ai-context refresh

    13-frontend-conventions lands as the maturation-track contract: shell, data-fetching, X-Aros-Tenant threading, shared primitives (QueryBoundary / Loading / ErrorPanel / Empty / DataTable / StatusBadge), page layout, file conventions, testing convention (vitest + RTL + MSW + the 401-reload trap), and a maturation punch list. Caught a real audit-chain QueryKey collision between the index dashboard mock and the live audit page, plus the earliest-membership wording the middleware actually uses.
  8. Live runtime defect

    Discovery worker: interval paused, adapter assumes a permission it does not have

    Operator dispatched a bug — discovery-worker on dev was healthy and on the current image but discovery only ran once and never repeated, and inventory had zero VM entities despite the proxmox adapter calling /qemu and the docstring claiming VM.Audit is granted. Two defects in one. Worker fix in commit 1c29bee. See the matching ChallengeCard below.
  9. CI gate — Piece 1

    Test-auth strategy doc (14-test-auth)

    Header injection bypassing the edge for backend integration; Playwright E2E as frontend-only with page.route stubs; case-sensitive PLATFORM_ADMIN_EMAIL sourced via importlib. Adversarially reviewed with three lenses (citations, operational correctness, security framing) over 14 verifier agents — 21 confirmed findings, all addressed before commit. The biggest reframe: §7 was rewritten honestly because UFW currently allows :8000 from the whole 192.168.1.0/24, the edge does not strip client-supplied X-Auth-Request-* as a documented invariant (only overwrites on forward-authed routes via Traefik authResponseHeaders), and ADR-0013 does not enumerate the overwrite as a Consequence. The doc tracks all three as hardening follow-ups instead of pretending the invariant already holds.
  10. CI gate — Piece 2

    Playwright E2E smoke against vite preview, /api stubbed via page.route

    Eleven tests covering each operator-listed route plus three core flows (create-tenant, audit list, approve a pending approval). Adversarial review surfaced four blocking findings: catch-all 404 was registered last in setupApiStubs which in Playwright LIFO precedence means it WINS every request (catch-all moved to first so it runs last); single-asterisk glob does not span slash so `/api/v1/audit*` does not match `/audit/integrity` and trailing-double-asterisk is degenerate unless both neighbours are slash — switched the multi-segment routes to RegExp matchers. Plus the audit assertion strengthened from the always-rendered Chain integrity label to the actual `valid` badge value. Eleven tests discover (six route-renders + one title assertion + three core flows + one .skip pinning the forward-looking X-Aros-Tenant openapi-fetch middleware contract).
  11. CI gate — Pieces 3-6

    Integration on real PG, single-head guard, post-deploy smoke, template validation

    Piece 3 wires the 14 @pytest.mark.integration tests into a CI step against the existing postgres:16-alpine sidecar. Piece 4 adds an `alembic heads | wc -l == 1` guard so two engineers branching the migration tree without an explicit merge fails CI loudly. Piece 5 ships scripts/smoke.sh and per-env smoke steps that hit /health/db (commit-match) plus an SSH-and-curl synthetic create-and-read tenant against localhost:8000 to bypass the edge overwrite; smoke runs after deploy-dev and before notify-dev so a flaky smoke prevents the deployed ntfy push. Piece 6 lands the validate-pipelines step (yaml.safe_load every .woodpecker file) across the two app repos and introduces the first aros-infrastructure CI pipeline (terraform fmt + ansible syntax-check + ansible-lint + secret-grep).
  12. Close-out — RLS verify

    Verify-and-close: t/t clean across every tenant-scoped table

    The five-round integration iteration ended with the suite running as a dedicated aros_backend_test role (PG refuses to revoke SUPERUSER from the bootstrap user). That made the test pass — but it changes WHO connects, not necessarily whether the policies are correct. The close-out verification ran an adversarial RLS audit workflow against every migration that touches a tenant-scoped table. Verdict: t/t clean — audit.event, tasks.task, policy.tenant_policy, approval.approval, inventory.entity, notification.alert, notification.remediation_candidate all ENABLE+FORCE+key on current_setting(app.tenant_id). No USING(true) regression. No later migration ALTERs any of the seven to drop FORCE. Adversarial verifier ruled out the five escape hatches. Round 5 was clean test hygiene; the policies themselves are correct. The CI diagnostic dump now covers all six tenant-scoped schemas so a future regression to a new tenant-scoped table is loud in the CI log.
Six forks the recap is built around

Each of these moved Sprint 4 off the obvious path

The contract tests cover the interface and the unit lane covers the pure logic. Every challenge below was surfaced only by running against the live homelab, or by adversarially refuting an assumption that looked safe on paper.

Discovery worker: 5-minute interval was paused, not dead

apschedulerdiscovery-workerpg-protected-default Major
Symptom

Operator reported that the discovery worker was healthy and on the current image, but only one cycle had ever run (the 18:57 boot run) and no subsequent interval fire across 71 minutes. /api/v1/inventory was missing every VM entity it should have materialised.

Root cause

Two defects in one. The interval issue was the discovery_main entrypoint passing next_run_time=None to scheduler.add_job. In APScheduler 3.x that does NOT mean fire-immediately — it puts the job in the PAUSED state, because _real_add_job only computes the trigger first-fire time if not hasattr(job, "next_run_time"), and passing None explicitly satisfies the hasattr check while leaving the attribute None forever. The one cycle that ran was the explicit _enqueue_discovery() kick at boot, not the trigger. Verified empirically against APScheduler 3.11.2. The operator hypothesis — that the AsyncIO scheduler was blocked by the dramatiq consumer — was refuted by reading the actual worker: it is a BackgroundScheduler in its own thread, the worker thread is non-blocking, the seal worker uses the same pattern and works.

Resolution

Delete next_run_time=None and the misleading “Fire once shortly after boot” comment. Extract the add_job call into a _make_scheduler() helper so the regression test exercises the production code path, not a re-rolled copy. Pin via AST (no next_run_time kwarg, no positional past the canonical two — blocks the index-10 positional escape). Pin via behaviour (build the scheduler, start it, assert next_run_time is set). Also instrument the adapter so the next live cycle is self-diagnosing: per-node qemu HTTP 200 vm_count=N plus per-node tally plus a warning when qemu returns non-200. Commit 1c29bee.

Discovery: zero VM entities materialised, the docstring lied about VM.Audit

proxmoxadapterdocstring-debtpermissions Major
Symptom

Operator framed this as a code bug: the proxmox adapter must not be reading the per-VM /config endpoint, so it never persists VM entities. /api/v1/inventory had hardware (pve1/2/3) plus 12 orphan docker_container rows but zero VM-type entities.

Root cause

The framing was wrong — the adapter ALREADY materialises a vm/proxmox_vm InventoryEntity from any qemu-LIST row that carries a vmid (proxmox.py:148-198 in the qemu-list+gather block and the entity-append loop), independent of the /config call. The /config call only adds metadata.ip_addresses. An adversarial root-cause workflow refuted the framing and pointed upstream: zero VMs plus qemu HTTP 200 plus zero observed /config calls fingerprints an empty qemu LIST, which means the deployed PVEAuditor token cannot enumerate the homelab VMs (privilege-separation or VM.Audit scoped wrong). The proxmox.py docstring asserted “Config is readable with the read-only PVEAuditor grant (VM.Audit)” as a hard fact — that assertion was unverified and now contradicted by the live behaviour. The workflow also flagged the original operator math — “first new-code cycle should report ~15-20 VMs touched, only 2 touched” — as mathematically wrong: touched counts only discovered+updated (not unchanged), and 2 touched on an already-populated DB means VMs have been absent on EVERY prior cycle, not just this boot.

Resolution

Adapter code untouched (it is correct). Instrumented proxmox.discover so the next live cycle reveals which branch fires (empty list vs token-rejected vs vmid-shape) without a manual curl. Rewrote the docstring to mark the VM.Audit grant as an architectural ASSUMPTION (not verified deployment state) and to point at the new instrumentation log line as the source of truth, with the decisive operator-side curl spelled out inline. Added a project memory note (proxmox_token_audit_state.md) so future agents inherit the truthful state instead of re-reading the old confident docstring. The decisive curl is for the operator to run; once the token is fixed (or replaced) the memory + docstring get updated to record the empirical state.

Playwright stubs: LIFO route precedence inverted every specific handler

playwrightpage-routelifo-precedenceglob-span Major
Symptom

Adversarial review of the Piece 2 diff (the new E2E smoke) flagged that the catch-all 404 was registered last in setupApiStubs — and in Playwright the last-registered route wins. So every /api/v1/me, every /tenants list, every audit fetch was returning 404, every specific stub was dead, and the tests would either fail (good outcome) or pass for the wrong reasons (worse).

Root cause

Playwright matches route handlers in REVERSE registration order — documented behaviour in playwright-core types.d.ts. Earlier handlers only run if the later one calls route.fallback(). Also separate but related: single-asterisk glob in page.route compiles to [^/]* which does not span slash, so **/api/v1/audit* does not match /api/v1/audit/integrity. Trailing double-asterisk is only treated as a deep glob when both neighbours are slash or end-of-string, so **/api/v1/tasks** does NOT match /api/v1/tasks/<id>/cancel. Same for approvals approve/reject. Four blocking stub-shape bugs in one review.

Resolution

Restructured setupApiStubs: catch-all registered FIRST so it runs LAST in LIFO, every specific route registered after so they win. Multi-segment routes switched to RegExp matchers — /\/api\/v1\/tasks\/[^/]+\/cancel$/ etc. — because RegExp matchers are unambiguous and avoid the glob single-asterisk gotcha entirely. Audit assertion strengthened from /valid|integrity/i (which matched the always-rendered “Chain integrity” label even when the integrity stub was broken) to /^valid$/i (which only renders after chain.data resolves with status===‘valid’). PlatformHealth.components renamed from bare postgres/redis to the real health.patroni/health.redis keys the COMPONENT_ICON map expects.

Test-auth doc §7: the deployment-network invariant was aspirational, not enforced

ci-gatetest-authlan-trustadr-0013 Major
Symptom

First draft of 14-test-auth confidently asserted “backend containers MUST NOT be reachable from any network path that bypasses the edge” and “the edge MUST strip any client-supplied X-Auth-Request-* before forwarding.” Adversarial review (3 lenses, 14 verifier agents, 21 findings) refused to let either claim stand.

Root cause

The base UFW role in aros-infrastructure allows :8000 from from_ip: 192.168.1.0/24 — the entire LAN, not just the edge VM and the worker host. The aros-backend Dockerfile runs uvicorn --host 0.0.0.0 and CI binds -p 8000:8000 on the host. So any host on the LAN can header-inject and impersonate any identity today, including the seeded platform admin. Separately, ADR-0013 does NOT enumerate header stripping/overwriting as a Consequence — Traefik authResponseHeaders only OVERWRITES X-Auth-Request-* on forward-authed routes (the per-env -health routers in aros-edge.yml bypass forward-auth, so client-supplied headers on /health/ reach the backend untouched — but auth.py exempts /health/ from the auth path so the header is read-and-discarded). The “edge strips” mental model was too strong.

Resolution

§7 rewritten honestly. Three subsections: §7.1 documents what the edge actually does (overwrite, not strip; only on forward-authed routes); §7.2 admits the UFW :8000 LAN trust is not enforced isolation; §7.3 lists the three hardening tasks (tighten UFW to edge-plus-localhost, add a trusted-proxy or shared-secret guard in auth.py, amend ADR-0013 with a Consequences bullet). The doc tracks these as known follow-ups, not as something the recipes already assume. Also: stopped hardcoding akadmin@example.com in test recipes (the seed actually uses Hi@ryantaylor.uk, the email match is case-sensitive, and leaving the literal in the doc would have functioned as a header-injection cheatsheet); recipes now source via importlib at runtime from migration 0011 PLATFORM_ADMIN_*.

Five rounds of CI integration iteration, ending at a dedicated test role

ciintegration-testsrlspg-protected-bootstrap-user Moderate
Symptom

Piece 3 wired pytest -m integration into a CI step against postgres:16-alpine. The first run failed every PG-touching test with psycopg.OperationalError: fe_sendauth: no password supplied. After fixing that, eight tests failed with UniqueViolation: duplicate key value violates unique constraint user_username_key / Key (username)=(akadmin) already exists. After fixing that, one test (test_tenant_selection_isolates_inventory_and_rejects_non_member) failed with AssertionError: assert sel-client-entity not in {link-test-container, link-test-vm, sel-client-entity, sel-platform-entity} — a cross-tenant RLS leak in a force-enabled policy.

Root cause

Round 1: conftest._stable_env pinned PG_PASSWORD="" for every test as a defensive measure (so accidental DB connections fail loudly in the unit lane); the clobber survived into the integration lane. Round 2: tests hardcoded X-Auth-Request-User=akadmin and X-Auth-Request-Email=akadmin@example.com; migration 0011 seeds akadmin/Hi@ryantaylor.uk; upsert_user_from_oidc searches by email, did not find the wrong email, tried to INSERT a new identity.user with username=‘akadmin’, collided with the seed. Round 3: ALTER ROLE NOSUPERUSER alone — STILL leaked. Round 4: added NOBYPASSRLS — PG refused with permission denied to alter role / The bootstrap user must have the SUPERUSER attribute. PostgreSQL structurally protects POSTGRES_USER from being demoted by itself. The whole strategy of degrading the bootstrap role cannot work.

Resolution

Round 5: create a SEPARATE non-superuser role aros_backend_test with the same password as the existing PG_PASSWORD, grant it CONNECT plus USAGE plus ALL on every schema the app touches (public, identity, tenant, audit, inventory, tasks, policy, approval, notification), and run alembic plus pytest as that role with PG_USER override. Migrations already finished as the bootstrap superuser; the test role only needs to read alembic_version (no-op at head) and CRUD the data tables. Diagnostic dump in the step shows role attributes plus inventory.entity RLS state so a future regression is loud. Comment in ci.yml explains WHY aros_backend_test exists so nobody re-derives this in three months. Then the verify-and-close pass adversarially audited every tenant-scoped table — t/t clean — and extended the diagnostic dump to cover all six tenant-scoped schemas, not just inventory.

conftest fixture clobber, the operator never saw

pytestfixtureenv-override Minor
Symptom

Round 1 of the CI integration iteration above. Worth its own card because it is the kind of cross-lane fixture bug that only surfaces when a new lane runs for the first time.

Root cause

The autouse _stable_env fixture in conftest.py wrote monkeypatch.setenv("PG_PASSWORD", "") for every test. The intent was defensive: if a unit test accidentally tries to connect to PG, fail loudly with a missing-password error rather than reach the local DB on a development workstation. Reasonable for the unit lane. The integration lane is the first lane that actually needs PG_PASSWORD to be whatever the CI step set, and the fixture ran first and clobbered it before the FastAPI app loaded Settings.

Resolution

Make the override conditional on the test marker. if not request.node.get_closest_marker("integration"): monkeypatch.setenv("PG_PASSWORD", ""). Integration tests inherit the CI env value. All four other stable-output overrides (ENVIRONMENT, COMMIT_SHA, DEPLOY_TIMESTAMP, DEPLOY_HOST) stay unconditional so integration assertions on those values stay deterministic. Lesson archived in the close-out commit message so the pattern (test fixture for the unit lane silently breaks the new lane) is not re-discovered.

ADRs

Four decisions, written down so Sprint 5 has rails not folklore

Sprint 4 took the implicit three-physical-host assumption and made it a written invariant, then layered three more decisions on top — the notification pipeline, the remediation engine outline, and host-aligned MinIO.

ADR-0027 amendmentADR-0028ADR-0029ADR-0030
ai-context

Two new prescriptive contracts so future maturation work has rails

13-frontend-conventions and 14-test-auth both adversarially reviewed before commit. The reviews changed both materially — 13 fixed a real audit-chain QueryKey collision; 14 had its §7 rewritten honestly because the deployment-network invariant was aspirational.

13-frontend-conventions14-test-auth
The bar

What a green PR has to clear now

The CI gate stream is the part of Sprint 4 that pays off in every future sprint. A green PR has passed each of these, and its deploy will pass the post-deploy smoke.

lint + format + typecheck + unitintegration-on-real-PGmigration round-trip + single-headPlaywright E2E smokepost-deploy smokedeployment-template validation
Carried forward

Six operator follow-ups, explicitly tracked

Not blockers for Sprint 4 close, but they get written down so the level-set tomorrow does not rediscover them.

1. The decisive Proxmox VM.Audit curl (live behaviour says the token is gapped; docstring + memory now reflect that as unverified rather than asserted). Operator runs the curl when convenient; once known, update memory + docstring.

2. UFW :8000 tightening per 14-test-auth §7.3 — allow only from the edge VM (mvm) and localhost. Operator says they will do this with console access.

3. VM 109 teardown + monitoring.ryanit.com repoint to the new HA Grafana — runbook lives at aros-infrastructure/docs/runbooks/obs-cluster-deploy.md.

4. MSW install in aros-frontend + tests/setup.ts wiring per 14-test-auth §5.3. Recipe is in the doc; install is one PR.

5. Move PLATFORM_ADMIN_USERNAME / PLATFORM_ADMIN_EMAIL out of the digit-prefixed migration 0011 module into a normal module so tests can plainly import them instead of going through importlib.

6. Discovery-liveness CI flag — opt-in step that sleeps 6 minutes after deploy and asserts docker logs aros-worker-discovery-dev --since 11m | grep -c "discovery: cycle complete" returns >= 2. Catches a (b)-style regression on the 5-min interval.

Stack

Everything that landed in Sprint 4

On top of the Sprint 3 substrate. The discovery adapters + inventory + cross-adapter linker + notification pipeline + the C3 IaC are the data plane. The CI gate stream is the discipline-forcing function that keeps it honest.

inventory.entityproxmox + docker_adapternotification pipelinepostgres-patroni raft variantC3 obs cluster IaCCI gate streamai-context contractsMemory note
Receipts

Sprint 4 by the numbers

Counted across aros-backend, aros-frontend, aros-infrastructure, aros-docs, and projects-recap.

Adapters total
7

5 health from Sprint 3 plus proxmox plus docker

Adversarial review workflows
12

across discovery bug, ai-context docs, Playwright, RLS audit

CI integration iteration rounds
5

PG_PASSWORD -> identity -> NOSUPERUSER -> NOBYPASSRLS -> dedicated role

pytest passing in unit lane
219

14 integration tests deselected from this lane, exercised in CI integration step