Lessons learned
What went wrong while building Apelora, why, and the rule we follow now. Every coding agent reads this before starting a task (AGENTS.md says so) and adds an entry when something fails for a reason that was not obvious.
How to use this file
- Before a task: read the sections that touch your area. The rules here come from real failures.
- Add an entry when a check fails for a non-obvious reason, when a real system behaves differently from the mock or the documentation, when the owner corrects how we work, or when a fix needed more than one attempt.
- Entry format: one bullet,
**What happened** → cause → rule now, with the date (YYYY-MM-DD) and, where useful, the file or test that now guards it. Keep it to three lines. - Keep it useful: merge duplicates, and when a rule moves into code, a test or a skill, shorten the entry to a pointer. Delete entries that no longer apply.
- Review it at the end of each phase: look for repeated causes, which mean a process or tooling gap. Fix the gap, not just the instance.
Patterns so far
Read these first. Most entries below come from one of four causes:
- The mock or stub was kinder than the real thing. It answered instantly, had no limits and never failed. Model latency and limits in stubs, and test against a real local system before calling something done.
- A failure was silent. Nothing in the logs or on screen said why. Every refusal and swallowed error gets a log line with identifiers, and the UI shows the server's reason.
- Check the result, not the step. Wait for the observable result (an instance's location changed, the page shows data), not for "the job finished".
- Environment differences between macOS and Linux, development and production builds, and mouse and touch input.
Front end and browser
- The provider's sign-in code was exchanged twice in development and the sign-in failed (2026-10-21) → React StrictMode runs mount effects twice. The second run found the pending sign-in already used, failed, and triggered a request with no token. → Do one-shot side effects (code exchange, one-time tokens, POSTs on mount) once per page load through a shared promise (
finishOidcSignIninapps/fabric-console/src/App.tsx). Guard: the e2e test delays the token endpoint by 400 ms and expects exactly one exchange. - Stubbed tests passed while the real flow failed (2026-10-21) → the stub answered at once, so the race never happened. → Give stubs of external services realistic latency, and finish UI work with one run against a real local service (
infra/dev, profileoidc). - "Your session ended" when the real cause was a refused token (2026-10-21) → the UI picked a generic reason. → Show the server's message when the cause is not the obvious one (
fromProviderinApp.tsx). - A Playwright test's second
gototo another hash did not reload the page → hash-only navigation keeps the old route stubs and state. → Use separate tests for separate stub setups, orpage.reload(). - A stubbed identity provider failed in the browser → no CORS headers. Real providers send them for registered origins. → Stubs of cross-origin services send
access-control-allow-origin. - Row actions pushed Delete off screen at 390 px; 24 px buttons in measurements → no wrapping, and the measurement ran without touch emulation, while the console makes controls 44 px on coarse pointers. → Use
flex-wrap justify-endfor action groups, and measure phones withisMobileandhasTouchon. - Screenshots caught dialogs mid-animation; Ctrl+click did nothing on macOS → use
emulateMedia({ reducedMotion: "reduce" })andControlOrMeta. - The Studio showed the generic "saved sign-in was not accepted" after a refused provider token (2026-10-21) → two places ran the same detection, and the one that did not do the exchange reported last. → Keep facts about the page load (which token the provider issued) at module level, not in one call's local variable (
providerTokeninapps/studio/src/features/applications/backend.ts). - A docs agent captured screenshots while the same page was being redesigned in the main checkout (2026-10-21) → parallel work on one surface. → Do not run screenshot work for a page while it is being changed; regenerate shots after the design settles, in the same change as the design.
- A strict content security policy reported
evalin both applications although nothing called it (2026-10-21) → zod 4 probes forFunction("")inside atry; the browser reports the attempt even though it is caught. →z.config({ jitless: true })inpackages/assembly-model/src/schema.ts; prove a policy by loading the production build under it (tests/e2e/web-serving.spec.ts), never by reading the code. The terminal emulator writes<style>elements at run time, so both sites' policies allow inline style elements (the Console opens terminals too since 2026-10-21). - A Playwright test counted three loads where it expected two (2026-10-21) → the dev server runs React StrictMode, which mounts views twice. → Count requests from a baseline taken just before the action (
image mirrors show the mode…intests/e2e/fabric-console.spec.ts). - A shared terminal component spent its single-use ticket on a socket React closed at once (2026-10-21) → moving the Studio's terminal into
packages/terminalmade it a component that mounts with the ticket, and React StrictMode in development runs a fresh mount's effect twice; the stub saw two sockets; against the broker the first would spend the ticket and the second would get 401. In the Studio's old code the effect ran on a prop change of an already mounted component, so it never double-ran. → Effects that spend something single-use start it on the next task and cancel it in cleanup (TerminalScreen); the Console spec counts exactly one socket per session. - Tests broke when a fixture list grew (permission catalogue counts) → expected when you add to a shared fixture: update the counts in the same change.
- "One primary button" tests failed after adding dialogs (2026-10-21) →
FormDialogrenders its children while closed, so a closed dialog's primary button counts. → Render a dialog's form only while it is open ({open && <form>…}), which also makesautoFocuswork on opening. - A new field in an API response broke stubbed pages (2026-10-21) → the stubs in
tests/e2e/identity-fixtures.ts(andscreenshots.spec.ts) return the old shape, so the page readundefined. → When a response gains a field the UI reads, add it to every stub of that route in the same change. - A new header chip pushed the platform badge under it at 390 px (2026-10-21) → the Studio header's left group was
shrink-0 whitespace-nowrap, so it could only grow sideways. → Header groups wrap (flex-wrap); a Playwright test opens the chip's panel at 390 px (studio-capacity.spec.ts). ConfirmDialognever focused its Cancel button although the code calledfocus()(2026-10-21) → daisyUI's modal isvisibility: hiddenduring its opening transition, and a hidden element cannot take focus;prefers-reduced-motiondid not change that. → Focus dialog controls after the modal is visible (the dialog retries on the next frames,components/ui.tsx); assert focus withtoBeFocused()(fabric-console-operations.spec.ts).- A form dialog's
autoFocusfield was not focused when the dialog opened (2026-10-21) → the same cause asConfirmDialog: the field mounts while daisyUI's modal is stillvisibility: hidden. → Focus the first field with a retry on the next frames (useFocusWhenShown,views/Alerts.tsx); tests asserttoBeFocused(). - A spacing measurement reported a 492 px gap between sections (2026-10-21) → the capture had been trimmed by dropping middle sections, which leaves their space behind. → Trim only a contiguous run of subtrees from the top, or keep the whole page; re-read any gap finding against the trim.
- TypeScript refused
views/account/index.tsxnext toviews/Account.tsx(2026-10-21) → macOS file systems ignore case, so./views/accountresolved to the file andtscreported "differs only in casing". → Never name a folder like a sibling file; the Console's credential pages live inviews/credentials/. - Every accessible-name check failed for "Sign out" buttons labelled "Sign rita out of this sign-in" (2026-10-21) → axe's
label-content-name-mismatchwants the visible text inside the accessible name (voice control users say what they see). → Start anaria-labelwith the visible words: "Sign out rita, signed in …". - A ticking "checked 12 s ago" inside
aria-livewould be read out every few seconds (2026-10-21) → live regions announce every text change. → Announce state changes (errors, stale data), never clocks; see thefabric-consoleskill. - Grouping the navigation broke a phone test on another page (2026-10-21) → visible group headings and a taller header added 40 px above every page on phones, pushing the directory rows' actions out of the first screen that
toBeInViewportchecks. → On phones the navigation stays one wrapping row with the group names for screen readers only (App.tsx); run every console spec after changing the shell, not just the new page's. - The sectors test failed axe colour contrast (1.62:1) on a dialog heading, every run, with nothing changed in the view (2026-10-28) → axe ran right after Save, while the closing modal was still fading out, and measured the half-transparent text. → Wait for the dialog to be hidden (
await expect(dialog).toBeHidden()) before an accessibility scan; scan a dialog only once it is fully open or gone. - Servers tabs rendered as plain text, then failed axe (2026-10-21) → daisyUI styles
.tabonly as a direct child of.tabs(anliwrapper broke it), and its inactive tab colour is below 4.5:1. → Tabs are<a class="tab">directly inside thenav.tabs, inactive onestext-base-content/75(views/servers/Servers.tsx; axe infabric-console-servers.spec.ts). - A deployed application's canvas said "Mock · Design only" with no live state (2026-10-25, found through the screens on the lab) → only the deploy dialog started watching the status, and the stubbed spec deployed and opened the canvas in the same page. → The designer starts watching a deployed application itself (
ApplicationRoute.tsx). Every stubbed flow that matters also gets a step intests/lab/ui.spec.ts, which reaches the screen the way people do (a link, a reload, the next day). - A worktree's Playwright run would have tested the main checkout's code (2026-10-25) →
reuseExistingServeraccepts any server on 5173/5174, and a dev server from the main checkout was still running. → Check which directory the server runs from before trusting a run, and give separate runs separate ports (ui-e2e.shdoes). - A lab test sent
"volumes": nullto the worker (2026-10-25) →spec["volumes"].as_array_mut()on aserde_json::Valueinserts the missing key asnullbefore reading it (mutable indexing creates the entry). → Read an optional key withget_mut(…), nevervalue[key]on a mutable value. - A test timed out clicking a "Try again" that had gone (2026-10-25, only in the full suite) → one switch made both stubbed endpoints succeed, and the servers panel re-read on its own before the test clicked its retry, so the error panel and its button vanished. → Give each stub its own failure switch and flip it just before the retry that should succeed; a panel that polls can recover between two steps (
fabric-console-health.spec.ts). - "That token was not accepted" vanished now and then (2026-10-29, 1 failure in the full suite, 2 in 15 under load) → the sign-in check and the overview's own request were both refused for the wrong token; whichever came second ran
onAuthagain, found the token already cleared and set the reason to a bare sign-in page. → A refusal keeps the reason already shown (setAuthNeeded((shown) => shown ?? reason),App.tsx). To catch an ordering race, delay one stub and wait for its answer before asserting (fabric-console.spec.ts, token mode); passing on a single rerun proves nothing.
Control plane and identity
- The control plane refused a provider's token and logged nothing (2026-10-21) → refusals returned 401 without a log line. → Each refused credential is logged at warn as
credential refusedwith method, path, credential kind, issuer and reason, never the token. It is not audited, since one row per bad request would let anyone fill the audit log. - A real provider issues 24-hour tokens; Apelora accepts at most one hour (2026-10-21) → provider defaults differ from our limits. → Document the limit next to the setting, configure the dev provider for one hour, and make the refusal reason visible.
- A provider that is down at startup stayed refused until a restart (2026-10-21) → discovery ran only at startup and on save. → Retry unreachable providers every minute (
auth_config::retry_unreachable). - An LDAP bind with an empty password succeeds (2026-10-21) → it is an anonymous ("unauthenticated") bind, not a failed one. → Refuse an empty password before connecting (
ldap::sign_in; guarded byan_empty_password_is_refused_before_anything_is_sent). ldaps://LDAP.Examplekept its capitals after URL parsing (2026-10-21) → theurlcrate lowercases hosts only for special schemes (http, https, …), notldap/ldaps. → Lowercase the host ourselves (directories::validate): the directory URL is its people's issuer, so its spelling must be stable.- The development directory's
memberOfwas empty (2026-10-21) → thememberofoverlay does not run for offlineslapadd. → The seed listsmemberOfexplicitly (infra/dev/directory/seed.ldif). - Any change to sign-in settings can lock everyone out → the API enforces the guards, not just the UI (
lockout_guard,set_api_tokens,guard_leaving_local_accounts). Every new way in (local accounts, directories) must be counted by all of them. extract(epoch …)came back as NUMERIC and failed to decode asf64(2026-10-21) → PostgreSQL 14+ returns numeric. → Cast to::float8in queries that sqlx reads into floats.- A real control plane on the shared development database would have broken other checkouts (2026-10-21) → sqlx refuses to start when the database has a migration the binary does not know, and parallel sessions share
apeloraon port 15432. → For real end-to-end runs of a branch with a new migration, create a separate database on the same server (docker exec apelora-dev-db psql -U apelora -c "CREATE DATABASE …"), pointDATABASE_URLat it, and drop it afterwards. - A new
HealthStateschema silently replaced the instanceHealthStatein the OpenAPI document (2026-10-21) → utoipa names schemas by the Rust type name only, so two types with one name collide without an error. → Afternpm run api:generate, checkgit diff packages/api-client/src/generated.tsfor removed lines; give new schemas a distinct prefix (Health*,Capacity*). - Two people disabling the last two super admins at once both succeeded (2026-10-21) → the "last one" guard counted first and changed afterwards, outside a transaction; it also counted assignment rows, not enabled people. → Guards that keep "at least one" run the change and the count in one transaction under an advisory lock (
identity::change_keeping_a_super_admin;containment_tests.rsraces two requests). - A token or role could reach further than the person who made it (2026-10-21) → the escalation guard covered assignments only; tokens, revocation and legacy tokens (which skipped the route gate) had their own, weaker checks. → Every path that mints a credential or removes access applies the same containment (
beyond_caller,check_revocation), and every caller passes the route gate. - A security review found guards that covered one path to an effect but not its neighbours (2026-10-25) → the escalation guard covered assignments but not joining a group or mapping one; group names matched without their source; the sign-in limit read the socket peer while the rate limiter believed trusted proxies; a terminal re-check skipped the sign-in that opened it. → When adding or reviewing a guard, list every path to the same effect (granting a role, naming the client, ending access) and give each the same check and its own test (
containment_tests.rs, ADR 0016). - A persistence function generic over
impl sqlx::Acquirebroketokio::spawnof the supervisor with "implementation ofSendis not general enough" (2026-10-21) → the borrowed-lifetime bound cannot be proven for every lifetime inside a spawned future. → Take&PgPool, or&mut PgConnectionfor work inside a transaction (enqueue_job_in(&mut tx, …));impl PgExecutorfor single statements is fine. - A provider save audited
reachable, which is only known after the settings reload (2026-10-21) → once the audit row moved into the change's transaction, the reload and the probes could only run after the commit. → In-transaction audit rows carry only what is known before the commit; probes run afterwards and their result goes in the response (auth_config.rs,directories.rs). - Worker test
run_stops_on_shutdownfailed now and then (2026-10-21) → a fixed 100 ms shutdown raced the heartbeat task when the machine was busy. → Wait for the observable condition (the worker's row) before shutting down (services/deployment-worker/src/lib.rs). - Parallel
sqlx::tests asserting on a global metrics gauge would race each other (2026-10-21) → gauges are process-wide statics and tests run at once. → Assert on what the function returns (outbox::Pass); check the family's presence in the listener test (deployment-worker/src/metrics.rs).
npm run api:generatefailed again for the first file download (GET /support-bundle, a tar) (2026-10-21) → the generator knew JSON and text only. →application/x-tar,application/zipandapplication/octet-streammap toBlob, and the client returnsres.blob()(client.test.ts).- Widening the secret scan to every Apelora token prefix flagged an existing test's all-zero
aps_token (2026-10-21) → the scan greps source text. → Tests build token- and key-shaped values at run time (format!("aps_{}", "0".repeat(64)), a PEM label joined from words), so the scan stays strict. - Two handlers made
#[derive(OpenApi)]fail with "storage_pools_configis defined multiple times" (2026-10-21) → utoipa names a path's generated config after the module path joined with_, sostorage::poolscollided with the existingstorage_poolshandler. → Name new handlers somodule_fncannot equal an existing handler's name (storage::pool_usage). - A generic schema name (
Comparison) went into the OpenAPI document (2026-10-21) → utoipa names schemas by the Rust type only; a generic name would later collide silently. → Domain enums exposed through the API carry their area's prefix (AlertComparison,AlertSeverity); checkgenerated.tsfor generic new names, not only removed lines.
npm run api:generatefailed on the first route that answers text (GET /metrics) (2026-10-21) → the client generator accepted only JSON responses. → The generator maps atext/plainsuccess tostringand the client returns the text (packages/api-client/src/client.test.tsguards it). A new non-JSON route needs the same treatment.- Failed terminal opens were never in the audit trail (2026-10-21) → the audit table accepts the outcomes
success,deniedandfailed; the code wrotefailure, the database refused the row, and alet _ =swallowed the error. A mutation test and a test of the real database found it, not the compiler. → Write audit outcomes through one set of constants or an enum, never free text; never discard an audit result withlet _ =(log it, or refuse the action); a test of every outcome against the real table (sessions_tests). - A limit that refused a request had already spent the single-use approval it came with (2026-10-21) → the approval was consumed before the per-person cap was checked, so a person had to ask another person again. → Reserve the slot first, spend the approval last, and give it back (discard the reservation) if a later step fails (
a_refusal_by_a_limit_does_not_burn_the_approval). - A session's timers and its terminate button stopped working while a client was stalled (2026-10-21; found by the security review, not a test) → the loop awaited a send to a browser that had stopped reading, and nothing else in it ran. → Every send in a long-running loop has a timeout, limits are polled first (
biased), and the slot is released by a drop guard however the task ends. - The first review of the terminal feature found six holes the tests did not (2026-10-21) → the tests proved what the author thought of. → A read-only security review by a separate agent runs before a security feature is committed; give it the contract (
secure-remote-access) and ask it to find what the code does not do.
Incus (REAL INCUS TESTED behaviour that the documentation did not make obvious)
apelora-dnsexited at once inside Debian andsystemctl is-activereturned 3 (2026-10-23) → the stub listener holds127.0.0.53:53, and the kernel then refuses0.0.0.0:53(Address in use). The binary was fine; a direct run before the stub started was fine. → Turn the stub listener off in that instance (DNSStubListener=no), restartsystemd-resolvedwhen it is active, then start the unit. Runreset-failedfirst and ignore its status: a unit that has never failed makes it exit 1, and a crash loop otherwise hits the start limit so a later restart is refused even after the port is free (install_dns_resolver).- A later
network.synccould not update the resolver:text file busy(2026-10-23) → the first attempt had already started the binary, then waited on another instance, and the retry opened that path with truncate. → Writeapelora-dns.newandmv -fit into place; the running process keeps the old inode untilsystemctl restart. - The service name resolved and the instance name stopped after the client restarted (2026-10-23) → Debian's
resolv.confis the stub127.0.0.53, so the resolver forwarded to itself, anddns.domainreplaced theincussearch domain the instance name lives in. → Drop loopback forwarders, read/run/systemd/resolve/resolv.conffor the real servers, and setdns.searchto<application>.internal,incus.
- Waiting on a copy operation was refused for the restricted identity ("permission for project default") → the operation is created in another project. → Fallback: poll the project's running operations for the names, then check that the target exists (
copy_volume,project_operation_running). - Copied volumes lost their size and
user.*labels → set them explicitly on the copy. - A
refreshcopy deletes the target volume's own snapshots and is accepted while the instance runs. Plan restores around that. - Moving an instance was refused → the project needs
restricted.cluster.target=allow(now inscripts/test-cluster/up.sh). - Attached custom volumes do not follow a live-migrating VM unless marked dependent, which ties their lifetime to the instance. Design moves with that in mind (
docs/design/mobility-and-ha.md). - A module's IPv6 traffic was refused although its connection was drawn (2026-10-22) → Incus gives an OVN network an IPv6 subnet even when the uplink has none, and an instance gets its IPv6 address about a second after IPv4.
network.syncfilled the address set as soon as any address existed, so the set held only IPv4 and nothing synced again. → When the network hasipv6.address, the sync waits for each reachable instance's IPv6 address (a_sync_waits_for_an_ipv6_address_that_comes_after_ipv4;scripts/test-cluster/ipv6.sh). After 60 s with IPv4 and no IPv6 the set keeps IPv4 and the sync succeeds (a_sync_stops_waiting_for_ipv6_that_never_arrives); otherwise a guest that never configures IPv6 fails forever and the job-failures alert stays on. Check every address family a network hands out, not only the one the uplink uses. Check every reachable instance in a replica slot, not the slot as a whole. - An IPv6 /64 cannot be a service range (2026-10-22) → enumerating 2^64 addresses is impossible, and IPv6 has no broadcast so the last address is usable. →
APELORA_SERVICE_ADDRESSESaccepts IPv4/16–/30(skip network and broadcast) or IPv6/112–/126(skip the subnet address), including comma-separated ranges of either family; each module still gets one address. Backends must match the listen family (/32vs/128). Guard:service_ranges_skip_network_and_broadcast_and_reject_odd_input,a_sync_puts_ipv6_backends_behind_an_ipv6_service_address. - Giving the lab uplink (
incusbr0) an IPv6 address wedged new OVN network creates (2026-10-22) →incus network set incusbr0 ipv6.address=fd42:198:18::1/64on the live four-member cluster madeincus network create … --type=ovnhang with no operation;systemctl restart incusis the same trap as before (SIGKILLincusdthen start recovered). IPv6 service addresses only needipv6.routesandrestricted.networks.subnets. → Keep the lab bridge atipv6.address=none; routefd42:198:100::/112(ovn.sh). Guard:scripts/test-cluster/ipv6.sh. mapfileis not on the macOS bash that runs lab scripts (2026-10-22) → macOS ships bash 3.2;mapfileis bash 4. Scripts that run on the workstation (not inside the VM) use awhile readloop (scripts/test-cluster/ovn-ha.sh).- PEM files cannot be arguments to
limactl shell(2026-10-22) → newlines split the remote command. Copy the files onto the VM andincus config set …="$(cat …)"there (ovn-ha.sh). - Debian's
ovn-central.serviceis a dummy oneshot (ExecStart=/bin/true, 2026-10-22) → the databases areovn-ovsdb-server-nb,ovn-ovsdb-server-sbandovn-northd.systemctl enable --now ovn-centraldoes not restart them. Package install starts empty local DBs before join options are written. → Write/etc/default/ovn-centralfirst, restart those three units, and wipe the joiner DBs (ovn-ha.sh). - macOS LibreSSL
openssl x509 -reqsigns with SHA-1 (2026-10-22) → Debian OpenSSL 3 refuses the leaf (ca md too weak) and OVN never loads TLS. The CA was SHA-256 becausereq -new -x509defaults differently. → Always pass-sha256when signing leaves (ovn-ha.shissue). - A single-node OVN
Connectionofptcp:<ip>:6641is replicated to every raft member (2026-10-22) → joiners try to bind the first member's address and never listen on 6641/6642. → After the cluster forms,ovn-nbctl set-connection pssl:6641andovn-sbctl set-connection pssl:6642(no IP: each member listens on all interfaces). gh attestation verifyprinted the command list and every archive looked unverified (2026-10-26, Ubuntu'sgh2.45) → the subcommand arrived in 2.49. → Check theghversion first; with an old one, query the attestations API per digest (RELEASING.md), and say that the signature was not checked.- The mock copy ignored
fail_create; the mock move did not carryused_by→ when the real adapter gains a behaviour, give the mock the same behaviour, failures included, in the same change. - A real-move test passed too early → it waited for the job, not for the result. → Wait until the instance's location has changed.
- Two "memory used" figures disagree by gigabytes (2026-10-21, REAL INCUS TESTED) →
/1.0/cluster/members/<m>/stategives total and free RAM (caches count as used),/1.0/resources?target=<m>gives used without caches. → Show the/1.0/resourcesfigure (HealthServerResources.memory). - A shared pool (clustered LVM, Ceph) appears in every member's state with the same space (2026-10-21) → it is one pool. → Count it once (
capacity::pool_is_shared); summing per member multiplied the test cluster's free space by four. - The restricted deploy identity sees only its own projects' instances → per-server instance lists from it are partial. Say so on screen (Health → Instances) instead of implying the server is empty.
- Cluster settings came back empty for the deploy identity (2026-10-21, REAL INCUS TESTED read) → Incus answers
GET /1.0withconfig: {}to a project-restricted certificate; the admin identity sees it, together with secrets and the placement scriptlet. → Read settings with the admin identity and return only allow-listed keys (server_config(keys),CLUSTER_SETTINGS); defaults come from/1.0/metadata/configuration, which the deploy identity can read. - A virtual machine listed Running is not ready for
incus exec(2026-10-23, VM recovery drill) → Incus reports Running when the VM process is up, before the guest agent answers. The first drill aborted onset -eatincus exec … cat(the helper discards stderr), and the exit trap deleted the volume before the marker could be read. → Wait untilincus exec … truesucceeds, and capture a failed read (|| true) so the script can say FAIL before the trap runs. - Incus refuses an instance over a project limit with HTTP 500, not 403 (2026-10-21, REAL INCUS TESTED) → the limit check runs inside the create and is reported as an internal error ("Reached maximum number of instances", "Reached maximum aggregate value … for limits.cpu"). → Never map a limit refusal by status code; Apelora checks quotas itself before queueing (
tenancy::check_deployment), so the Incus refusal is only the backstop. - A project's state comes back as
Limit/Usage, capitalised (2026-10-21) → Incus'sProjectStateResourcehas no JSON tags, unlike the rest of the API. → Decode both spellings (WireResourceincrates/incus-adapter/src/tenancy.rs); read wire shapes from the pinned source, not from the YAML example. - A stand-by reports
database: true(2026-10-21, REAL INCUS TESTED read) → the flag means "holds a copy", not "votes". → Voters are members with thedatabaserole (clustering::database_role).
- "The SAN's lock stops a second writer" is only half true on clustered LVM (2026-10-21, read in the Incus 7.0.2 source) →
lvmclusteractivates container file systems exclusively but VM disks and block volumes shared (so they can move live); a shared lock does not exclude another host. → Fence on the failed host's sanlock lease being DEAD (its watchdog has fired), never on time or on a lock refusal (ADR 0013,apelora_domain::fencing). - The restart failed with "LV locked by other host" right after a member reported the failed host DEAD (2026-10-21, REAL INCUS TESTED) → each member's sanlock judges a lease dead from its own readings, up to about 10 s apart, and the target member had not yet. → The
instance.recoverjob waits (Outcome::Waiting, no attempt used) for up to 120 s after fencing; the lock refusal is the storage protecting the data, not an error to escalate. - Incus reported all four members Offline for 10 s while only one VM was stopped (2026-10-21, REAL INCUS TESTED) → the heartbeat round was held up by the dead member. → Nothing on shared storage moves on Incus's status alone; the lease check kept every healthy member unfenced.
- An offline member's instances cannot be read and cannot be moved to a group (2026-10-21) → Incus lists them without config, and with the source offline the move request is forwarded to the target, which must be a member. → Recovery policy comes from Apelora's deployment records, and Apelora names the target member (
recovery::choose_target); the placement scriptlet still validates it. - Rewriting every ACL on every
network.syncdominated a 120-container run (2026-10-22) →PUT /1.0/network-acls/{id}took a mean 3–6 s once the cluster held about a hundred objects, and one worker never finished. Four workers got the instances Running, then every sync still failed its first attempts (20 s GET timeout, or POST "already exists" after GET 404). → Skip ACL writes whose rules already match (a_sync_writes_only_the_acls_whose_rules_changed). Treat POST "already exists" as success when the object carries our marker (ensure_treats_already_exists_as_ours_when_the_object_carries_our_marker). Measure withscripts/test-cluster/measure.sh; figures indocs/design/capacity-lab-sizing.md. - A mock-verified resize failed on the lab's first attempt (2026-10-21) →
PATCH /1.0/instances/{name}on a running container is answered synchronously, but the adapter expected an asynchronous operation for every write. The job's retry hid it: the second attempt saw nothing to change. → A real run shows retries: read the test's output forwill be retriedlines, not just the result. Calls that may finish at once returnNonefor the operation (start_operation_or_done). - The substitute replica ran, yet half the requests to its service address failed for the whole outage (2026-10-25, REAL INCUS TESTED) → Incus refuses every load balancer create, update and delete while any member is unreachable: it notifies all members with
NotifyAlland reverts on failure (driver_ovn.go,cluster/notify.go, 7.0.1 and 7.0.2). Retrying cannot get through, and OVN's health check kept the dead backend. → When a retry never succeeds, read the upstream code path before tuning retries. The runbook tells the operator to bring the member back or force-remove it, and the upstream report is a maintainer's to file. A passing drill (2026-10-20) had hidden this.
- The supervisor "restored" a replica seconds after it was deployed (2026-10-25, found by a lab run) → a new instance is Stopped in Incus until its
instance.createjob has started it, and the supervisor checked only for aninstance.startjob in flight. → A stopped replica with a create job in flight counts as still being created (a_replica_still_being_created_is_not_started_by_the_supervisor). When a state can be reached two ways, check every job that leads to it.
Lab, shell and tooling
- A new member of a sized loop pool needs the size and an empty source (2026-10-24) → the join that brought the second, third and fourth members online answered both:
size(12GiB) andsourceempty, so Incus created the loop file. A preseed with only the address and the token does not answer those questions. →scripts/enrol/enrol-member.sh --pool-size 20GiBwrites both for the pool namedlocal. A disk pool still uses--member-config. This switch has not been run against a cluster; the keys are the ones that join used. - A moved replica never rejoined its service address (2026-10-26, evacuation drain on the lab) → after an evacuation the container ran on its new member with its IPv6 address but no IPv4 lease for a few seconds; the sync that ran then skipped it (no address of the service's family) and still succeeded, and nothing changed afterwards to sync again. → A running replica without its address makes the sync retry (
servicesinnetwork.rs, testa_running_replica_without_its_address_yet_is_waited_for_not_left_out). When a sync skips something silently, ask what will make it look again. - The evacuation check counted failures the step before had caused (2026-10-26) → after the member-failure step the load balancer still pointed at the retired substitute until a refused sync went through, while Apelora's own port check already said both replicas were healthy. → Start a measurement from what the data path reports (OVN backend health from the load balancer's state), not from the application status.
grepwith no match ended aset -euo pipefailscript (2026-10-26,demo.sh) →failed=$(grep -o FAIL file | wc -l)fails the pipeline when nothing matches, which is the success case. → Wrap a grep that may find nothing:{ grep … || true; } | ….- The first install showed an old sign-in page (2026-10-24) → nothing newer than draft
v0.1.0-alpha.1(20 October) had been tagged, so the installer unpacked that package. Its screens predate the sign-in page, and its control plane has noGET /api/v1/sign-in. → Installv0.1.0-alpha.2(the default inscripts/test-cluster/install-units.sh). That tag is main, so the control plane, the worker and both screens are one tree. Leavev0.1.0-alpha.1unpublished. - Incus installed on the first new guest and failed on the other three (2026-10-24) → those guests were still running their first-boot package setup, which held
/var/lib/apt/lists/lock. The installer calledapt-getonce and exited. →scripts/test-cluster/install-incus.shwaits for cloud-init, then retries apt while the lock is held. - Copying a file to several new hosts at once did not record their host keys (2026-10-24) → OpenSSH rewrites
known_hoststhrough a temporary name, and the parallel copies collided (known_hosts.old: File exists). → Add new hosts one at a time, or give each copy its ownUserKnownHostsFile. - A storage helper said creating a volume timed out, and the volume was empty (2026-10-24) → the helper returned after the volume existed and before the image was copied (a 3 GiB volume holding 56 KiB). Copying the image into that volume worked. → After that error, check how much of the volume is used. If it is empty, copy the image into it. Do not run the helper again; it tries to create the same volume.
npm run lintfailed with "multiple candidate TSConfigRootDirs" (2026-10-21) → agent worktrees live under.claude/worktrees/inside the repository, and ESLint scanned their copies. →.claude/**is in the ESLint ignores; any new repo-wide tool must ignore it too.- "
cargo rungives development mode" was wrong inside the repository (2026-10-21) →.cargo/config.tomlsetsDATABASE_URLfor every cargo command. → Development mode isDATABASE_URL= cargo run -p apelora-control-plane; check tool-level config before documenting a default.
- A worktree with a new migration must not use the shared development database (2026-10-21) → once a worktree applies a migration the main checkout does not have, the main checkout's control plane refuses to start (sqlx: an applied migration is missing from its sources). → For real-stack runs in a worktree, create a separate database on the dev server (
CREATE DATABASE apelora_<topic>) and drop it afterwards;#[sqlx::test]already uses fresh databases. - Dev servers on 5173/5174 belong to whichever checkout started them (2026-10-21) → Playwright reuses them, so a worktree's run would test the main checkout's code. → In a worktree, run Playwright with a temporary config on other ports (e.g.
--port 5274) and remove it afterwards. - Lab scripts broke on macOS →
/usr/bin/env bashis bash 3.2, which has different quoting and breaks on empty arrays. → Lab scripts use/opt/homebrew/bin/bash. - The ops self-test failed on macOS while the scripts passed by hand (2026-10-21) → it ran them through their
#!/usr/bin/env bashshebang, which is bash 3.2. → Operations scripts (scripts/ops/) must also run on Linux servers, so they keep/usr/bin/env bash, refuse bash < 4 with a message, and tests call them as"$BASH" script. - A restore drill that passed on the host failed as soon as the client tools ran in a container (2026-10-23) →
psql -f /path/on/the/hostnames a file the container cannot see, and the error is only "No such file or directory". → Stream the SQL on standard input (psql -f - <file).APELORA_PG_CLIENT_IMAGEnever receives a host path. - A base backup waited 60 seconds for a write-ahead segment that recovery later found (2026-10-23) →
pg_walfile_name(pg_current_wal_lsn())names the segment still being written.pg_basebackuphad already archived the segments the backup needs and said so. → Read the file name frombackup_label, and check that file is in the archive. Do not wait for the segment that is still open. - The archive proof passed and the CI step still failed (2026-10-23) →
pg_basebackupinside the container leaves the backup directory mode 0700, owned by uid 999, andrm -rfby the runner exits non-zero. A trap that ends on thatrmturns a passing proof into a failed job. → Open the mode from a root container (chmod -R a+rwX) before removing the directory, and end the trap with an explicitexit. postgresql-client-18was installed andpg_dump --versionstill was not 18 (2026-10-23) →/usr/bin/pg_dumpis thepg_wrapper, and with no local cluster it does not select the client just installed. The binaries are/usr/lib/postgresql/18/bin/pg_dump. → Point the service at those files (a link in/usr/local/bin, which systemd searches first). Do not trustpg_dump --versionuntil you have run that path.verify.shprinted VERIFIED and exited 1 (2026-10-21) → its EXIT trap ended with[[ -n $TMP ]] && rm …, which returns 1 when there is nothing to remove, and a trap's status becomes the script's. → End traps and functions withif …; then …; fi, and check the exit status in script tests, not just the output.- nftables rules that looked right were refused (2026-10-21) →
fwdis an nft keyword (not a chain name) andcommentmust end a rule. → Lab scripts that generate rules validate them withnft --checkinplan(scripts/test-cluster/egress.sh), so the dry run fails instead ofblock. - A plain
sleepwas blocked in the agent shell → wait withuntil <condition>; do sleep 1; done. - An edit piped as
python3 /dev/stdin <<EOFchanged nothing and said nothing (2026-10-21) → in the agent shell the heredoc did not reach the interpreter; the file stayed as it was and a later test failed for a reason that looked unrelated. → Put edit scripts in a file in your scratchpad folder, run that, and check the change withgrepbefore moving on. - In an agent worktree,
source ~/.config/apelora/test-cluster/envand multi-step shell lines were refused (2026-10-21) → the worktree guard cannot prove such commands stay inside the worktree. → Put lab runs in a script in your scratchpad subfolder that exports the variables itself and run it with/opt/homebrew/bin/bash <script>; do edits with the editor or a script file, one plain command per call. - The Incus CLI attach failed → it needs a device name.
- A design-review tool refused SVGs with text → review illustrations by rendering them and looking at screenshots instead.
- Another agent overwrote a helper script in the shared scratchpad mid-task (2026-10-21) → parallel sessions share the scratchpad directory. → Keep your helpers in your own subfolder of the scratchpad, named after your worktree.
- The fence probe stopped working on a member after its VM was restarted (2026-10-21) → Lima gives a VM new SSH host keys at every boot, and the probe pins host keys (as it must). → After restarting a lab VM run
scripts/test-cluster/fence-probe.sh applyagain;ha-drill.shdoes it after the drill and its plan refuses a lab where a probe fails. - Commands with a
cd, a loop or a heredoc were refused in an isolated worktree (2026-10-21) → the worktree guard cannot prove such commands stay inside the worktree. → Put multi-step shell work in a script file in your scratchpad subfolder (absolute paths) and run that one file; keep direct commands plain. - A spacing measurement reported a 92 px gap that did not exist (2026-10-21) → the snapshot had been trimmed to fit the tool call and lost a sibling. → Trim whole subtrees (e.g. keep
<main>), never single siblings, and re-check any finding against the full capture. - IPv6 loopback was warned as a remote OTLP receiver (2026-10-23) → comparing
host_str()to"::1"missed the form the URL crate actually returns. → Classify loopback withIpAddr::is_loopbackpluslocalhost(any case). Guard:traces_endpoint_accepts_loopback_http_and_rejects_other_schemes. - Stryker's Vitest plugin reported every mutant as surviving (2026-10-21) → with Vitest 5 the plugin's per-file activation hook never ran, so mutants were never switched on, and a copied sandbox imported the original workspace packages through
node_modulessymlinks anyway. →stryker.config.mjsuses the command runner and mutates in place; check a new mutation setup by breaking one line by hand and seeing it killed before trusting any score. - A killed Stryker run left 240 source files rewritten (2026-10-21) → in-place mode injects
// @ts-nocheckand restores from a backup only when it exits cleanly; the session ended mid-run. → Start from a cleangit status, never editapps/orpackages/while it runs, and after an interrupted rungit checkout -- <the mutated paths>after checkinggit diffshows only injected lines. cargo mutantsreported timeouts on mutants that were fine (2026-10-21) → the first build of a fresh copy takes minutes, so the per-mutant timeout (150 s) counted build time. → Give it--build-timeoutand a test timeout well above one test run, run one scope at a time (they compete for CPU), and re-run only the missed functions with-Fafter adding tests.- A clippy lint appeared on code that passed an hour earlier (2026-10-21) → the toolchain moved (
ok().expect(),sort_by). → Runcargo clippy --workspace --all-targets -- -D warningsagain right before committing, not only after the last edit to that file. - Test code was flagged by
clippy::unwrap_usedand by code scanning (2026-10-21) → the workspace deniesunwrapoutside tests only by convention, and CodeQL reads inlinemod testswith fixed keys as hard-coded secrets. → Test fixtures live intests.rs/*_tests.rs(left out of scanning) with#![allow(clippy::unwrap_used)]and a reason; an inline test module with a fixed secret moves out. - A CLI that prints a one-time token cannot satisfy the cleartext-logging query (2026-10-21) → writing the secret straight to stdout instead of through
println!only moved the finding. → Do not rewrite code to hide a deliberate flow from a scanner; decide to dismiss with a written reason, or change the design (for example write to an owner-only file).
clippy::large_enum_variantflipped between failing and being an unfulfilledexpectas the mock gained fields (2026-10-21;Providerin the adapter) → which variant is larger changes whenever either side gains a field, and an unmet#[expect]is itself an error. → Use#[allow(..., reason = ...)]for a lint whose trigger depends on sizes.- Every
sqlx::testfailed withPoolTimedOut(2026-10-21) → the dev PostgreSQL container had stopped after a restart, and the tests give no hint. → Rundocker compose -f infra/dev/compose.yaml up -dand wait a few seconds before concluding a Rust test is broken. - A timing test (
concurrent_callers_share_one_probe) failed in a full workspace run and passed alone, three times (2026-10-21) → it depends on scheduling and the full run loads the machine. → Re-run a failing test alone before debugging it; if it only fails under load, make it wait for a condition instead of time (not done yet for this one). - The adapter could not reach a lab MAAS that curl reached (2026-10-21) → the lab certificate came from
openssl req -x509, which setsCA:TRUE; OpenSSL and curl accept that as a server certificate, the Rust TLS stack (rustls/webpki) refuses it ("CA used as end entity"), and the error said only "cannot connect". → Generate lab server certificates as leaves:-addext basicConstraints=critical,CA:FALSE -addext extendedKeyUsage=serverAuth; and test a TLS client against the real thing with the client you ship, not only curl. cargo test --exact missing_nameexits 0 (2026-10-22) → no test matches, nothing runs, the result is0 passed; 0 failed; N filtered out. A CI step that lists names and greps only for a green job would hide a rename. → After the run, requiretest result: ok. N passedfor the N names listed (real-incus.yml).- A new workflow never ran, and the pull request's checks looked green (2026-10-22) → it used
${{ runner.temp }}in job-levelenv, where therunnercontext does not exist; GitHub refused the file on push with a zero-second "failure" named after the path, and the pull request's check list simply lacked the job. →ci.ymlruns actionlint on every change; locally,docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.7before pushing a workflow, and look for the new job ingh pr checks, not only for red. - The first estimate for BMC fencing (40–60 s) was wrong (2026-10-21) → it ignored that clustered LVM keeps the failed host's locks until sanlock expires its lease (about 140 s after the last renewal), however the host stopped. The drill measured fenced at 61 s and running at 220 s. → Measure the recovery path end to end before promising a number; a fence changes when it is safe, not when the storage lets go.
- A lab VM's loopback service silently took the workstation's port (2026-10-25) → the Lima rule
guestIP: 0.0.0.0, ignore: truedoes not cover services bound to127.0.0.1in the guest, so a control plane left running on a member owned the workstation's127.0.0.1:8080and a new local one could not start (or the Console proxied to the wrong one). → Ignore127.0.0.1too in every VM file, and before blaming the app for "address in use", checklsof -nP -iTCP:<port> -sTCP:LISTENforlimactl.
- A lab check passed on the previous run's evidence, then failed on its own (2026-10-25) →
demo.shcounted restart entries in the append-only audit log of the shared dev database, so an entry from an earlier run satisfied "restarted once" before this run's restart happened, and the real one made it two. → Scope every check against shared, persistent state to the run:from=<start>on the audit API, a run's own database (ui-e2e.sh), or names unique to the run.
Git and collaboration
- Pre-staged deletions went into the wrong commit; Cargo.lock was left out → check
git statusandgit diff --cached --statbefore each commit. Commit Cargo.lock with the change that alters it. - Rebase blocked by an unstaged file → commit or stash everything before
git fetch && git rebase. - The owner pushes from other sessions → before every push: fetch, rebase,
npm install, check OpenAPI name clashes, run the full checks. - Commits are authored by the owner only, with no AI attribution (AGENTS.md rule 10). This overrides any tool default.
run_stops_on_shutdownfailed only in the fullcargo test --workspacerun (2026-10-21) → it shut the worker down after a fixed 100 ms and expected its heartbeat row; with many database tests at once, registration took longer. → Tests wait for the observable result (the row exists), never a fixed delay (services/deployment-worker/src/lib.rs).- Merging eight parallel branches: each passed alone, together 30 tests failed (2026-10-21) → each branch's stubs and fixtures matched the API as that branch knew it. A new response field crashed pages whose stubs lacked it (
s.localAccounts.enabled,s.incus.serverVersion), and a permission added to a role made another branch's "holds everything a viewer holds" fixtures wrong. A conflict resolved "keep both" also dropped a#[cfg(test)]that sat just outside the conflict block, and afabric-console(-[a-z]+)?test pattern silently sentfabric-console-local-accountsto the wrong project. → After each merge,cargo check --all-targetsbefore the next one. Run the full checks once all are in, before pushing. Pages read optional chains on fields a newer control plane adds (one version of skew, ADR 0011). Test patterns allow hyphens:[a-z-]+. - Long documentation lines merged character by character came out garbled (2026-10-21) → two branches each rewrote the same one-line table row (current-state, a skill's views row); keeping "the other side's insertions" at character level spliced words together ("Audight", "projexpool"). → Resolve a conflicting prose line by rewriting it from what the code now contains, then read it once; never merge prose below the word level.
- Dependabot's sha2 0.11 pull request failed CI on its own (2026-10-21) → sha2 0.11 implements the
digest0.11 traits, and hmac 0.12 only acceptsdigest0.10, so the pair builds only when both move. With hmac 0.13,new_from_slicecomes fromKeyInit, notMac. → Crates that share traits move together: give them their own Dependabot group (rustcrypto). - tokio-tungstenite 0.30 built, but put a second WebSocket stack in the tree (2026-10-21) → axum 0.8.9 depends on 0.29. → Before taking a bump, check
cargo tree -i <crate>@<old>. A crate another dependency already pins follows that dependency (Dependabotignorewith the reason). - CodeQL reported about 40 "hard-coded cryptographic value" and "cleartext logging" findings, all but one in tests (2026-10-21) → fixed test passwords, and values printed in assertion messages, look like secrets to those queries. → CodeQL runs from
.github/codeql/codeql-config.yml, which leaves test files out by name. A reported inlinemod testsmoves to<module>/tests.rs. The one finding in shipping code (token createprints the new token once) was dismissed with its reason. - CodeQL
cleartext-transmissionflagged Redfish Basic Auth (2026-10-22) → shipping code rewrotehttps://tohttp://for loopback tests (Mapping.plain), so the scanner treated the BMC password as going over HTTP. → Keep TLS on the shipping path (https_only(true), refuse a non-https URL beforebasic_auth); drive tests through a local HTTPS BMC with a CA:FALSE leaf and a CA file. Do not add an HTTP downgrade in shipping code to make tests easier. - CodeQL
uncontrolled-allocation-sizeflaggedVec::with_capacity(rows.len())on the jobs list (2026-10-22) → the store already pages, but the allocation took the Vec's length, which the query treats as user-controlled.rows.len().min(JOB_LIST_LIMIT)stayed tainted (alert #59). → Sizewith_capacityfrom the named constant alone (JOB_LIST_LIMIT), never fromrows.len(). curl --cacertof Incus's cluster certificate failed on the members (OpenSSL 3) (2026-10-23) → the cluster cert is a self-signed leaf (CA:FALSE), and pinningserver.crtmissed::8444presents the cluster certificate, the same on every member. → Pin/var/lib/incus/cluster.crt's public key (--pinnedpubkey);--insecurewithout that pin is a bypass. Guard:scripts/test-cluster/metrics.sh.apt-get install nginxstarted the default site on :80 before the example config was linked (2026-10-23) →systemctl enable --now nginxthen left :443 closed even thoughnginx -twas clean. → Reload nginx after linkingapelora.conf(scripts/test-cluster/install-units.sh).- README screenshots still showed the old module stripe after the look had been fixed (2026-10-21) → the screenshots were regenerated before the CSS change and nobody re-ran the script. → After any visual change to a shared component, regenerate
docs/screenshotsin the same change and look at the README image. - My local checkout was 57 commits behind
origin/mainand I nearly redid work that was already pushed (2026-10-21) → other sessions push to the same repository. →git fetchand readgit log HEAD..origin/mainat the start of a task and before saying something is not done; rebase before pushing. - A new environment variable failed an unrelated-looking test (2026-10-21;
every_setting_the_services_read_is_listed) → every variable the services read must be listed insettings::SETTINGSfor the Administration page. → Add a new setting to that list,.env.exampleand the runbook in the same change.
Model and catalog
- A design note read as a list of remaining work (2026-10-24) → the page opened with a status line, slice numbers, and decision numbers, and the body was a backlog. → Say what the product does, in the present tense. Put decisions in one "See also". Put what has not been tried in a short limits section. Update the page when the behavior changes; do not leave the old plan in place.
- CodeQL rejected the deploy-time environment key (2026-10-24) → the token was built with
/^_+|_+$/, and a property id is catalog input, so a long run of underscores makes that expression backtrack. → Turn the id into a token with a loop (setupTokeninpackages/assembly-model/src/runtime.ts). Do not trim a repeated character with a regular expression on catalog text. - Every
moncable compiled to "module may reach the collector on 9100" (2026-10-21) → the compiler treated every output as the consumer and used one default port per protocol, but metrics are read (the collector connects) and almost no software serves them on 9100. → Metrics outputs reverse the direction and use the class's own port (ModuleClass.metrics,metricsEndpoint); a class offersmononly when a collector can read metrics text on a port (packages/module-catalog/src/metrics.test.ts). Check a protocol's direction and real port before adding it toDEFAULT_PORTS. - Grafana could not be cabled to Prometheus, and broker "consumer" outputs connected only to another broker (2026-10-25) → the manifests modelled data flow (Prometheus
queryout → Grafanauiin; Kafkaout→ consumers), but a cable means "the output side may reach the input side", so the compiled rules let Prometheus reach Grafana and never the reverse. Atcppeer output (CockroachDB) could not connect to a sibling at all, becausetcpoutputs feed onlytcpinputs. → Model who opens the connection: clients on the output side (Grafanaquery→ Prometheusapi; producers and consumers → brokerin), and makepeer/repl/primary/bridgeoutputs match a service input of the same class (packages/module-catalog/src/model.test.ts, "connections between catalog classes"). - Simple Icons marks are not all CC0 (2026-10-25) → its metadata gives some marks Apache-2.0, CC-BY(-SA) or custom trademark terms, and our attribution file said CC0 for all. → Read the
licensefield for each mark, record it inassets/logos/ATTRIBUTION.md, and use only CC0 or permissive marks for new classes (a test checks every bundled mark is listed with a licence). - A deployable NGINX showed twelve settings and applied one (2026-10-25) → only the image choice reached Incus; the rest were saved in the design, and the deploy-time fields arrived as
SETUP_*/SECRET_*variables that the image never reads. Nothing on screen said so. → A runtime maps settings explicitly (command,environment),appliedSettingssays which, and the Studio marks the others. Before making a class deployable, list what its image really takes and prove it inside the instance (CHECKSinscripts/runtime-requests.ts). - A new storage key nearly overwrote the custom modules (2026-10-25) → the catalog menu's open state was first given
apelora.studio.catalog.v1, which already holds the custom module classes. → Grep for a localStorage key before choosing one; the menu state lives inapelora.studio.catalog-menu.v1and an e2e test checks the custom-class key is untouched.
Renames and compatibility
- "an module" left behind after renaming "appliance" → the article sat before a line break. → Do renames with a regex that crosses line breaks, then grep
.env.example,CITATION.cff, scripts and docs. Keep serde aliases and a migration for stored data. - A private struct still had the old field name → serde aliases go on every deserialised type, including private worker types.
Secrets and evidence
- Tokens must never reach the transcript, logs or screenshots → serve test tokens to the browser from a git-ignored file, revoke them when done, and delete any screenshot that shows one.
- The audit log is append-only → when cleaning up test tokens, delete the token rows in their own statement. Never touch audit rows.
- Label every result: MOCK VERIFIED, CONTRACT VERIFIED, REAL INCUS TESTED, or REAL-BROWSER TESTED against a named local service. Never round up.
- A count written in prose goes stale (2026-10-21) → "fifteen built-in alert rules" stayed in three pages while the code had sixteen, and a first attempt to count them by matching the rule constructors missed one written as a struct literal. → Take a count from something the code already asserts (the test's
RULES.len()literal), or generate the page (as foropenapi.jsonandSETTINGS.md); don't copy numbers into setup text. - Example systemd units with a
Documentation=file://…that is not on the runner failsystemd-analyze verify(2026-10-22) → the check copies the units and drops that line (scripts/ci/verify-install-examples.sh). Parsing is not the same as running them under a dedicated account on a production host. nginx -tas the runner could not load the example TLS keys (2026-10-22) → the dummy keys are installed 0600 root:root, matching a host, and the GitHub runner is not root. On a host the NGINX master reads them as root. →sudo nginx -t(scripts/ci/verify-install-examples.sh).
This page is a copy of docs/development/LESSONS-LEARNED.md in the repository, which is the source of truth. Suggest a change.