-
Notifications
You must be signed in to change notification settings - Fork 0
Conventions
Provenance. Generated by
/n8-mapfrom the commit named below, and not regenerated since. Individual statements may have been overtaken by later work — most of the issues this map cites have since been fixed and closed. Read it as a snapshot of the codebase's shape, not as a current defect list; the GitHub issue register is authoritative for what is actually open. (The generating commit hash predates a history rewrite and no longer resolves.)
One-line summary: the canonical forms for C# (minimal-API endpoint groups, I*Store + EfCore* stores, snapshot caches, BackgroundService loops, audit events, analyzer policy), the React/Mantine SPA (hooks + api clients, DataTable, page-context providers, theming vars), the Node sidecars, and git/n8SDLC workflow — every rule shown with a real excerpt and its path:line.
Generated from commit 01f0f174 on 2026-08-31 by /n8-map.
Read this before writing code. Where the codebase is inconsistent, the dominant pattern is stated as the rule and the exceptions are named so an executor knows what not to copy.
- Four analyzer packs run on every build (
Directory.Build.props:21-60): NetAnalyzers (AnalysisLevel=latest,AnalysisMode=Recommended,EnforceCodeStyleInBuild=true),Microsoft.VisualStudio.Threading.Analyzers,AsyncFixer,SonarAnalyzer.CSharp. - Warnings stay warnings:
TreatWarningsAsErrorsis not set (Directory.Build.props:14-16). A clean historical baseline means any new warning is a visible diff — fix it or suppress it with rationale; never leave a new one. - Per-rule severity tuning lives only in the repo-root
.editorconfig(Directory.Build.props:17-19). Each downgrade has a one-line rationale. Do not add<NoWarn>to csproj files. - Formatting: 4-space indent, LF, UTF-8, trailing whitespace trimmed, final newline (
.editorconfig:13-19). Tests getCA1707off so snake_case test names are legal (.editorconfig:326-327).
Suppress inline only with a rationale comment, and restore immediately after. Canonical form (src/AutoNate.Web/Endpoints/StatusAppearanceEndpoints.cs:62-69):
// EF Core translates .ToLower() to SQL LOWER() for Postgres but
// can't translate .ToLower(CultureInfo) — so the locale-flavor
// CA rules don't fit this comparison. Status strings are admin-
// entered ASCII labels; locale-sensitive lowering doesn't matter.
#pragma warning disable CA1304, CA1311
var exists = await db.StatusAppearanceEntries
.AnyAsync(x => x.Status.ToLower() == status.ToLower(), ct);
#pragma warning restore CA1304, CA1311Attribute form when a whole type is the exception (tests/AutoNate.E2E.Tests/AutoNateE2ECollection.cs:12):
[SuppressMessage("Naming", "CA1711", Justification = "xUnit collection-definition types are conventionally suffixed 'Collection'.")]Rules already off globally that you should not re-suppress locally (read the rationale before fighting them): CA2007 ConfigureAwait (.editorconfig:85-87), VSTHRD200 Async suffix (:63-70), S108 empty catch — reserved for catch (OperationCanceledException) {} (:144-149), CA2016 — CancellationToken.None is deliberate in cleanup/audit paths (:218-222), CA1001 — app-lifetime singletons with SemaphoreSlim fields (:268-277), CA1862 — .ToLower() == .ToLower() inside EF queries (:187-193).
| Thing | Rule | Evidence |
|---|---|---|
| Namespace = folder |
AutoNate.Web.Endpoints, AutoNate.Web.Services.<Area>, AutoNate.Web.Authorization.EndpointFilters
|
src/AutoNate.Web/Endpoints/GroupEndpoints.cs:6, src/AutoNate.Web/Services/Records/IRecordCommentStore.cs:3
|
| One endpoint group per file |
<Noun>Endpoints.cs containing public static class <Noun>Endpoints with a single Map<Noun>Endpoints extension |
src/AutoNate.Web/Endpoints/ (69 files), GroupEndpoints.cs:8-10
|
| Store interface + EF impl |
I<Noun>Store in Services/<Area>/I<Noun>Store.cs; implementation EfCore<Noun>Store beside it |
Services/Records/IRecordCommentStore.cs, Services/Records/EfCoreRecordCommentStore.cs
|
| Domain exceptions |
<Noun>NotFoundException, <Noun>ValidationException, <Noun>ForbiddenException declared at the top of the store interface file, sealed, message built in ctor |
IRecordCommentStore.cs:5-19 |
| Scaffolded EF entities |
Persistence/Scaffolded/*.cs; alias them at the top of stores to avoid clashing with domain models |
EfCoreRecordCommentStore.cs:4-5 (using RecordCommentEntity = AutoNate.Web.Persistence.Scaffolded.RecordComment;) |
| Request/response DTOs |
public sealed record <Verb><Noun>Request(...) / <Noun>Dto(...) positional records at the top of the endpoint file (or nested in the endpoints class) |
RecordCommentEndpoints.cs:9-29, GroupEndpoints.cs:239
|
| Store inputs |
Create<Noun>Input / Update<Noun>Input records in the models layer, distinct from request DTOs |
GroupEndpoints.cs:57-59 maps CreateGroupRequest → CreateGroupInput
|
| Constants |
EntityKinds.X, Actions.X (lowercase string values, no separators); event types IamEventTypes.GroupCreated etc. |
.claude/skills/add-permission-gate/SKILL.md steps 1-2; GroupEndpoints.cs:23-25
|
| Primary constructors | Use C# 12 primary constructors for DI-injected classes |
EfCoreRecordCommentStore.cs:9-10, RecordTypeShortCodeCache.cs:23-26
|
| Sealed by default for concrete classes |
public sealed class EfCore…, sealed record
|
EfCoreRecordCommentStore.cs:9, AuditEventPublisher.cs:11
|
Every endpoint file follows this exact skeleton (src/AutoNate.Web/Endpoints/GroupEndpoints.cs:10-30):
public static IEndpointRouteBuilder MapGroupEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/groups").RequireAuthorization();
group.MapGet("/", async (
HttpContext http,
bool? includeArchived,
IGroupStore store,
IAuditEventPublisher auditPublisher,
CancellationToken ct) =>
{
var groups = await store.ListAuthorizedAsync(http.User, includeArchived ?? false, ct);
await auditPublisher.PublishAsync(
IamEventTopic.TopicName,
IamEventTypes.GroupListViewed,
IamResourceKinds.Group,
resource: null,
details: new { resultCount = groups.Count, includeArchived = includeArchived ?? false },
ct);
return Results.Ok(groups);
}).AuthorizedInHandler("store.ListAuthorizedAsync filters via FilterQueryAsync(Group, View) against the actor's grants");Mutation form — the full chain (GroupEndpoints.cs:48-74):
group.MapPost("/", async (
CreateGroupRequest request,
HttpContext http,
IGroupStore store,
IAuditEventPublisher auditPublisher,
CancellationToken ct) =>
{
try
{
var grp = await store.CreateAsync(
new CreateGroupInput(request.Name, request.Description),
http.GetActorId(), ct);
await auditPublisher.PublishAsync(IamEventTopic.TopicName, IamEventTypes.GroupCreated,
IamResourceKinds.Group, resource: new { id = grp.Id, name = grp.Name }, details: null, ct);
return Results.Created($"/api/admin/groups/{grp.Id}", grp);
}
catch (GroupValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).DisableAntiforgery()
.RequireKindPermission(EntityKinds.Group, Actions.Create);Rules, each load-bearing:
-
Register in
Program.cs.app.Map<Noun>Endpoints();alongside the others (src/AutoNate.Web/Program.cs:1421-1440). Nothing else discovers endpoint groups. -
MapGroup(prefix).RequireAuthorization()first, then per-route gating. The group prefix carries route tokens when the resource is nested (RecordCommentEndpoints.cs:35:"/api/records/{recordId:guid}/comments"). -
Every
/api/*route must carry an explicit auth decision orAuthorizationGatePresenceTestsfails the build (tests/AutoNate.Web.Tests/Authorization/AuthorizationGatePresenceTests.cs:10-22). Pick exactly one:-
.RequirePermission(EntityKinds.X, Actions.Y)— per-instance; id read from route valueidby default, pass a third arg for another token name (Authorization/EndpointFilters/RequirePermissionExtensions.cs:12-24;RecordCommentEndpoints.cstail uses"recordId"). The token name must match a{token}in the route or the filter throws at request time. -
.RequireKindPermission(kind, action)— bulk/kind-level, no instance id (RequirePermissionExtensions.cs:37-45). -
.AuthorizedInHandler("reason")— the handler filters viaFilterQueryAsync/actor-scoped queries; the reason string is mandatory and should name the mechanism (Authorization/EndpointFilters/AuthorizationDecisionMetadata.cs:41-50). -
.OpenToAuthenticated("reason")— any signed-in user; use sparingly (AuthorizationDecisionMetadata.cs:59-68). -
.AllowAnonymous()— login/health/public-share only.
-
-
Every mutating route (
MapPost/MapPatch/MapPut/MapDelete) chains.DisableAntiforgery()before the permission call. The SPA postsapplication/json; without it the antiforgery middleware returns 400 (.claude/skills/add-workflow-execution-action/SKILL.md"Common slip-ups"). -
Actor comes from
http.GetActorId(); never from the body or query. It returnsGuid.Emptywhen unauthenticated so handlers can short-circuit toResults.Unauthorized()without catching (src/AutoNate.Web/Endpoints/HttpContextActorExtensions.cs:7-15):public static Guid GetActorId(this HttpContext http) { var raw = http.User.FindFirstValue(ClaimTypes.NameIdentifier); return Guid.TryParse(raw, out var id) ? id : Guid.Empty; }
Pass
http.User(theClaimsPrincipal) to store methods that filter by grants (ListAuthorizedAsync), and theGuidto methods that stampcreated_by/updated_by. -
CancellationToken ctis the last lambda parameter and is forwarded to every store/publisher call. Stores declareCancellationToken cancellationToken = defaultlast (IRecordCommentStore.cs:23-49). The only sanctionedCancellationToken.Nonesites are audit publishes inside cleanup/catch paths (15 sites; rationale.editorconfig:218-222). -
Return shapes:
Results.Ok(dto),Results.Created($"/api/…/{id}", dto),Results.NoContent(),Results.NotFound(),Results.Conflict(new { error = "…" })(GroupEndpoints.cs:203). Validation failures areResults.BadRequest(new { error = ex.Message })— an anonymous{ error }object, notValidationProblem(zero usages).Results.Problem(title:, detail:, statusCode:)is reserved for upstream-failure translation (2 sites;src/AutoNate.Web/Endpoints/DataStoreEndpoints.cs:759-768maps aPostgresExceptionto 502 afterLogWarning). -
Map domain exceptions to status codes in the handler, not in middleware:
catch (XNotFoundException) → NotFound(),catch (XValidationException ex) → BadRequest(new { error })(GroupEndpoints.cs:98-105). Stores throw; endpoints translate. -
Map entity → DTO with a private static
ToDtoat the bottom of the endpoints class (RecordCommentEndpoints.cstail:private static CommentDto ToDto(RecordComment model) => new(...)). -
Paging:
var ps = Math.Clamp(pageSize.GetValueOrDefault(50), 1, 200);andpage.GetValueOrDefault(0), then.Skip(pg * ps).Take(ps); putpage,pageSize,totalCount,resultCountinto the auditdetails(src/AutoNate.Web/Endpoints/CabinetEndpoints.cs:47-59). Upper bound is 200 for lists, smaller for previews (DataConnectorEndpoints.cs:139uses 50). -
Audit events on every read and mutation — see §1.7.
Canonical store (src/AutoNate.Web/Services/Records/EfCoreRecordCommentStore.cs:9-38):
public sealed class EfCoreRecordCommentStore(IDbContextFactory<AutoNateDbContext> dbContextFactory)
: IRecordCommentStore
{
private const int MaxBodyLength = 10_000;
public async Task<IReadOnlyList<RecordComment>> ListForRecordAsync(
Guid recordId, bool includeDeleted, CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var query = dbContext.RecordComments.AsNoTracking()
.Where(c => c.RecordId == recordId);
if (!includeDeleted) { query = query.Where(c => !c.IsDeleted); }
var rows = await query.OrderByDescending(c => c.CreatedAtUtc).ToListAsync(cancellationToken);
return rows.Select(r => r.ToModel()).ToList();
}Rules:
-
Inject
IDbContextFactory<AutoNateDbContext>, never a scopedAutoNateDbContext. Open withawait using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);per method. Read paths use.AsNoTracking(). -
Return domain models (
Models/…), not scaffolded entities.entity.ToModel()extension per entity. -
Validate inside the store and throw the domain exception (
EfCoreRecordCommentStore.cs:46-54) — the endpoint translates to HTTP. Trim strings; enforce max lengths asprivate const int. -
Timestamps are
DateTimeOffset.UtcNowwritten as.UtcDateTimeinto*_at_utccolumns (EfCoreRecordCommentStore.cs:64-73). -
Raw SQL is interpolated, never concatenated. Prefer
FromSqlInterpolated($"…{issueId}…")— EF parameterises the holes (src/AutoNate.Web/Services/SystemIssues/SystemIssueRemediationDispatcher.cs:83-88,Services/Menus/EfCoreMenuStore.cs:507-516):// FOR UPDATE so we serialise with the loop dispatcher and concurrent // /remediate calls. The row count + bound id are dynamic so we use // FromSqlInterpolated. var row = await dbContext.SystemIssues .FromSqlInterpolated($"SELECT * FROM system_issues WHERE id = {issueId} FOR UPDATE") .SingleOrDefaultAsync(cancellationToken);
The one sanctioned raw-string exception is dynamic column/ORDER BY assembly in
EfCoreRecordStore.ListAuthorizedAsync, which still passes every user value through an explicitNpgsqlParameterarray:.SqlQueryRaw<long>(countSql, parameters.Select(p => p!).ToArray())(Services/Records/EfCoreRecordStore.cs:184-187,:209-211). Schema DDL inPersistence/DatabaseSchemaInitializer.csusesExecuteSqlRawAsyncwithstring.Formatplaceholders — double literal braces ({{) there (.claude/skills/add-projection/SKILL.mdstep 1). -
Case-insensitive search uses
EF.Functions.ILike(CabinetEndpoints.cs:42-44); uniqueness checks use.ToLower() == .ToLower()under a#pragma warning disable CA1304, CA1311with rationale (§1.1). -
Postgres unique violations are caught by SqlState:
private const string PgUniqueViolation = "23505";(Services/Datasets/EfCoreDatasetStore.cs:12).
Use this shape for any rarely-changing lookup that is read on a hot path (src/AutoNate.Web/Services/Records/RecordTypeShortCodeCache.cs:23-79):
public sealed class RecordTypeShortCodeCache(
IDbContextFactory<AutoNateDbContext> dbContextFactory,
ILogger<RecordTypeShortCodeCache> logger)
: IRecordTypeShortCodeResolver
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private IReadOnlyDictionary<Guid, string> _byId = new Dictionary<Guid, string>();
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var rows = await dbContext.RecordTypes.AsNoTracking()
.Select(rt => new { rt.Id, rt.ShortCode }).ToListAsync(cancellationToken);
_byId = populated.ToDictionary(r => r.Id, r => r.ShortCode); // swap whole snapshot
_logger.LogInformation("Record-type short-code cache refreshed: {Count} entries.", _byId.Count);
}
finally { _refreshLock.Release(); }
}
}- Readers get lock-free
TryGet…over an immutable dictionary; writers replace the reference under aSemaphoreSlim(1,1). Bursts coalesce on the lock. - Register as a singleton; pair with an
IHostedServiceinitializer that (a) refreshes once inStartAsyncand does not fail startup on error (:97-109), and (b) subscribes toHookPoints.AuditEventPublishedwith a filter on the relevant event types to re-refresh (:114-131). The_hookHandleis kept for disposal. - Do not implement
IDisposablefor theSemaphoreSlim(.editorconfig:268-277explains the parallel-test failure this caused).
All periodic work inherits this loop discipline (src/AutoNate.Web/Services/SystemIssues/Detectors/PeriodicIssueDetector.cs:17-108). Copy it; don't hand-roll while(true):
// Capacity 1 so bursts of RequestImmediateScan() calls coalesce into a
// single wake-up.
private readonly SemaphoreSlim _wakeSignal = new(initialCount: 0, maxCount: 1);
public void RequestImmediateScan()
{
try { _wakeSignal.Release(); }
catch (SemaphoreFullException) { /* a wake is already pending */ }
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.DetectorsEnabled) { logger.LogInformation("Detector {DetectorId} disabled …", DetectorId); return; }
try { await Task.Delay(InitialStagger(), stoppingToken); }
catch (OperationCanceledException) { return; }
while (!stoppingToken.IsCancellationRequested)
{
try { await RunOnceAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
catch (Exception ex)
{
logger.LogError(ex, "Detector {DetectorId} tick failed; retrying after Interval.", DetectorId);
}
try { await _wakeSignal.WaitAsync(Interval, stoppingToken); }
catch (OperationCanceledException) { return; }
}
}Rules:
-
Master switch from options (
SystemIssues:DetectorsEnabled,Projections:WorkerEnabled,FlowableCache:RetentionEnabled) so tests can silence loops (tests/AutoNate.Web.Tests/AutoNateWebApplicationFactory.cs:68-83). -
Expose
RunOnceAsyncpublicly so tests drive one tick without the loop (PeriodicIssueDetector.cs:14-16,:36). -
Exception isolation: catch everything except cancellation;
LogErrorand continue. One bad tick never kills the service. -
Coalescing wake signal:
SemaphoreSlim(0, 1)+Release()swallowingSemaphoreFullException; the loop waits onWaitAsync(Interval, token). Mutation paths callRequestImmediateScan()from request threads withoutTask.Run(EfCoreMenuStoreafter every menu write, per:38-46). -
Startup stagger (
InitialStagger()default 5 s, overridable) to avoid thundering herds. - The
catch (OperationCanceledException) { return; }/when (token.IsCancellationRequested)forms are the only blessed empty catches (.editorconfig:144-149).
Non-record domains publish through IAuditEventPublisher (src/AutoNate.Web/Services/Events/AuditEventPublisher.cs:19-39):
Task PublishAsync(
string topicName, // "iam.events", "site.events", …
string eventType, // "iam.group.created" — <domain>.<noun>.<verb>; reads end .viewed / .list.viewed
string resourceKind, // "group"
object? resource, // tiny: new { id, name }; null for list/search
object? details, // resultCount / page / filterHash; never row contents or PII
CancellationToken cancellationToken = default);- Publish after the store call succeeds and on the 200 path only — not on 404 (
GroupEndpoints.cs:36-45). Reads publish*.viewedwithresource: new { id, name }; lists publish*.list.viewedwithresource: nullanddetails: new { resultCount, … }(GroupEndpoints.cs:21-29). - Use the domain constant classes (
IamEventTopic.TopicName,IamEventTypes.X,IamResourceKinds.X;ContentEventTopic,RecordSchemaEventTopic, …). Never string-literal a topic in a handler. - Every new event type is added to
Services/Events/EventCatalog.csor it silently won't appear in the SPA Events page; every new topic prefix must be appended toDesiredStreams[0].SubjectsinServices/Nats/NatsStreamProvisioner.cs. Full checklist:.claude/skills/add-audit-event/SKILL.md. Record lifecycle events use the typedIRecordEventPublisherinstead (.claude/skills/add-record-event-type/SKILL.md). - Hot polled routes wrap the publish in a per-user
IMemoryCachegate (60 s) — see skill step 8. - Delete handlers snapshot the entity before deleting so the event carries a name, not just a UUID (
GroupEndpoints.cs:159-168).
- Structured
ILogger<T>with named placeholders, never string interpolation:logger.LogError(ex, "Detector {DetectorId} tick failed; retrying after Interval.", DetectorId). - Level usage (counts in
src/AutoNate.Web):LogWarning113,LogInformation68,LogError63,LogDebug11,LogTrace/LogCritical0. Use: Error = a unit of work failed and will be retried/skipped (loop ticks, startup refresh); Warning = degraded but handled (upstream Postgres error mapped to 502, cache refresh after an event failed); Information = lifecycle (cache refreshed, detector disabled); Debug = rare tracing. - Always pass the exception as the first argument when you have one (
RecordTypeShortCodeCache.cs:108,:129). -
CA1848/CA1873(LoggerMessage delegates) are downgraded to suggestion — plainLogXxxcalls are the standard (.editorconfig:81-91).
- Plugin csproj bodies are empty; settings inherit from
plugins/Directory.Build.props:10-24(net10.0, nullable, reference toAutoNate.Plugin.Abstractionswith<Private>false</Private>— required for type identity across the collectibleAssemblyLoadContext). Never referenceAutoNate.Webfrom a plugin. - Layout, manifest,
Configure/Cleanupcontract, hooks, per-plugin schema and migrations:.claude/skills/plugin-creator/SKILL.md; worked examplesplugins/HelloPlugin/,plugins/Auditor/.
Multi-file wiring recipes live in .claude/skills/: add-permission-gate, add-workflow-execution-action, add-audit-event, add-record-event-type, add-projection, add-page-context-provider, plugin-creator, mantine-form, mantine-combobox, mantine-custom-components. Invoke the matching skill before touching those surfaces, and fix any drift in the SKILL.md in the same commit as the feature (auto-memory feedback_skill_drift.md).
- Vite + React 19 + TypeScript
strict: true,moduleResolution: bundler, path alias@/* → ./src/*(src/AutoNate.Spa/tsconfig.app.json). Import app code via@/…, never relative../../. - Scripts (
src/AutoNate.Spa/package.json:6-12):npm run dev,npm run build(=tsc -b && vite build),npm run type-check,npm run lint(=eslint src --max-warnings=110 --report-unused-disable-directives). -
Warning ratchet:
--max-warnings=110is the current count; it may only go down. If your change adds warnings, fix them; if it removes some, lower the number in the same commit (999 → 411 in8674f2fb; 411 → 164 when archived-118 escaped 234 JSX entities and dropped 13 unused disable directives).--report-unused-disable-directivesis part of the script so a directive that stops being needed fails lint instead of lingering, andeslint-comments/require-descriptionis an error: everyeslint-disablemust carry a-- reasonsaying why it is safe, because a bareexhaustive-depsdisable is indistinguishable from a stale-closure bug someone silenced (archived-32). - ESLint flat config (
src/AutoNate.Spa/eslint.config.js):react-hooks/rules-of-hooksis an error (:59),react-hooks/exhaustive-depswarn (:60); jsx-a11y recommended at warn (:89-97);@typescript-eslint/no-explicit-anyoff (:80); unused vars warn with_-prefix escape (:81). Real-bug rules stay errors — a lint error blocks; a warning counts against the ratchet. - No unit-test runner in the SPA (no vitest/jest in
package.json); SPA behaviour is verified by Playwright E2E (seeTesting.md).
| Location | Rule | Evidence |
|---|---|---|
src/api/<noun>.ts |
camelCase file; exports plain async functions over the shared api axios instance; request/response types live beside them |
src/api/users.ts:1-31 |
src/hooks/use<Noun>.ts |
React Query hooks; exports <NOUN>_QUERY_KEY const + use<Noun>() + use<Verb><Noun>() mutations |
src/hooks/useUsers.ts:17-46 |
src/pages/<area>/<PageName>.tsx |
PascalCase component file, default export, one page per file; sibling use<Page>PageContext.ts, <page>Schemas.ts, utils.ts
|
src/pages/admin/Groups.tsx, src/pages/manage-users/userSchemas.ts, src/pages/notes/useNotesPagePageContext.ts
|
src/components/<Name>.tsx or src/components/<area>/
|
Shared UI; data-table/DataTable.tsx, PageHeader.tsx, ConfirmModal.tsx
|
src/components/ listing |
src/types/<noun>.ts |
Shared wire types (LocalUser in types/flowable.ts) |
src/api/users.ts:2 |
src/lib/ |
Non-React helpers (siteAppearance.ts, blocknote/, bpmn/, cron/, yjs/) |
src/lib/ listing |
src/routes/appRoutes.tsx |
All in-shell routes; src/router.tsx only for out-of-shell routes (document editor, public share) |
src/router.tsx:14-52 |
src/agent/pageContext/ |
Chatbot page-awareness framework (do not edit per page) | .claude/skills/add-page-context-provider/SKILL.md |
Heavy pages are lazy: const PipelineEditor = lazy(() => import("@/pages/admin/pipelines/PipelineEditor")); wrapped in <Suspense fallback={null}> (src/routes/appRoutes.tsx:19-26). Every in-shell route is wrapped with ProtectedRoute via the protect() helper (appRoutes.tsx:57).
The axios instance (src/api/client.ts:5-11) is the only HTTP client — baseURL: "/", withCredentials: true, JSON content type. Its interceptor rejects any text/html response on /api (SPA-fallback guard, :13-32) and redirects to login on 401 except for the /api/auth/me probe (:33-47). Do not create a second axios instance or use fetch for /api.
Client function shape (src/api/users.ts:19-22):
export async function listUsers(signal?: AbortSignal): Promise<LocalUser[]> {
const { data } = await api.get<LocalUser[]>("/api/users", { signal });
return data;
}Hook shape (src/hooks/useUsers.ts:17-37):
export const USERS_QUERY_KEY = ["users"] as const;
export function useUsers() {
return useQuery<LocalUser[]>({
queryKey: USERS_QUERY_KEY,
queryFn: ({ signal }) => listUserDirectory(signal)
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (request: CreateUserRequest) => createUser(request),
onSuccess: () => qc.invalidateQueries({ queryKey: USERS_QUERY_KEY })
});
}- Always thread
signalfromqueryFninto the client so navigation cancels in-flight requests. - Parameterised keys are functions returning
as consttuples:USER_SUPERVISOR_KEY = (userId) => ["users", userId, "supervisor"] as constwithenabled: !!userId(useUsers.ts:71-79). - Mutations invalidate the list key on success; nothing else refetches manually.
- Real-time invalidation:
useInvalidateOnChannels(channels, queryKeys)subscribes to bus channels and invalidates the given keys (src/hooks/useInvalidateOnChannels.ts:9-27). Use it instead of polling. - Permission gating in the UI uses the batched
usePermissionChecks(checks)+permissionKey(check)pair; build thechecksarray in auseMemo, read withmap?.get(permissionKey(...)) ?? false(src/hooks/usePermissionChecks.ts:20-40,src/pages/manage-users/ManageUsers.tsx:70-75). Kind-level checks useid: "*". - Error text for
Alerts comes fromdescribeError(err)— the exported one issrc/pages/workflow-executions/utils.ts; three private copies exist insrc/components/workflow/*Modal.tsx(TaskFormModal.tsx:124,GatewayChoiceModal.tsx:83,SimpleCompleteTaskModal.tsx:77). Import the exported one; don't add a fourth. - QueryClient defaults:
refetchOnWindowFocus: false,retry: 1,staleTime: 30_000(src/main.tsx:24-31). Don't override per hook without a reason.
Canonical page (src/pages/admin/Groups.tsx:18-54):
import PageHeader from "@/components/PageHeader";
import { useUsers } from "@/hooks/useUsers";
import { useCreateGroup, useDeleteGroup, useGroups } from "@/hooks/useAdmin";
export default function Groups() {
const { data: groups = [], isLoading } = useGroups();
const create = useCreateGroup();
const [error, setError] = useState<string | null>(null);
…
return (
<>
<PageHeader
title="Groups"
description="Group users together so role assignments and permissions can target many people at once."
/>
{error && (<Alert color="red" variant="light" mb="md">{error}</Alert>)}-
Hooks first, before any early
return. This includesuse<Page>PageContext(...)anduseDocumentTitle(...); a hook belowif (isLoading) return <Loader/>produces "rendered more hooks than during the previous render" (.claude/skills/add-page-context-provider/SKILL.md"Common slip-ups"). - Start the page with
<PageHeader title description actions? />(src/components/PageHeader.tsx:15-28). - Set the tab title with
useDocumentTitle("Data Stores")(src/hooks/useDocumentTitle.ts:12-22; usagesrc/pages/admin/datastores/DataStoresPage.tsx:40). Only four pages call it today — new pages must; retrofit when touching an old one. - Modal state as a discriminated union:
type ModalState = { kind: "none" } | { kind: "edit"; user: LocalUser } | …(src/pages/manage-users/ManageUsers.tsx:36-41).
-
Mantine v9 is the sole UI framework. No Bootstrap, no ColorAdmin, no
--bs-*,bi-*,form-control,panel-*,btn btn-*,row/col-*,mb-3— they render nothing (auto-memoryproject_coloradmin_removed.md; CLAUDE.md "Mantine v9"). Do not re-addreact-hook-form,@tanstack/react-table, TipTap. - Look up component props via
docs/mantine/llms.txtfirst, then themantineMCP server, thenhttps://mantine.dev/llms-full.txt(auto-memoryreference_mantine_docs.md). - Icons are FontAwesome:
<i className="fa fa-plus" />(354 usages; 0bi-*). CSS loaded once insrc/main.tsx:12. -
Tooltips:
<Tooltip label="…" withArrow position="bottom">from@mantine/core, keeparia-labelon the target, and do not also settitle=(auto-memoryfeedback_use_mantine_tooltip.md). Exception/backlog: 182title="…"attributes remain in.tsxvs 41 files using<Tooltip>— convert when you touch them; don't add newtitle=. - Toasts:
notifications.show({...})from@mantine/notifications(<Notifications />mounted insrc/main.tsx:47). - Confirmations:
src/components/ConfirmModal.tsxor@mantine/modals(ModalsProviderinmain.tsx:44).
(src/pages/manage-users/ManageUsers.tsx:3-4, :333-337; schema src/pages/manage-users/userSchemas.ts:3-11)
import { useForm } from "@mantine/form";
import { zod4Resolver as zodResolver } from "mantine-form-zod-resolver";
export const createUserSchema = z.object({
username: z.string().trim().min(1, "Username is required").max(150),
email: z.email("Email must be valid").trim().or(z.literal(""))
});
export type CreateUserForm = z.infer<typeof createUserSchema>;
const form = useForm<CreateUserForm>({
mode: "controlled",
initialValues: { username: "", firstName: "", lastName: "", password: "", email: "" },
validate: zodResolver(createUserSchema)
});
const onSubmit = form.onSubmit(async (values) => { … await create.mutateAsync(values) … });- Schemas + inferred types live in a sibling
<page>Schemas.ts; usezod4Resolver(zod v4 API:z.email(...)). - Bind inputs with
form.getInputProps("field"); keepisSubmittinglocal state aroundmutateAsync. - Deeper patterns (nested/array fields,
createFormContext, uncontrolled mode): invoke themantine-formskill.
Never import mantine-datatable directly in a page — go through src/components/data-table/DataTable.tsx. Column shape mirrors the old ColumnDef subset (DataTable.tsx:27-35):
export type DataTableColumn<T> = {
id?: string;
accessorKey?: keyof T & string;
accessorFn?: (row: T) => unknown;
header?: ReactNode | ((ctx: DataTableHeaderContext<T>) => ReactNode);
cell?: (ctx: DataTableCellContext<T>) => ReactNode;
enableSorting?: boolean;
meta?: { wrap?: boolean };
};Props: mode: "client" | "server" | "auto", loadAll / loadPage(req: DataTablePageRequest), queryKey, filter options (DataTable.tsx:44-70). Define columns and pageSizeOptions at module level or in useMemo — an unstable array reference triggers an infinite re-render inside mantine-datatable (DataTable.tsx:37-42). The empty-state string (emptyMessage) is what E2E tests assert on (tests/AutoNate.E2E.Tests/PermissionGatingTests.cs:79-84).
For any page whose live state the assistant should see, create src/pages/<area>/use<Page>PageContext.ts and call it once at the top of the page (src/pages/notes/useNotesPagePageContext.ts:1-12, :41-45):
const PAGE_KEY = "notes";
export function useNotesPagePageContext(options: Options): void {
const optsRef = useRef(options);
optsRef.current = options;
const getSnapshot = useCallback((): PageSnapshot | null => { /* read optsRef.current */ }, []);
…
useRegisterPageContext(entry);
}Rules: snapshot ≤ 64 KB, synchronous getSnapshot, read state via refs, pageKey must match src/agent/usePageKey.ts, mutations are in-memory only (never persist from onPageAction), opt sensitive inputs out with data-agent-exclude. Full recipe: .claude/skills/add-page-context-provider/SKILL.md. Existing providers: pages/{dashboard,notes,query,workflow}/…, pages/admin/{datastores,datasets}/….
- Notes/pages: BlockNote
@blocknote/{core,react,mantine} ^0.51(useCreateBlockNote,BlockNoteView,editor.document). No@tiptap/*imports; theoverridespin inpackage.jsonfor@tiptap/corestays (auto-memoryproject_editor_stack.md). - Documents:
@eigenpal/docx-editor-reactmounted insrc/components/documents/DocxDocumentEditor.tsx, Yjs fragment name"default",key={role}mandatory, button reset scoped under.ep-rootinDocxDocumentEditor.css(auto-memoriesproject_documents_editor.md,feedback_docx_editor_button_reset.md). - Collaboration goes through the existing Hocuspocus sidecar with a new
kind:id prefix — never a second sync server (auto-memoryproject_collab_foundation.md).
-
Global CSS is limited to
src/index.cssandsrc/widgets.css.widgets.cssis "Mantine-tokens only — novar(--bs-*)" (src/widgets.css:1-5) and usesvar(--mantine-color-text),var(--mantine-color-dimmed)(:16-33). -
Theme colours come from CSS vars, never literals.
applySiteAppearanceToDocumentpublishes--app-header-*,--app-top-menu-*,--app-sidebar-*and--mantine-color-body/text(src/lib/siteAppearance.ts:363-387). Shell chrome reads them with fallbacks (src/shell/headerStyles.ts:6-10):export const HEADER_BG = "var(--app-top-menu-bg, #20252a)"; export const HEADER_FG = "var(--app-top-menu-link-color, rgba(255,255,255,0.78))";
-
The Mantine theme object is static and module-level (
src/providers/MantineRoot.tsx:6-17); brand colour changes flow via--mantine-color-brand-*vars. Do not rebuild the theme on state changes (it infinite-loopedColorInput). -
Component-scoped CSS files sit beside the component (
DocxDocumentEditor.css). Prefer Mantine style props (mb="md",c="dimmed") over inlinestyleobjects; when an inline style is unavoidable it must reference a--app-*/--mantine-*var. Backlog: 92 inline hex colour literals remain in.tsx— don't add to them. -
Scratch output (Playwright snapshots, debug captures) goes under
/temp/(gitignored,.gitignore:62), never the repo root (auto-memoryfeedback_temp_files_location.md).
A grep-only "zero importers" verdict is not evidence (multi-line destructured imports; WorkflowStudio.tsx is classified binary by grep). The only accepted verification is the trial delete: mv x.ts x.ts.bak && rm -f src/AutoNate.Spa/tsconfig.*.tsbuildinfo && npx tsc -b --force (auto-memory feedback_unused_ts_module_verification.md). After installing large ESM packages: rm -rf node_modules/.vite && npm run dev -- --force (auto-memory feedback_vite_force_after_heavy_deps.md).
-
Plain
tscbuilds, ESM ("type": "module"),strict: true,target ES2022,moduleResolution: Bundler, output todist/(services/hocuspocus/tsconfig.json,services/executor/tsconfig.json). Scripts:build/start/devonly (services/*/package.json). No ESLint config in either service — TypeScript strictness is the lint. -
Intra-service imports use the
.jsextension (import { createAuthHook } from "./auth.js";,services/hocuspocus/src/index.ts:2-4). -
Configuration is env-var only, read once at module top; required vars fail fast (
services/hocuspocus/src/index.ts:6-25):function requireEnv(name: string): string { const value = process.env[name]; if (!value) { console.error(`Missing required environment variable: ${name}`); process.exit(1); } return value; } const port = Number.parseInt(process.env.HOCUSPOCUS_PORT ?? "1234", 10); const sharedSecret = requireEnv("YJS_INTERNAL_SHARED_SECRET");
Optional vars carry a default (
NATS_URL ?? "nats://localhost:4222",services/executor/src/index.ts:13). -
Logging is
console.log/errorwith a[service]prefix (services/executor/src/index.ts:20). -
Sidecars own no auth decisions: Hocuspocus calls back to .NET
POST /internal/yjs-authwith the shared secret; .NET remains the source of truth (services/hocuspocus/README.md"What it does"). The executor replies{ success: false, errorMessage }rather than disconnecting (services/executor/src/index.ts:6-11). -
Each sidecar has its own Dependabot group (
.github/dependabot.yml).
- Subject: imperative, ≤ ~72 chars, capitalised, no trailing period. Feature commits state the user-visible outcome (
Add Files-backed datasets: CSV/raw parsers, file/folder scope, schema preview,98d99c82;Fix two test-suite failures: writer-role catalog race and /api 404 guard,bdc72176). Housekeeping uses achore:/chore(n8):prefix (8674f2fb,01f0f174). - Body: bullet list of what changed per file/area and why; the fix commit
bdc72176explains the race and the routing-DFA reason. Multi-phase feature commits start the body with**Shipped Phase N of …**and reference the plan file (b1b8b9fc,cc80bc78). - Trailers on agent-authored commits:
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_… - Issue linkage:
Refs #Nin the body (01f0f174:Refs archived-7-archived-93 (audit findings filed 2026-08-31)). NeverFixes/Closesin commits — closing keywords are silent no-ops off the default branch and belong in the PR body (n8-execSKILL.md:34).
- Default branch is
master— notmain; n8SDLC docs saymain, target the default branch (.n8/config.ymldefault_branch: master; auto-memoryproject_n8sdlc_workflow.md). - Milestone work: one branch per milestone
milestone/m<N>-short-nameoff up-to-date default (n8-execSKILL.md:23); one commit per story withRefs #N; PR body listsCloses #Nfor every completed story only (SKILL.md:36). Non-milestone work follows the same shape with a descriptive branch (n8-proj-mgmt); Dependabot branches aredependabot/npm_and_yarn/.... - GitHub Issues are the plan; every issue carries exactly one
area:*label from.n8/config.yml(api, spa, plugins, services, flowable, infra, ci, docs, tests) plussev:*for audit findings. Issue forms:.github/ISSUE_TEMPLATE/{epic,story,bug}.yml. - Any change that deviates from what planned issues assume (library, provider, architecture, scope, an invariant) gets an
## Ad-hoc — <date>entry in.n8/decisions.mdand a/n8-replansuggestion to the user (CLAUDE.md:29-33). Format is in that file's header. - Plans predating n8SDLC live in
docs/plans/YYYY-MM-DD-kebab.mdas historical context; don't add new ones unless asked (auto-memoryreference_plan_location.md). - Whole-codebase audits run via
/n8-auditwith checklists in.n8/memory/audit-*.md; a "dead code" claim needs a trial delete before filing (.n8/memory/audit-conventions.md). - CI runs in
.github/workflows/ci.yml(three jobs: SPA lint/typecheck/build, backend tests, Playwright E2E), added for issue archived-79 after this map was generated. The equivalent local gate before merge isdotnet build AutoNate.sln && (cd src/AutoNate.Spa && npm run lint && npm run build) && dotnet test AutoNate.sln(README.md:96-101).
Getting started
Using Auton8
- Records
- Workflows
- Documents-and-Notes
- Queries-and-Dashboards
- Data-Stores-and-Pipelines
- The-Assistant
- Administration
Building Auton8
Repository