IMPORTANT: This file is loaded automatically on every new conversation. If you encounter a repeatable issue or bug that might arise in the future, you must log the issue and its solution into this file. However, always ask the user at the end of the conversation if they want you to log it or not before doing so.
CRITICAL: Always use bun — bun run, bun install, bun test, and bun to execute JS/TS files. Never npm, yarn, or pnpm.
CRITICAL: The frontend is apps/portal.
- Portal specifics worth knowing before searching: shared UI lives in
packages/components(HeroUI, e.g.ConditionsBuilder,JsTextField), block settings panels inapps/portal/src/components/canvas/panel/blocks/, and database introspection is already available viauseDbMetadata(tableNames/getColumnsForTable/allColumns). Check these before building a new component — the equivalent usually already exists. - Test critical components when you add or change them (keyboard flows, canvas editing, anything a user can't work around) with React Testing Library on happy-dom, turned on in that test file only. Copy
canvas/BlockPickerSidebar.test.tsx.
- Two remotes, and they are not interchangeable. Issues, discussions and PRs always target
Fluxify-rest/Fluxify(--repo Fluxify-rest/Fluxify). Branches are only ever pushed to the user's fork,origin(git push origin <branch>). A PR from the fork needs--head <user>:<branch>. - Use the
ghCLI for everything GitHub — PRs, issues, CI status, merges. - Ask at the start of a conversation whether to branch or work on
main. - Testing before commit: test only the folders that changed. The pre-commit hook formats and lints the staged files (Biome, auto-fixed and re-staged), then runs typecheck, FTA analysis and unit tests — never
--no-verify; if it fails, fix the cause (see the FTA section below). Integration and e2e tests (*.test.ts,testing/e2e) run only in CI, on every PR and push. - Format before you commit: run
bun run lint --write(Biome: formatting, import order, lint fixes). The hook does the same on staged files, but it is skipped in the GitHub web editor and by--no-verify, so CI runsbun run lintas the real gate. Biome is the only formatter — never hand-format or add Prettier/ESLint. Rules that bite:noSecrets(a fake credential in a non-test file fails the build — put fixtures in*.spec.ts/tests/),noSwitchDeclarations,noAssignInExpressions. - Secrets: never commit a real key, not even as a fallback (
process.env.X || "sk-…"). CI'ssecret-scanjob (Gitleaks,.gitleaks.toml) checks every commit a PR adds and fails the build; Biome'snoSecretscatches the same in source. Placeholders belong inenv.example, docs or tests, which the scan allows. - Branch names follow convention (
feat/…,fix/…,chore/…). PR descriptions say why and what. - Writing a multi-line commit message: use
git commit -F -with a bash heredoc. The PowerShell@'…'@form silently becomes a literal@subject line when run through the Bash tool.
Issue: New portal pages render unthemed — wrong background, invisible text, borders that vanish — because the markup carries literal hex values (bg-[#12151D], border-[#1E232F], text-[#D0F237], bg-[#ccff00]) or Tailwind's default palette (text-zinc-400, text-white, text-black, bg-white/[0.04]). These are frozen dark-theme values: they ignore --accent, do not flip under .light / [data-theme="light"], and drift from the design system the moment a token changes.
Cause: Copying an existing page as a starting point. Several older files still contain hardcoded hex, so the wrong pattern looks like the house style. A hex value in a neighbouring file is NOT precedent — it is unconverted debt.
Fix & Best Practices:
- Always use the semantic utility classes. The theme is defined in
packages/components/src/styles.cssas CSS variables; the Tailwind utilities built on them are the only supported way to colour portal UI:- Surfaces:
bg-background,bg-background-secondary,bg-surface,bg-surface-secondary,bg-overlay - Text:
text-foreground,text-muted,text-muted-foreground,text-accent,text-accent-foreground - Lines & rings:
border-border,border-accent,ring-accent,ring-focus - Status:
text-danger,text-success,bg-warning(and theirborder-*/bg-*forms)
- Surfaces:
- Never write
text-white/text-black/text-zinc-*/bg-white/[0.04]. Map them: primary text →text-foreground, secondary text →text-muted, hover wash →hover:bg-surface-secondary, selected wash →bg-accent/10, hairline ring →ring-border. - The lime accent is
--accent, never a literal.#ccff00/#D0F237→bg-accentwithtext-accent-foreground(the accent needs dark text for contrast;text-accent-foregroundalready encodes that). - Prefer the component over restyling a native element. A lime CTA is
<Button variant="primary">, not a<button>with an accent background pasted on — the variant already tracks the theme. - Opacity modifiers on a token are fine (
bg-accent/10,text-muted/50); they stay theme-aware. Literal rgba/hex overlays are not. - Check before committing any portal UI:
grep -nE "(bg|text|border|ring)-\[#|zinc-[0-9]|text-(white|black)" <changed files>— this must return nothing.
Issue: an internal link written as <a href="/<projectId>/..."> loads a 404 (or the login page) once the portal is served under its base path.
Cause: the router is created with basepath: BASE_PATH (/_/admin/ui, apps/portal/src/main.tsx). The browser resolves a raw href against the origin, so the base path is dropped; TanStack Router's <Link> prepends it for you.
Rules:
- Route anywhere inside the portal with
<Link to="/$projectId/..." params={{...}} search={{...}}>from@tanstack/react-router. Never hand-build the path. - Do not put the base path into
to/params/search— the router adds it behind the scenes.withBasePath(...)is only for a real URL used outside the router:window.open,target="_blank", a download, or an external link. - A query string goes in
search(search={{ open: runId }}), never concatenated onto the path. - A
Linkneeds a router in context, so a test that renders one must stub@tanstack/react-router(seecomponents/versions/DeploymentGates.test.tsx).
Issue: "Module not found: Can't resolve 'child_process'" or similar Node.js built-in errors in Next.js client code.
Cause: Importing utilities (like canAccess) or types directly from the root of a server module (e.g., @fluxify/server) forces the Next.js bundler to evaluate the server's main barrel file (index.ts). This barrel file exports modules that rely on Node.js built-ins (like database schemas, ORMs, and pg), breaking the frontend build.
Fix & Best Practices:
- Utility Functions: Always use deep imports for utility functions to bypass the root
index.ts. For example, use:import { canAccess } from "@fluxify/server/src/lib/acl";instead of:import { canAccess } from "@fluxify/server"; - Types: When importing types from the server module, always explicitly use
import typeso the bundler drops the import entirely during compilation:import type { AccessControlRole } from "@fluxify/server";
Golden rule: A chat request's last message must be user or tool (or an assistant message explicitly marked as a prefix). Mistral (and some others) hard-reject anything else with 400 invalid_request_message_order. Never send a message array whose final element is an assistant or system message, and never re-send a model's final reply back to it. Retry corrections must be a user turn.
Issue:
- Default HeroUI v3
<Checkbox>fails to render properly or breaks contrast in custom themes (light/dark mode) due to strict subcomponent structure expectations and unstyled SVG icon defaults. Error: A slot prop is required. Valid slot names are "selection"on<Checkbox>inside a<Table>component.Warning: A PressResponder was rendered without a pressable childwhen placing a<button>inside<Table.Column>.
Cause:
- HeroUI v3 Checkbox requires specific compound component wrapping (
Checkbox.Root,Checkbox.Control,Checkbox.Indicator) and theme tokens; unstyled or raw usage breaks contrast/layout in dark/light themes. - React Aria / HeroUI Table expects selection checkboxes rendered inside
<Table.Header>or<Table.Cell>to explicitly declareslot="selection". <Table.Column>is already rendered as an interactive ColumnHeader by React Aria, so embedding a native<button>creates conflicting PressResponders.
Fix & Best Practices:
- Always use
@fluxify/componentsCheckbox: Useimport { Checkbox } from "@fluxify/components"located inpackages/components/src/Checkbox. It is self-contained, fully typed withoutany, uses theme CSS variables (var(--accent),var(--accent-foreground),var(--border),var(--surface),var(--focus)) for light/dark theme compatibility, handleschecked,indeterminate,size,variant,label,description,errorMessage, and supportsforwardRef. Additionally, ALWAYS use thisCheckboxcomponent for UI toggle switches instead of importing or using a standaloneSwitchcomponent. - Table Selection: Pass
slot="selection"on any<Checkbox>rendered inside<Table.Header>or<Table.Cell>(e.g.<Checkbox slot="selection" ... />). - Replace nested
<button>elements inside<Table.Column>with clickable<div>or<span>elements (e.g.<div role="button" tabIndex={0} onClick={...}>).
Rule: ALWAYS use the unified @fluxify/components delete button components for delete and remove actions across the portal UI instead of ad-hoc <Button variant="ghost"> or solid <Button variant="danger">:
- Icon-only delete actions (table rows, card actions, form removals): Use
<DeleteIconButton aria-label="Delete ..." onPress={...} />(or pass customicon,size,isDisabled,iconSize). It usesvariant="danger-soft"(translucent danger styling) by default. - Text delete actions (bulk delete buttons, danger zone action buttons): Use
<DeleteButton onPress={...}>Delete ...</DeleteButton>(usesvariant="danger-soft"with leadingTbTrashicon by default). - Confirmation dialogs: Use
<ConfirmDialog danger ...>which defaults destructive action buttons tovariant="danger-soft"(translucent danger) unless explicitly givenvariant="danger". - Dropdown menu delete items: Style with
variant="danger" className="text-danger hover:bg-danger/10 focus:bg-danger/10 focus:text-danger"and<TbTrash size={16} className="text-danger" />.
Rule: ALWAYS use the unified @fluxify/components close button component for modals, dialogs, and clearable surfaces across the portal and components UI instead of default HeroUI <Modal.CloseTrigger> (which renders an unthemed solid background and custom SVG) or ad-hoc custom icon buttons:
- Modal headers: Use
<CloseButton />(or<ModalCloseButton />) in<Modal.Header>(with flex layout, e.g.<Modal.Header className="flex flex-row items-center justify-between">). It usesTbXfromreact-icons/tb(default 18px), applies theme tokens (text-muted hover:text-foreground hover:bg-surface-secondary active:bg-surface-secondary/80 rounded-md transition-colors), and setsslot="close"for automatic modal dismissal with React Aria. - Explicit close triggers: When a manual dismissal callback is needed (e.g. outside dialog context or controlled state resets), pass
onPress={onClose}(e.g.<CloseButton onPress={handleClose} />). - Clearable input controls: Use
<CloseButton aria-label="Clear ..." onPress={handleClear} />for consistent clear actions in search/selector bars.
Issue: When the canvas is set to readOnly, block settings panel inputs could remain interactive if fields didn't check change tracking state, and the top-level Save button remained visible. Furthermore, disabling elementsSelectable prevented users from opening and inspecting block settings panels in read-only mode.
Fix & Best Practices:
- Readonly Settings Panel Inputs: All settings panel controls (
BlockTextField,BlockJsTextField,BlockSelectField,ConditionsBuilder,FieldMapEditor,JavaScriptTextArea,BlockNameInput,BlockDescriptionField) must checkuseCanvasChanges().enabled(editable) and setisDisabled={!editable}orreadOnly={!editable}. - Hide Save Button in Readonly Mode: The header Save button must be conditionally rendered (
{!readOnly && <Button ...>Save</Button>}) so no save trigger is accessible in read-only mode. - Keep Elements Selectable: Set
elementsSelectable={true}on<ReactFlow>so users can still select nodes and open side panels to inspect block configurations in read-only mode, while keepingnodesDraggable={!readOnly},nodesConnectable={!readOnly}, anddeleteKeyCode={null}disabled.
The pre-commit hook runs fta-cli --score-cap 70, which fails the commit for any file scoring above 70 — including files you only touched, and including test files. FTA weights file length heavily, so a large file sits near the cap and a small addition tips it over.
- Check before you commit, not after:
bun x fta-cli --json <dir>and compare against the same command on a stashed baseline (git stash push -- <file>). That tells you whether the debt is yours or pre-existing. - The fix is splitting or deduplicating, not
--no-verify. Two near-identical loops parameterized into one helper, or a cohesive group of functions moved to its own module, both drop the score properly. A helper added inside the same file barely moves it — the lines are still there. - Some files already on
mainare over the cap, so a commit touching them fails on debt you did not create. Fix it in the same commit and say so in the message.
Issue: main broke after two PRs that touched the same function were merged — find_resource threw ReferenceError: searchBy is not defined on every call, for every agent holding the tool. CI reported only a failing lint job.
Cause: The conflict was resolved in GitHub's web editor. That path runs no local hooks, so the pre-commit chain (Biome → typecheck → FTA analyze → selective tests) never executed. The resolution kept both sides' bodies — one PR's wrapper, the other's new logic — but dropped an identifier from the destructuring pattern the second PR had added. Nothing on the server catches a free identifier.
Fix & Best Practices:
- Never resolve a conflict in the GitHub web editor when both sides touched the same function. Pull the branch, resolve locally, let the pre-commit hook run, then push.
- After any merge you resolved by hand, run
bun run --cwd apps/<app> typecheck(tsgo --noEmit) onmainbefore assuming it is green. A failing typecheck job may be hiding a runtime break, not a style nit. - When two PRs edit one function, expect the conflict to land on the signature: verify every parameter each side added still exists in the merged destructuring/argument list.
Issue: Reviewing whether user/LLM-supplied search terms in api/v1/find-resource/search.ts could be injected.
Cause/behaviour (verified in node_modules, not assumed): In the sql tagged template, literal pieces become StringChunks and every interpolated value falls through to escapeParam(idx, chunk) → a $1-style bound parameter. A Column interpolates as an escaped identifier. sql.raw() is the only path that concatenates text into the query.
Rules:
-
sql\${column} = ${userValue}`` is safe — never hand-quote or hand-escape the value, that only creates a double-escaping bug. - Treat any
sql.raw()on a request-derived string as an injection finding. - Two things parameterization does not cover: values that are syntax for another parser (a
to_tsquerystring is bound safely but can still be a malformed tsquery → a runtime error), andilike(col, \%${k}%`), where user%/_` act as LIKE wildcards. Bound ≠ harmless — bound means "cannot escape the value slot". - Postgres also errors outright on a type mismatch against a typed id column (
uuid = 'auth',serial = 'auth'). Where the caller swallows errors into[], one ordinary keyword blanks the entire search — an availability bug, so guard id comparisons with a shape check before they reach the query.
Issue: Postgres JSON paths (profile ->> 'city') read NULL on every row, even though the column looked filled in.
Cause: With Bun's sql tag, ${JSON.stringify(obj)}::jsonb binds the value as a JSON string scalar ("{\"city\":…}"), not an object. ->> on a string returns nothing.
Fix: Cast through text: ${JSON.stringify(obj)}::text::jsonb. Postgres then parses the text into a real object. Found seeding testing/e2e/src/seed.ts.
Issue: TypeError: undefined is not a constructor (evaluating 'new _Worker(url)') from any server-side code that constructs new ELK(). Tests pass under Node and fail under Bun.
Cause: elkjs only lays out inside a Web Worker. Its in-process fallback (elkjs/lib/elk-worker.min.js) ends in module.exports = {default: j, Worker: j}, and Bun's ESM/CJS interop resolves that to an empty namespace — so the constructor is undefined. createRequire, dynamic import(), and passing an explicit workerFactory are all dead ends; the module genuinely has nothing to hand back.
Fix & Best Practices:
- Do not add elkjs (or any worker-dependent layout lib) back. The layered left-to-right layout in
packages/blocks/layout.tsis dependency-free, runs identically under Bun and in the browser, and is the single implementation shared by the editor's Format button and the AI agent's canvas edits. - Import it from the subpath
@fluxify/blocks/layout, never the root barrel — the barrel dragsjsonwebtokenand the adapters into the browser bundle (see the package-bleed section above). layoutGraph(nodes, edges, { changedIds })returns positions only for blocks that actually move, anchored on the leftmost unchanged block, so an AI edit nudges the graph instead of teleporting it to the origin.- A green local lint does not prove a dependency was fully removed —
node_modulesstill holds it. After dropping a dependency, grep the source for the import (grep -rn "elkjs" apps packages), because CI installs clean and will fail on what your local tree still resolves.
Issue: code meant to spot "this server can't run transactions" (a standalone mongod) never matched, so the fallback and the clear error message never ran.
Cause: the server refuses with IllegalOperation (code 20), "Transaction numbers are only allowed on a replica set member or mongos". But with retryable writes on (the driver's default), the driver (execute_operation.js) rethrows it as a new MongoServerError, "This MongoDB deployment does not support retryable writes…", with no code, and puts the server's error on originalError. A check on code === 20 plus the server's text only ever sees the rewritten error.
Fix: isTransactionUnsupported (packages/adapters/db/transactionErrors.ts) unwraps originalError before matching. Reuse it; don't match the message text anywhere else.
Test it against a real standalone server (packages/blocks/mongoStandalone.test.ts). Every other Mongo test runs a replica set, and a mock built from the server's error text passes while the real driver fails.
Rule: whenever you add or change code that talks to a real external service — a database, a KV store, a queue, an object store — the change is not finished until a *.test.ts exercises it against that service in a container. A mock-based *.spec.ts is necessary but never sufficient.
Why this is not optional. A mock proves what your code sends; only the real service proves what happens when it arrives. Every one of these is invisible to a mock and has shipped as a bug somewhere:
- A TTL that never actually expires the key (a wrong unit, or
setwheresetexwas meant). - A value that does not survive the round trip — the store returns text, so numbers, booleans, dates and
nullall come back as strings. - A missing key that reads as
nullon one provider and""or the string"null"on another. - A client whose methods are callback-based, not promise-based, so
awaitsilently yields the client's return value instead of the result (the Memcached client does exactly this; the Redis one does not). - A connection that is opened per request instead of reused, which no assertion about a mock can detect.
- A provider that rejects a field the mock happily accepted.
What to write.
- Mirror the existing container tests:
beforeAllremoves a stale container by name,pullImage,startContainerWithRandomPort, then poll for readiness — a container that accepts TCP is not a service that accepts queries (MySQL needs ~90 × 500 ms).afterAllmust remove the container even when the test failed. - Use the shared helpers in
packages/adapters/containerTestHelpers.ts(re-exported asdb/testHelpers.ts), never a hand-rolled Docker call. - Give every test its own randomly-suffixed table and key (
faker.string.alphanumeric(8)) so tests sharing a container cannot collide. - Test the feature the way a user wires it, not just the adapter method: compile a real graph and run it, so the emitted code, the factories and the service are all in the assertion. Adapter-only coverage misses everything the compiler does.
- Cover the edge cases the real service owns: expiry, absence, invalidation, malformed stored data, key isolation, and client reuse across requests.
Where it goes. Next to the code it tests, in a package that declares the deps. A container test needs dockerode and @faker-js/faker in that package's devDependencies — they are hoisted to the root node_modules so an undeclared import resolves locally and then fails in CI, which installs clean. Graph-level examples: packages/blocks/kvMysqlCache.test.ts (a read-through cache over real Redis + MySQL).
Remember *.test.ts only runs in CI, so it will not slow the pre-commit hook — but it also will not catch your mistake before you push. Run the file directly while developing: bun test packages/blocks/kvMysqlCache.test.ts.
Rule: each test job in .github/workflows/tests.yml runs only when its package, or a package it depends on, changes. The filters in detect-changes must equal the closure of @fluxify/* dependencies in the package.json files, plus .github/workflows/tests.yml itself (editing it runs every test job; editing ci.yml, checks.yml, nightly.yml or release.yml runs none). bun run check:ci-gates (the lint job) fails when they drift, or when a test file imports a @fluxify/* package its package.json does not declare.
When you must update it:
- New workspace (app, package, test project) or a new test job: add its filter, its
detect-changesoutput and its jobif:intests.yml, and add it toGATESinscripts/check-ci-gates.ts. Anything the script does not track (a new kind of gate, a new workspace glob, a new test location) means updating the script itself in the same PR. - Added or removed
@fluxify/*dependency: update the filters of every job that depends on that package. - Renamed or moved workspace dir: update the filters and
GATES. Never edit a filter to silence the check without confirming the dependency really changed. Workflow layout (.github/workflows):ci.yml(PRs, pushes to main) callschecks.yml(lint, helm, secret scan) andtests.yml(path-filtered tests;full: truedisables the filter).nightly.yml(Kit image) andrelease.yml(version tags) call both withfull: true, then build. Nothing but those two builds or publishes. Rules: every workflow's top-level jobs carryif: github.repository_owner == 'Fluxify-rest', so forks never run CI. A deployment (nightly, release) must see every jobsuccess: never usealways()or!contains(needs.*.result, 'failure')on it, because those let a skipped job pass.tests.yml'srequire-alljob fails afullrun if any test job was skipped, so keep every new test job in itsneeds.
New Routes Serve in Dev at Once, but Production Needs active: true, a Promoted Version AND Live Dependencies
Issue: Route never served on a production worker / e2e public-path 404.
Cause: A route (and a workflow) has two live switches (#738). devActive is development and defaults to on; active is production and defaults to off. Production also needs a promoted version (#739): the prod artifact is built from the snapshot of prod_version_id, never from the working copy, and a row with no prod_version_id has no prod artifact whatever active says. The compiler publishes to each environment's bucket only where it is served and removes it where it is not. A new route therefore answers at /_/dev/<path> right away, and 404s on the public path of a production worker.
Fix & Best Practices: For a test or seed that calls the public path: pass active: true when creating the route, save the canvas, then commit and promote: promote(stack, id) in testing/e2e/src/envs.ts (or POST /v1/routes/:id/versions, then POST /v1/routes/:id/deployments). Promote after the canvas is final; editing it later changes development only. Tests that only call /_/dev need nothing. call_route and a workflow's manual Run check devActive, not active. A seeded row inserted straight into the database has no prod_version_id: give it a version and set it, or go through the API. Dependencies (#740). Custom blocks and middlewares are versioned the same way, and production runs each one's promoted version, never its working copy. A route or workflow cannot be promoted while a block or middleware it uses (directly, or through another) has no promoted version: the custom_blocks gate fails and lists them by kind, id and name. So a test that calls the public path of a route that uses a block promotes the block first (promote(stack, blockId, "custom-blocks") in testing/e2e/src/envs.ts, or POST /v1/custom-blocks/:id/versions then POST /v1/custom-blocks/:id/deployments), then the route; a seeded row inserted straight into the database needs a version and prod_version_id for the same reason. Promoting a block runs the tests of every production route that uses it, so give a route's suites a development worker before promoting a block it uses. Bit in #746, #739 and #740 e2e.
Issue: every React Testing Library spec in apps/portal dies on the first render() with TypeError: React.act is not a function (In 'React.act(callback)', 'React.act' is undefined), thrown from @testing-library/react's act-compat. Nothing about the test or the component is wrong. It arrives in bulk — 375 fail across files nobody touched — because it takes out every RTL spec at once.
Cause: React only exports act from its development build. Bun's test runner sets NODE_ENV=test only when it is unset, so a shell that already exports NODE_ENV=production (agent sandboxes do; a production build pipeline does) overrides it, React resolves to react/cjs/react.production.js, and RTL's act shim finds nothing to call. There is no NODE_ENV in CI: the variable is simply absent, Bun defaults it, and CI passes.
Fix: set it for the command, on every bun test that renders React:
NODE_ENV=test bun test apps/portal/src/components/.../Your.test.tsxPowerShell: $env:NODE_ENV="test"; bun test …. This includes committing: the pre-commit hook runs bun run test:unit under your environment, so from a shell that exports production the hook fails on portal specs it never touched. Run the commit from a shell where NODE_ENV is test (or unset) rather than reaching for --no-verify.
A failing React.act is not a function means the variable, not your component — do not "fix" it by importing act from react-dom/test-utils, which only moves the failure.
Issue: a spec that stubs @/lib/http (or any module a service imports) to fake a request makes other spec files fail in the same bun run test:unit run — usually as a timeout or a fetch is not defined, in a file that was passing on its own. Running that file by itself passes, which is what makes it look like flakiness.
Cause: mock.module registers against Bun's module registry for the whole process, not the test file, and test:unit loads many files into one. A stub left registered replaces the real module for every later file that imports it, including the axios client and its interceptors.
Fix: stub the one function the test is about, on the service object, and let beforeEach(() => mock.restore()) clean up:
import { beforeEach, spyOn } from "bun:test";
const { orchestrationService } = await import("@/services/orchestration");
beforeEach(() => mock.restore());
const get = () => spyOn(orchestrationService, "getProject").mockResolvedValue(status);spyOn on a service method is scoped to that object and restorable; mock.module on a shared module is neither. Keep mock.module for modules that genuinely cannot be imported in a test process (node-only code in a DOM test), and say why in a comment.
The /docs directory contains user-facing documentation — not a technical or contributing guide. These rules apply whenever writing or updating any file inside /docs.
- Expose internal implementation details (e.g. class names, file paths, library names, database schemas, Redis channels, trie structures, pub/sub signals, or architecture patterns like "adapter pattern").
- Use jargon that only a backend engineer would know without explanation.
- Reference source files or internal module names (e.g.
schemaParser.ts,HttpRouteParser,routesLoader). - Describe how the system is built — only describe what it does and what the user can expect.
- Write in a tone that assumes the reader is a senior developer.
- Write in plain, natural English that is understandable by junior developers, non-technical users, and LLM agents alike.
- Explain behavior (what happens) not mechanism (how it works internally).
- Use tables, callout blocks (
::: tip,::: info), and clear headings to improve scanability. - Keep examples concrete and realistic — show inputs and outputs a user would actually see.
- Ensure every page is self-contained enough that an AI agent reading it cold can understand what the feature does.
Why: the docs build publishes llms.txt, an index AI agents read to find pages (vitepress-plugin-llms in docs/.vitepress/config.ts). Each entry is the page's title and description. A page without them is warned about on build ([docs] <page> has no title ...) and gets a weak default: its first # heading and the first 100 characters of its text.
Rules:
- Start every new page with
title:and a one-sentencedescription:that says what the page is for. Don't leave it to the default. - Treat a
[docs] ... has no ...warning inbun run docs:buildas something to fix, not noise. - Add a new section's sidebar key to
LLMS_SECTIONSinconfig.ts, or its pages land under "Other" inllms.txt. - Blog posts (
blog/**) and the home page are left out ofllms.txton purpose, so they need no description. - Changing the defaults? Run
bun run test:docs. Bun's test discovery skips.vitepress, so CI does not run that spec.
Why: coding agents deploy Fluxify by reading docs/deployments/* through llms.txt (docs/deployments/coding-agent.md). A stale page makes them deploy it wrong. production.md once described worker services that the compose file no longer had.
Rule: when a source file below changes something a user sets or runs into (a new, renamed or required env var, a default, a port, an image, a service, a limit, a startup behavior), update the matching docs in the same PR:
| Source change | Update |
|---|---|
apps/server/src/lib/env.ts, packages/common/env.ts |
The env.example files, and the settings table in coding-agent.md#checklist. Also kit.md or production.md, whichever the setting belongs to. |
env.example, docker/kit/* (compose, Dockerfile, Caddyfile, entrypoint.sh) |
kit.md, and the Kit prompt and checklist in coding-agent.md |
docker/production/*, docker/admin/*, docker/orchestrator/*, docker/worker-compiled/* |
production.md, and the Admin + Workers prompt and checklist in coding-agent.md |
deploy/helm/fluxify/** (values, templates, CRDs) |
kubernetes/helm-values.md, kubernetes/install.md, and the Helm prompt in coding-agent.md |
apps/server/src/modules/orchestrator/**, seedDefaultClaim in apps/server/src/db/seed.ts |
production.md (workers and claims), kubernetes/index.md |
nodeEntitlement and the edition rules in apps/server/src/lib/edition.ts |
editions.md#workers, and the workers checklist in coding-agent.md |
Health endpoints, the Route not found response, NATS or JetStream requirements |
The "How to verify" and "Troubleshooting" sections of coding-agent.md |
Before you open the PR, grep docs/deployments for the old name or value.
Issue: Each block's Docs tab inlines its docs/blocks/<page>.md (apps/portal/.../panel/docs/blockDocs.ts + MarkdownViewer.tsx). VitePress-only syntax that looks fine on the docs site breaks there, and a bad description: breaks the whole docs build.
Rules:
- Template. Every block page: intro sentence,
## When to use it,## Inputs(table with Required and Default),## Outputs(handles table),## Example,## How it behaves,## Related blocks. Read the block source for fields, defaults and handles; never guess. - Keep page file names.
BLOCK_DOC_PAGESmaps each block type to a page, andblockDocs.spec.tschecks they exist. New sub-pages are fine; renames are not. A new block type needs a page before it typechecks. - No in-page
[text](#anchor)links. The panel opens every link in a new tab, so they go nowhere. Link as/blocks/<page>#heading; relative./x.mdlinks are rewritten todocs.fluxify.restautomatically. - No
{#custom-id}on headings. The panel prints it as text. VitePress generates the same anchor from the heading text. - Only
::: info,::: tip,::: warning,::: danger, tables, lists, code fences. Nocode-group,details, HTML or images. - Frontmatter values must not contain
:(colon + space) unless quoted, or YAML fails and the docs build stops. - Keep pages short. The panel is narrow. Move long reference material to its own page (
db-conditions,db-joins,db-paging-sorting,js-runner-examples). - Check:
bun run docs:build(temporarily setignoreDeadLinks: falseto see dead links, then restore it) andbun testinapps/portalforpanel/docs.
Use codebase-memory-mcp (search_graph, query_graph, trace_path, get_code_snippet) to locate code, trace callers, or understand architecture — it answers in far fewer tokens than scanning files. Fall back to grep/glob/file reads for exact text, non-code files, or anything the graph does not cover.
CRITICAL: Caveman mode is ACTIVE by default for this project.
Always adhere strictly to the caveman skill rules:
- Be terse and direct.
- No filler phrases, no preamble, no postamble.
- Execute first, talk second.
- Explain only when result is surprising or asked for.