Skip to main content

History / Changelog

This page is a changelog-style rendering of the repository's DEVLOG.md — a chronological record of why things ended up the way they did, including decisions carried over from other projects and gaps deliberately left open. CLAUDE.md (and this site's other pages, generated from it) is the normative, current-state reference; this page is for context on how it got there. Entries are kept in their original order — nothing has been dropped, though older/minor entries are summarized more tersely than major ones.

1. Frontend design system ported from LSCoreNext (2026-07-22)

Eos.Web's shell (AppShell/TopBar/SideNav/AppBreadcrumb) and login page were reskinned using a vendored CSS design system (tokens.css/app.css) and a shared component library (Button, PageHeader, Modal, ConfirmDialog, EmptyState, LoadingSkeleton, ErrorBoundary, StatusBadge, MenuIcon, Toast/ToastProvider), ported from a sister project, LSCoreNext — a separate decoupled-migration effort against the same legacy application, found in this repo's temp/ folder. Only its frontend visual layer was ported; it's unrelated to Eos's backend/architecture.

Deliberately scoped as a design-only port, not a full rewrite:

  • The 12 existing CRUD pages keep using Ant Design (antd) for tables/forms — the source project never built a ported grid/form equivalent.
  • Data fetching stays on axios + useEffect/useState; adopting TanStack Query (used throughout the source project) was deferred as a separate task.
  • The source project's per-client login-page branding logo wasn't ported — no backend support for it exists in Eos yet.
  • MenuIcon always falls back to a generic icon — Eos's menu DTO has no IconName column yet, unlike the source project's MAS_MENU.IconName.

2. Lesson carried over: real classes, not invented ones

:::tip Lesson learned in the source project, carried forward as a standing rule Nearly every shared shell component the source project built early on used invented CSS class names with zero real styling behind them, written before anyone had actually read tokens.css/app.css. Once those files were read, a complete, already-working design system turned out to exist for nearly everything — the invented classes/components had to be rewritten wholesale (one entire CSS file deleted as fully superseded). That's why this port vendored tokens.css/app.css wholesale up front, rather than re-implementing the look piecemeal from screenshots or guesswork. :::

3. Sidebar collapse + hover-flyout, ported as one unit

SideNav's collapsed icon-rail + hover-triggered flyout (200ms close-delay, cancelled on re-entering either the icon or the flyout) was ported as-is from the source project's own redesign pass. Known limitation carried over unfixed: the flyout has no viewport-boundary clamping — acceptable today given how few menu items exist, worth revisiting later.

4. Navigation approach: conventional routing, not a tab workspace

The source project explored, then reversed away from, an ERP-style multi-tab workspace (closable tabs, multiple screens open at once) in favor of conventional one-screen-per-URL routing — no usage data justified the extra complexity, and plain routing gives deep-linking for free. Eos's shell inherits this same philosophy.

5. Known gaps / deferred, from this port

  • TanStack Query not adopted — still plain axios + useEffect/useState.
  • No per-item menu icons until Eos's backend adds an IconName-equivalent column.
  • No per-client login-page branding logo.
  • This design port had not been visually verified in a browser by the agent that built it at the time (no browser tooling available in that session).

6. CitiesPage pilot: antd grid/form replaced, then a real crash found and fixed

Piloted the design system on CitiesPage as the first of the 12 CRUD pages to come off antd: table.grid for the list, .right-panel for create/edit (replacing Drawer), the .field/.fields-2col/.fields-3col pattern (replacing Form/Row/Col), .cb checkboxes (replacing Switch), a native <select> (replacing Select), ConfirmDialog (replacing Popconfirm).

:::danger Real incident: hard-crashed the browser tab Shipped without pagination at first, and it hard-crashed the browser tab ("Aw, Snap!") on real data. Root cause: antd's Table paginates at 10 rows/page by default, silently protecting every page built on it from ever rendering more than a page's worth of DOM at once. The vendored table.grid pattern has no such limit — it renders exactly what it's given, and MAS_CITY is a real, large reference table (world cities). Fixed by adding client-side pagination (50 rows/page) as a first pass — see the Design System and Pagination pages for how this was later upgraded to real server-side pagination. Every one of the other 11 pages needed the same treatment as it came off antd, regardless of how small that entity's table looked. :::

7. Cities page slow on first open against real client data — startup cache warm-up

Reported against a real deployment: the Cities page was noticeably slow the first time it was opened, fast after that. Root cause: CityRepository's 1hr IMemoryCache meant the very first request after API start (or cache expiration) was an uncached hit against a genuinely large real-client MAS_CITY table (same root cause as entry 6's crash). Fixed by warming ICityRepository.ListAsync() once at API startup, wrapped in try/catch so a warm-up failure never blocks the app from starting.

8. Company logo never rendered — [Authorize] + <img> tags don't mix under JWT auth

Reported as "the top bar never shows the real company logo." Root cause: browsers only attach the Authorization: Bearer header for requests made through the apiClient axios interceptor — never for a bare <img> tag's own request. CompaniesController had [Authorize] on the whole class including GetLogo, so every logo request 401'd and silently fell back to the text logo, indistinguishable from "no logo uploaded."

Fixed with [AllowAnonymous] on CompaniesController.GetLogo specifically — safe because CompanyService.GetLogoAsync was already deliberately ungated by design.

:::caution Not a general fix ClientsController.GetLogo has the identical mismatch, but ClientService.GetLogoAsync makes a real territory-gated access decision, so stripping its [Authorize] the same way would be a real security regression. The correct fix there is fetching the image through apiClient as a blob and using URL.createObjectURL(...), keeping the backend gate intact — not yet done for ClientsPage's logo as of the original entry. :::

9. CORS AllowedOrigins not actually substituted at deploy — array vs. scalar

A real deploy left Cors:AllowedOrigins empty despite adding an indexed pipeline variable (Cors.AllowedOrigins.0) — FileTransform@2's JSON substitution reliably replaces a scalar value by dotted path name, but substituting into a JSON array by index turned out to be unreliable in practice. Fixed by changing Cors:AllowedOrigins to a single comma-separated string (split in Program.cs) instead of a JSON array — a pattern worth reusing for any future config value that might otherwise be modeled as a JSON array.

10. Server-side pagination rolled out to all 12 grids — and a near-miss on territory gating

Followed up on entry 6's client-side pagination fix with real server-side pagination — PagedResult<T>, repository-level ListPagedAsync for ungated entities vs. service-level GetAllPagedAsync for gated ones, the one-route-two-shapes GetAll convention, usePagedList + DataGrid.

Rolled out via parallel agents, each told (incorrectly) that their entity was "not territory-gated, mirrors City exactly."

:::tip Worth remembering Every agent caught the mismatch and self-corrected — Equipment, Department, ForumType, Queue, TrCode, and Address are all actually MAS_USER_TERRITORY-gated (only City and Country genuinely aren't). Each agent independently read the real service code and implemented the gated (filter-then-page) shape instead of the literal instruction, catching what would otherwise have been a real access-control regression across six entities at once. :::

The self-corrections weren't fully consistent with each other structurally (three different approaches across entities, all equally safe), and one group left a dead, unfiltered ListPagedAsync on their repositories as a foot-gun — removed. Address needed its client-side type-filter threaded through to the server as a query param rather than being applied only to the current page. Backend and frontend both built clean; all 50 existing backend tests passed.

11. Translatable labels rolled out shell-wide and to all 12 CRUD pages

Built a full i18n system from scratch: languages sourced from real MAS_CHOICES data (ChoiceType = 'LANGUAGES'), flag icons as real ChoiceImage blobs (not emoji). Frontend: useTranslation() (t(key, fallback, params?), fallback required so a missing dictionary entry never surfaces as broken UI), LanguageProvider/useLanguage(), LanguageSelector, one dictionary file per namespace under src/i18n/en/.

Rollout order: infrastructure first, CitiesPage converted by hand as the reference (shared components and shell also done by hand), then one parallel agent per remaining entity. All 11 agents produced code that compiled clean on the first consolidated build — no fix-up pass needed. Two adaptations worth noting for future rollouts: a translated-options constant had to move from module scope into the component body, and two pages had a pre-existing local variable literally named t that had to be renamed to avoid shadowing the translation function.

12. SuperAdmin accounts had no branch to show in TopBar — fixed with a DefaultBranch fallback

Found while checking the branch selector on a real environment: TopBar's branch block only renders when the JWT carries a branchCode claim, but a SuperAdmin's MAS_USER.BranchCode is always null/empty/"0" by definition, so the branch selector had silently rendered nothing for every SuperAdmin since it was built. Fixed by adding IAppSettingRepository/AppSettingRepository and having LoginService resolve a DefaultBranch setting as the SuperAdmin's displayed branchCode claim, falling back to the original blank value if no such setting exists. AuthenticatedUser.SuperAdmin itself is untouched — this only changes what a SuperAdmin's token displays as their working branch.

Not yet done (at time of writing): no client database had been confirmed to actually have the DefaultBranch setting row seeded.

13. A generic MAS_CHOICES admin page — the first entity with no legacy manage SP

Requested directly once the language selector needed a way to add more LANGUAGES rows. Built as a full generic MAS_CHOICES admin page rather than scoping to just Languages, since the table backs many unrelated picklists. This was the first entity to have no legacy MAS_Man* stored procedure — writes go straight through EF Core instead, documented as an explicit exception in the CRUD pattern. Also added MAS_CHOICE_TYPES as a small read-only lookup to back the admin page's type filter. One real design wrinkle: the existing active-only cached ListByTypeAsync couldn't be reused for the admin grid, which needs inactive rows too — solved with a different (uncached, live) filter shape for the paged/admin read rather than a different payload size.

The "HOU — Houston [Change]" link + modal popup was replaced with BranchSelector, a self-contained searchable combobox inline in TopBar — no modal. Reused existing vendored CSS classes throughout; no new CSS was written. Selecting a branch still only surfaces a toast — branch-switching itself still wasn't wired up to the backend, purely a UI replacement. BranchSwitcherModal.tsx was deleted as dead code.

15. /health endpoints, then API versioning

Two follow-ups from a backend-architecture review:

  • /healthEosDbHealthCheck checks EosDb connectivity, registered on both hosts. Eos.Workers needed a FrameworkReference to Microsoft.AspNetCore.App and a swap to WebApplication.CreateBuilder since it had no ASP.NET Core surface at all.
  • API versioningapi/v{major}/operations/... via Asp.Versioning.Mvc, specified in full up front. All 17 controllers surveyed before touching anything; AuthController confirmed to stay unversioned as the one cross-cutting exception. A temporary 308-redirect middleware plus AssumeDefaultVersionWhenUnspecified were added as migration nets, both flagged for removal once no traffic depends on them. All 17 frontend src/api/*.ts files updated to the new /v1/ paths.

16. FluentValidation wired up — global filter, one reference validator, then 12 more in parallel

FluentValidation had been a referenced dependency with zero validators. Wired up as auto-discovery (anchored on ChoiceDto, not Program, since validators belong in Application) plus a global ValidationActionFilter producing ValidationProblemDetails. Surveyed all 17 controllers: 13 accept a request body, 3 are read-only, AuthController.Login flagged as out of scope. Built ChoiceDtoValidator by hand as the reference, then dispatched 12 parallel agents (one per remaining DTO), each told to validate only genuinely primary/identifying fields and derive MaximumLength from the real EosDbContext column mapping. All 12 came back with real, verified rules; backend built clean on the first consolidated attempt.

17. Audit-trail change detection — DtoDiff for SP writes, EF ChangeTracker for Choices

Came out of a design discussion on whether audit trails needed an Observer-pattern implementation. Landed on: eventing (RabbitMQ) already covers "notify subscribers," what's actually needed is a diff/changeset step — and Eos's split write path needs two different mechanisms: DtoDiff (reflection-based before/after comparison) for the SqlCommand/legacy-SP path, and EF Core's own ChangeTracker for the one entity (Choices) written through plain EF Core. The diff payload landed on DomainEvent.Changes (null for Created/Deleted), reusing the existing eventing pipeline rather than inventing a second one. [AuditIgnore] was added to exclude write-only fields and fields already restricted from logging/output. Deliberately did not invent a persisted audit-log table — Changes only reaches a RabbitMQ message body so far; where audit history gets durably stored and queried is a separate, later decision. Backend built clean; 61 tests passed (52 existing + 9 new).

18. DBUp replaces per-client hand-run sqlcmd for database migrations (2026-08-05)

EosDatabaseMigrator (DBUp, embedded scripts, run from every composition root's Program.cs) replaced the old workflow of hand-running each DDL/seed script per client — a real gap it closed: two menu-permission seed scripts had never actually run against any database, including Sandbox, silently 403ing every non-SuperAdmin user on two controllers. Deliberately doesn't copy legacy's own migration tool's shape (plaintext connection string on the CLI, no journal table) — runs in-process off the app's own resolved connection string instead.

:::caution Real incident: four scripts hardcoded Sandbox-only IDs Four hand-run scripts hardcoded a specific RoleId/UserId "confirmed" against Sandbox's own test data — unsafe for any other client. Fixed by dropping the role-grant step from those scripts; granting a newly-seeded menu item to a role is now a manual, per-client step through the existing RoleMenuGrants screen. :::

Verified end-to-end against the real ceva-eos sandbox: all 12 embedded scripts journaled correctly on a first run, a second run was a true no-op.

:::tip Debugging lesson: check VPN before assuming a real bug The first verification attempt failed with a raw SQL connection timeout that looked identical to a real bug — turned out to be the VPN being disconnected. Check plain TCP connectivity first before assuming a sandbox-touching test/verification failure is a real code bug. :::

19. Optimistic concurrency rollout across every eligible Mas* repository (2026-08-08/09)

Every Mas* write was last-write-wins with no concurrency token anywhere. Settled on an application-layer UpdatedDt compare-then-write check (no table in the legacy schema has a real rowversion column, and legacy SPs execute unconditionally) — check-then-act, not atomic, a narrow TOCTOU window accepted as strictly better than nothing. Rolled out to 20 entities, all verified live against ceva-eos via throwaway harnesses.

Found and fixed four real, unrelated bugs along the way by insisting on live verification rather than accepting "build succeeded": EmailAccountRepository/ TpAuthConfigRepository.CreateAsync throwing despite the row inserting successfully (SCOPE_IDENTITY() returning DBNull after an RPC-style SP call — switched to @@IDENTITY); ClientRepository.DeleteAsync and 10 similarly-shaped call sites throwing because new SqlParameter(name, 0) resolved to the (string, SqlDbType) overload instead of (string, object); TrCodeRepository. DeleteAsync omitting a required SP parameter; and ClientRepository.DeleteAsync returning true for a client code that was never created (the SP's ~14 referential-integrity guards never actually check the code exists) — fixed with an existence pre-check.

:::tip Harness gotchas worth remembering for future live-verification work Reuse a fresh DbContext per operation (a shared one causes false Conflicts via EF's identity map); a freshly-created row's UpdatedDt is NULL until a baseline update, so seed a real timestamp before testing stale/correct-stamp paths; some legacy SPs' Create leaves extra audit/HR side-effect rows behind — an unconditional by-name cleanup still leaves zero residue. :::

20. Automation engine: Event-Condition-Action rules, built and verified in stages (2026-08-03/04)

Replaced legacy's IFTTT-style trigger/action/scenario model with a data-driven, DI-registered plugin model — brand-new Eos-owned tables (MAS_TRIGGERS_V2, etc.) rather than migrating legacy's live ones, since legacy's own consumer is confirmed still running against them for at least one client. Current shape is in CLAUDE.md's "Automation engine" section.

:::tip Most-verified feature to date, at the time Before Stage 4, applied the new DDL for real against ceva-eos, briefly flipped on real queue processing, ran Eos.Workers against the sandbox's real RabbitMQ, and published real synthetic events — a transient httpbin 503 logged as Failed, a real 200 logged as Success, both for real, not just build+unit-test green. :::

Later additions the same week: an audit-log query/UI (ScenarioLogsController/ ScenarioLogsPage.tsx, the first Eos.Web page for this module), and a scenario-evaluation trace (COM_SCENARIOS_LOG_EVAL_V2, recording every condition's result per candidate, not just the first failure) — also verified live against the real sandbox.

21. Azure File Share attachment storage + narrow EDI file primitive (2026-08-04, 2026-08-08)

Replicated legacy's ad-hoc Azure File Share attachment calls as a real, reusable storage primitive — first consumer WMS_ATTACHMENTS via MasterOrder, matching legacy's 15 MB upload cap and deleting an already-uploaded file on a rejected Create to avoid an orphaned blob. A narrow EDI file storage primitive (IEdiFileRepository) followed a few days later — asked the user first, since full EDI ingest wiring is still genuinely blocked; this ships only the storage plumbing a future adapter will need.

22–23. MAS_SYSCONFIG + three more admin CRUD pages (2026-08-06)

Built a full edit UI over MAS_SYSCONFIG (reversing its former read-only status) plus App Settings/Email Accounts/SSO Providers admin pages, all Reference-CRUD/pagination shape. Secret masking and preserve-if-blank behavior were added unilaterally, since this was the first time MAS_SYSCONFIG's credential columns became reachable over HTTP at all.

:::caution Two stale doc comments corrected by grepping the real SPs AppSettingRepository's doc comment claimed no write path existed — wrong, MAS_ManAppSettings exists (with a real self-assignment bug in its Update branch, routed around). ITpAuthConfigRepository's made the same wrong claim. Trusting an existing comment instead of the real SP body would have shipped both wrong. :::

SSO Providers was the highest-risk page in this pass — MAS_TP_AUTH_CONFIG already backs a live production SSO pilot, so ClientSecret never reaches a controller in either direction; the admin DTO carries HasClientSecret instead.

24. eos-scaffold: SP-signature-driven repository scaffolding CLI (2026-08-08)

Built a CLI to generate a full Reference-CRUD slice by introspecting the real SP signature and table schema, never by hand-transcribing a parameter list.

:::caution Real schema-drift bug found while building the tool itself A first pass against MAS_CURRENCY generated entity.UpdatedDtUTC, which doesn't compile — EF's scaffolder had cased the real column as UpdatedDtUtc. The same failure mode two prior real incidents (MAS_MENU, MAS_ORDER_TYPES) already warned about, now reproduced by a naive schema-introspection generator. Fixed by adding a schema-drift cross-check that scans the real scaffolded entity and drops any field with no match, printing what it dropped. :::

25. Atomic conditional-UPDATE optimistic concurrency: City pilot (2026-08-18)

Upgraded City from the app-layer compare-then-write check (entry 19) to a real DB-enforced atomic conditional UPDATE/DELETE, using EF Core's IsConcurrencyToken() on the ordinary UpdatedDt column plus overriding the tracked OriginalValue to the caller's last-seen value — EF folds the check into the same UPDATE ... WHERE ... AND UpdatedDt=@original it already generates and throws when the affected-row count is zero, closing the TOCTOU window entirely. Scoped to EntityFrameworkCore-strategy repositories only — StoredProcedure/Hybrid repos write through legacy SPs that execute unconditionally and can't be made atomic this way.

Proven twice: a throwaway EF Core InMemory POC first, then for real against ceva-eos. The live run caught a real test-scenario bug before it could be reported as a false negative — a freshly-created row's UpdatedDt is NULL, which skipped the concurrency check entirely until a baseline update was added before constructing the actual two-writer conflict. Only City was migrated this pass; the remaining 19 entities were rolled out later (see CLAUDE.md's "Consider optimistic concurrency" section for the full completed scope).

26–27. Draft/publish scaffold's first two real consumers: Transport Order, then Warehouse Order (2026-08-28/29)

The generic draft/publish scaffold (built 2026-08-04, commit 370a213) had sat unconsumed until Transport Order piloted it. Four correctness gaps surfaced during piloting — none Transport-Order-specific, all fixed in the shared scaffold before Warehouse Order started rather than being rediscovered per-entity: a missing BeginTransactionAsync on the draft repository interface; a transaction-accepting CreateAsync overload needed on every draft-enabled repository (confirmed empirically — a second nested transaction on the same DbContext throws at runtime); a new ICurrentUserAccessor (AsyncLocal-backed) so the draft service layer can get a display username down to a legacy SP parameter; and changing WriteRealEntityAsync to return (Result, Message) instead of a bare int, so an SP business-rule rejection surfaces as a retryable Conflict instead of an unhandled 500.

Warehouse Order's own wiring needed the same transaction-accepting-overload treatment, just via a different failure mode (SqlCommand.ExecuteNonQueryAsync throwing because the shared parameter-binding helper never assigned command.Transaction) — confirming the convention generalizes rather than being a Transport-Order-specific fix. Both passes deliberately narrowed scope: Warehouse Order ships header-only, with line-linking (WMS_WO_DL referencing an existing inventory item) left for a follow-up. Neither pilot added Testcontainers/HTTP integration coverage for the new draft endpoints themselves.

28. Eos.Api.External — first partner-facing business endpoint (2026-08-31)

POST api/v1/external/masterorder, writing through the existing internal MasterOrderService path, preceded by a read-only design audit. The audit found a real gap before any code shipped: PartnerTokenIssuer minted a JWT with no groupId/superAdmin/clientCode claims, so every partner call would have hit CallerContext's territory gate and come back unconditionally Forbidden — not a bug in the order service, a mismatch between the partner and internal token shapes. Fixed by embedding the same claim set internal tokens already carry.

Also added a deprecated inline Basic-auth scheme (PartnerBasic) alongside the existing OAuth2 password grant, built to produce an equivalent CallerContext either way and marked with an RFC 8594 Deprecation header on every response. Confirmed gap, not closed by this pass: AccessPolicyRegistryCoverageTests only reflects over the internal Api assembly, so the new Api.External controller is invisible to it — no equivalent coverage exists for Api.External yet.

29–31. i18n L1/L2/L3 — RTL stubs, label-translation column-parity widening, data-translation resolution (2026-08-31)

Three passes off a frontend and a backend i18n audit, all landing the same day. L1: found substantial prior work already shipped (real ar-LB/tr-TR content, RTL dir switching, directional-glyph handling) — generated __NEEDS_TRANSLATION__-stubbed content only for the 3 languages that had none (de-DE/es-ES/zh-CN, 1,197 keys × 3, mechanically generated for exact key/value parity with the English source) rather than regenerating the two languages with real content. L2: widened LabelTranslationsDto/its backing repository from 19 to 124 fields (every column confirmed present on both MAS_TRANSLATIONS and MAS_CLIENT_TRANSLATIONS), deliberately leaving the inconclusive A-prefix columns unprojected rather than guessing their meaning. L3: found the core data-translation mechanism (ILanguageContext, IDescriptionTranslationService, the Accept-Language pipeline) already built by a prior, undocumented session — built a new read-only Sku repository (the one DescType with nowhere to inject into, no SKU repository existed at all before this), and deliberately kept the service's real legacy-accurate 2-step fallback chain rather than the 3-step one a spec called for.

:::tip Recurring pattern across all three passes Each pass hit at least one point where the literal instruction conflicted with either already-shipped work or a confirmed real constraint, and stopped to confirm with the user rather than guessing — regenerating working translations as empty stubs, projecting unconfirmed A-prefix columns, and replacing a legacy-accurate fallback chain with a spec'd one that didn't match production behavior were all avoided this way. :::

32. eos-contracts — scoped package rename + Azure Artifacts publishing pipeline (2026-08-31)

Renamed the package to @logiswift/eos-contracts and wired up the CI publishing pipeline a same-day audit found was still missing despite the package already existing. eos-web deliberately stays on a file: reference to the local folder rather than the newly-published version — intentional until a real out-of-repo consumer needs the pinned published package, not a leftover to "finish" later.

33. Eos.Web CLAUDE.md doc-accuracy fixes (2026-08-31)

Two small onboarding fixes from a frontend-foundation audit: corrected a stale claim that the ~12 Reference CRUD pages still used antd (they were ported off it back in entry 6, and antd was later dropped as a dependency entirely — confirmed via grep before editing, not assumed), and added a short "when to use TanStack Query instead" pointer plus an "Api.External is a separate backend" section so a new contributor doesn't copy src/api/*.ts patterns against the wrong auth model. A reminder that even a file specifically kept as the current-state source of truth needs its own periodic accuracy pass.