Skip to content

release: promote develop to main — Collaboration service, .NET 10, audit hardening & branch-timeline UX#88

Merged
4Keyy merged 77 commits into
mainfrom
develop
Jun 2, 2026
Merged

release: promote develop to main — Collaboration service, .NET 10, audit hardening & branch-timeline UX#88
4Keyy merged 77 commits into
mainfrom
develop

Conversation

@4Keyy

@4Keyy 4Keyy commented Jun 2, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Promotes the entire develop line to main77 commits of work accumulated since the last
main sync. This is a release-grade integration PR that brings together a new microservice, the
.NET 10 platform upgrade, a multi-wave production audit, a large security/performance/observability
hardening pass, the task branch timeline and task-modal UX overhaul, the category-filtering
UX, accessibility fixes, the local/Docker launcher tooling (incl. one-command Wi-Fi sharing), a
comprehensive documentation refresh, and a green CI/CD + supply-chain pipeline.

Everything below is already on develop and green in CI; merging makes main the faithful,
production-ready reflection of the project.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Documentation
  • CI / infrastructure
  • Other: platform upgrade (.NET 9 → .NET 10), security & performance hardening

Highlights

🧩 New service — Collaboration microservice

  • Extracted the task comment timeline ("ветки" — user / genesis / system comments) out of TodoApi
    into a dedicated Collaboration service with its own bounded context, database
    (planora_collaboration), and gateway prefix (/collaboration/api/v1/comments).
  • TodoApi no longer holds any comment code; it publishes task-lifecycle integration events
    (TaskCreatedIntegrationEvent, TaskActivityIntegrationEvent, TaskDeletedIntegrationEvent) via
    its Outbox and exposes TodoService.CheckTaskCommentAccess over gRPC.
  • Collaboration authorises every read/write through that gRPC access check (owner / shared / public +
    friendship — never reading Todo's DB, INV-OWN-1) and materialises system/genesis comments from
    the Todo events through idempotent Inbox consumers (INV-COMM-4).
  • A standalone Planora.Migrator --backfill-collaboration step idempotently copies legacy comments.

🏗️ Platform — .NET 9 → .NET 10 (LTS)

  • Migrated the whole backend to net10.0, bumped EF Core / Npgsql / ASP.NET packages accordingly,
    and pinned the Docker base images to 10.0.
  • The local launcher auto-resolves (and, if needed, installs) a side-by-side .NET 10 SDK.
  • Removed three PackageReferences the .NET 10 SDK flags as redundant (NU1510) so the solution builds
    with 0 warnings / 0 errors even under a strict restore + -warnaserror.

🔍 Production audit — waves A → J

A systematic audit hardened the platform across every layer and codified the results as closed-form
architectural invariants (docs/INVARIANTS.md):

  • Security: refresh-token reuse detection, security-stamp rotation policy, gateway
    forwarded-headers guard, gRPC trust-context pin + x-service-key on every hop, header hardening,
    clock-skew unification, CategoryApi no longer leaks raw exception messages, friendship gate on
    comment reads.
  • Reliability/integrity: transactional Outbox and idempotent Inbox, EF schema-drift guard in
    the migrator, RabbitMQ publisher confirms, connection-pool sizing, NoOpDomainEventDispatcher
    semantics clarified.
  • Performance (Phase 4): EF N+1 sentinel interceptor, AsNoTracking() on append-only/read paths,
    outbox partial index, Postgres idle_in_transaction_session_timeout, Redis maxmemory policy,
    cache hit-ratio metric, hardware-adaptive WebGL background + MotionConfig.
  • Frontend integrity: hydration-year fix, rehydrate race, CSP nonce per request, CSRF retry,
    traceparent reuse, cross-tab logout, per-segment loading.tsx / error.tsx, AbortController on
    dashboard fetches.

🌿 Task branch timeline & task-modal UX overhaul

  • Single source of truth: a task's description now lives only on the Todo aggregate; the pinned
    "Author's Note" is synthesised on read (with live author identity), so it shows instantly and always
    matches the card — even for tasks created before the Collaboration service.
  • Sticky Author's Note: the pinned note is part of the scrollable branch; once it scrolls away a
    condensed frosted-glass bar appears, and clicking it smooth-scrolls back with a highlight pulse.
  • Continuous, centered rail: the timeline line is a single gradient that spans the whole feed (no
    more breaking when the feed scrolls); avatars and system-event markers are centred on it, and
    system markers use simple greyscale, event-typed icons.
  • Compose "+" menu actions: "Take into work / Leave task" and "Complete task" are available to all
    participants; the author-only "Description" item is hidden for non-owners.
  • Fixed-size modal, owner-only field gating (priority/date/visibility are read-only & muted for
    non-owners; the non-owner date popover hides the quick-pick row), modal stays open when leaving
    work (header pill, "+" menu, dashboard or active feed), and long unbreakable text now wraps.
  • Live & instant: branch comments update without reopening the modal; the Outbox dispatcher is now
    signal-driven so the "started/left/completed" system comment lands in well under a second.
  • Also fixed the comment edit/delete 409 concurrency bug (an AsNoTracking load dropped the
    PostgreSQL xmin token), and the branch now opens at the newest message.

🗂️ Category filtering UX

  • Replicated the /tasks category filter onto /tasks/completed (incl. the F hotkey), then
    unified both into a shared QuickFilterBar where the active-filter summary lives inside the
    plate (fixed-height crossfade — no layout shift).

♿ Accessibility

  • Associated auth-form and category Name/Description labels with their inputs, named the
    password-visibility toggles, and centred the navbar avatar.

📡 Realtime & observability

  • Durable Notification / NotificationDelivery + Outbox scaffold for the Realtime service; cache
    hit-ratio metric; structured Serilog + OpenTelemetry across services.

🛠️ Developer experience

  • Start-Planora-Local.ps1 and Start-Planora-Docker.ps1 brought to parity: health-gated startup,
    graceful shutdown, .NET 10 SDK auto-resolution, and one-command Wi-Fi/LAN sharing (-Lan) that
    detects the physical LAN IP (VPN-safe), opens the firewall (LocalSubnet only), and prints a share
    URL. Full annotated Get-Help and usage docs.

📚 Documentation

  • A comprehensive, marketing-grade README (with linked, version-pinned package tables), plus
    reconciled architecture / database / API / features / configuration / OPERATIONS /
    codebase-map / INVARIANTS and a running CHANGELOG.

🔁 CI/CD & supply chain

  • Backend build (-warnaserror) + tests, frontend lint/type-check/test/build, Playwright e2e, EF
    migration scripts, OpenAPI lint, markdownlint, and security (CodeQL, Trivy IaC, gitleaks, dependency
    audit, signed CycloneDX SBOM). Pinned action SHAs to Node 24 runtimes and added develop triggers.
  • Latest fixes: green markdownlint (normalised CHANGELOG bullet style) and restored the 85% branch
    coverage
    gate with a new QuickFilterBar test suite.

Testing

  • Backend unit / integration / architecture tests pass (dotnet test Planora.sln)
  • Frontend lint / type-check pass (npm run lint && npm run type-check)
  • Frontend tests pass (npm run test374 tests, branch coverage 85.64%)
  • Solution builds clean under the CI command and a strict restore + -warnaserror (0/0)
  • Markdownlint is green across the tree
  • Manually exercised the affected flows (task branch, modal actions, category filtering, LAN share)

CI on develop is green; this PR is a fast-forwardable promotion of that verified state.

Checklist

  • No secrets, personal data, or local-only config committed
  • .env.example updated for new env vars (LAN/CORS/email keys documented)
  • Documentation updated (README + docs/* reconciled with the code)
  • CHANGELOG.md updated under ## Unreleased

Generated by Claude Code

claude added 30 commits May 27, 2026 10:30
…apture default off, cache pattern, SHA256

Phase 1.5 audit-hotfix wave A (H1, H8, H17, H18):

H1 — Unify JWT ClockSkew. Six wiring points (Auth JwtConfiguration, Auth
DependencyInjection, Auth TokenService 2×, Messaging Program, Realtime
Program) used TimeSpan.Zero; BuildingBlocks consumer extension used 30s;
Gateway used 5s; SecurityConstants.TokenClockSkewSeconds was 5 and unused.
Now every wiring reads SecurityConstants.SecurityPolicies.TokenClockSkewSeconds
(set to 30 s — tight enough to bound post-expiry replay, loose enough to
tolerate Fly NTP drift). Pinned tests updated.

H8 — EF SetDbStatementForText defaults to false to remove the PII risk
documented in TelemetryConfiguration's own XML doc. Opt in via
OpenTelemetry:Tracing:CaptureDbStatementText=true per-environment.

H17 — CacheService.RemoveByPatternAsync implemented via Redis SCAN
+ KeyDeleteAsync (UNLINK) in 500-key batches. Prefixes the StackExchange
Redis instance-name to the pattern; skips replicas; cancellation-aware;
no-ops cleanly when no IConnectionMultiplexer is registered. L1 layer
remains TTL-bounded.

H18 — Idempotent fallback GUID hash: MD5 → SHA256 truncated to 16 bytes.
Removes the CA5351 static-analyzer flag with identical determinism.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…mpose, npm-audit high, NuGet cache, CD liveness probe

Phase 1.5 audit-hotfix wave B (H5, H7, H16, H21, H22, H23, P2-MIG-002):

H5 — Pin superfly/flyctl-actions/setup-flyctl to commit ed8efb3 (v1.6)
across all four CD workflow occurrences. Closes the supply-chain risk
explicitly flagged by the existing TODO comment.

H7 — docker-compose service healthchecks switched from aggregate /health
to /health/ready, matching INV-OBS-4 semantics and the Fly manifest
probes. depends_on.condition: service_healthy now means "ready to serve
traffic", not "process is alive".

H16 — npm-audit threshold raised from moderate to high. The frontend is
public-facing; High-severity transitive CVEs should block CI, not pass
silently.

H21 — Trivy IaC scan now has two passes: the first uploads SARIF for the
GitHub Security tab (informational), the second fails the job on any
HIGH or CRITICAL finding. MEDIUM is intentionally informational —
Trivy's MEDIUM rules are noisy at the IaC layer.

H22 — actions/setup-dotnet@v5 cache: true enabled across ci.yml,
security.yml, openapi.yml, migrations.yml. cache-dependency-path hashes
every csproj, Directory.Packages.props, and Directory.Build.props so the
key changes when (and only when) the restore graph changes. Expected
restore-time reduction ~60-80% on warm cache.

H23 — CD smoke now probes /health/live (15× × 2 s = 30 s window) BEFORE
the /health/ready poll. Distinguishes "gateway process crashed" from
"backends slow to warm up". A failed liveness fails fast instead of
burning two minutes on readiness retries.

P2-MIG-002 — Migration script idempotence check. The migrations workflow
now greps for "IF [NOT] EXISTS" markers in the generated SQL and fails
if a non-empty script lacks guards, catching any future EF tooling
regression where --idempotent silently produces non-idempotent output.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…SP, CSRF retry, traceparent reuse, cross-tab logout

Phase 1.5 audit-hotfix wave C (H9, H10, H11, H13, H14, H15):

H9 — Landing-page footer year guarded by the existing `mounted` flag.
auth/login and auth/register already use the same pattern; brings the
root marketing route in line. Removes the SSR/CSR DOM-text divergence on
year rollover and clock-skew machines.

H10 — Zustand onRehydrateStorage now explicitly sets isAuthenticated=false
when accessToken is absent on rehydrate. accessToken is in-memory only
so it's always absent post-rehydration; pinning the flag closes a brief
render window where guards could see isAuthenticated=true before
restoreSession() resolves.

H11 — Main axios client now retries a state-changing 403 once with a
fresh CSRF token (matching the existing auth-public.ts pattern). The
retry flag _csrfRetry prevents infinite recursion: a second 403 on the
same logical request propagates to the caller. Test rewritten to pin
the retry branch and the no-retry branch.

H13 — Cross-tab logout via BroadcastChannel. The Zustand store persists
to sessionStorage (per-tab), so the native `storage` event won't fire
cross-tab. clearAuth() now publishes a `logout` message on the
`planora-auth` channel; the SecurityInitializer subscribes and calls
clearAuth(true) on receipt (silent flag prevents an echo loop). A new
@/lib/auth-broadcast module owns the channel name and the publisher.

H14 — On a 401 retry, the original request's trace-id is preserved
(traceparentForExistingTrace) while a fresh span-id is generated. Keeps
backend trace correlation intact across the silent-refresh round-trip
instead of producing two unconnected traces.

H15 — CSP additions: object-src 'none', child-src 'none', worker-src
'self'. Defence-in-depth against reflected XSS payloads using <object>,
<embed>, or worker spawn. style-src 'unsafe-inline' stays — documented
trade-off for Tailwind/Next.js critical CSS injection.

Tests: 360/360 green; lint clean (only pre-existing warnings in navbar);
type-check clean.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
… wrapper removal, todo description, migrator drift, CODEOWNERS

Phase 1.5 audit-hotfix wave D (H2, H3, H4, H6, H19):

H2 — Refresh-token reuse detection. RefreshTokenCommandHandler now treats
presentation of a previously-rotated token (revoked with reason
"Replaced by new token") as a replay attack: every active refresh token
on the user is revoked with reason "Reuse detected — chain invalidated",
the security stamp is rotated (every minted access token gets rejected
on next call), and an Unauthorized response is returned. Adds
ISecurityStampService dependency to the handler; new xUnit theory pins
the chain-invalidation behaviour. Closes the audit's P2-Backend "no
refresh-token reuse detection" finding.

H3 — Todo description validator now MaximumLength(2000) on Create and
Update, matching TodoItemConfiguration.HasMaxLength(2000). Eliminates
the silent server-side truncation gap between FluentValidation's old
5000 ceiling and the actual varchar(2000) column. Direction chosen so
no existing data could exceed the new limit (column was always the
ground truth).

H4 — Auth API telemetry wrapper removed. Services/AuthApi/.../Configuration/
OpenTelemetryExtensions.cs is deleted; Program.cs now calls the canonical
BuildingBlocks AddPlanoraTelemetry(configuration, "AuthService")
directly, matching every other service and INV-OBS-5. Two test files
migrated to assert on the canonical surface.

H6 — Planora.Migrator now refuses to start a migration run when the
database has applied migrations that are absent from the compiled code
base (schema drift). Operators must reconcile (restore the missing
migration files in code, or reset the target environment) before re-
running. Removes the "delete-a-migration-locally-then-deploy"
foot-gun called out in the DevOps audit P1-MIG-001.

H19 — CODEOWNERS file. Codifies which surfaces (security primitives,
observability pipeline, outbox state machine, migrator, CI/CD,
deployment manifests, INVARIANTS) need reviewer attention. Branch
protection's "require code owner review" toggle can now enforce it.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…telemetry, schema-drift

Phase 1.5 audit-hotfix wave E (documentation closure):

INVARIANTS.md — five new closed-form rules:

- INV-AUTH-6 (refresh-token reuse detection): rotated-token replay must
  invalidate the entire chain and rotate the security stamp.
- INV-AUTH-7 (ClockSkew single source of truth): every JWT wiring reads
  SecurityConstants.SecurityPolicies.TokenClockSkewSeconds; literals are
  forbidden, pinned tests enforce equality.
- INV-FLOW-5 (migrator schema-drift guard): applied set must be a subset
  of code set; migrator fails fast on drift.
- INV-OBS-5 strengthened: forbid per-service wrappers around the
  canonical AddPlanoraTelemetry; EF SQL text capture is opt-in only.
- INV-FLOW-4 amended: migrations workflow asserts idempotence markers
  in every non-empty generated SQL script.

auth-security.md — new RefreshTokenCommandHandler (reuse path) row in
the security-stamp rotation table.

CHANGELOG.md — full Phase 1.5 hotfix-wave entry covering waves A-D
(H1 through H6, H8-H11, H13-H19, H21-H23, plus P2-MIG-002).

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…s (H12, H20)

Phase 1.5 audit-hotfix wave F:

H12 — AbortController plumbed through the tasks page mount-time fetch
trio. fetchActiveTodos, fetchCompletedPreview, fetchCategories now accept
an optional AbortSignal; the page-level useEffect creates one controller,
runs the three fetches in parallel via Promise.all, and aborts on
cleanup. axios.isCancel + signal.aborted guard every catch and every
setState past an await. Pagination loop checks signal.aborted between
pages so a rapid route switch does not keep paginating a stale list.

Scope: tasks page only. Dashboard / categories / profile fetch chains
have more complex orchestrations (multiple effects per page) and will
follow in a dedicated commit; tasks was the explicit P1-DATA-FETCH-RACE
target in the audit.

H20 — Opt-in pre-commit hooks. Two cheap gates that match what CI already
enforces but run locally in the few seconds before a commit:
  - ESLint with --max-warnings=0 on staged frontend files (requires
    frontend/node_modules present — gracefully skips if not installed)
  - dotnet format --verify-no-changes on Planora.sln if any .cs/.csproj
    is staged (gracefully skips if the dotnet CLI is not on PATH)

Implementation chosen for the polyglot tree: a plain executable
.githooks/pre-commit shell script plus a one-shot installer at
scripts/install-hooks.sh that calls `git config core.hooksPath
.githooks`. No npm dependency on husky, no root package.json required,
no implicit install on `npm install`. Contributors run the installer
once per clone; `git commit --no-verify` bypasses for emergencies.
CONTRIBUTING.md documents the opt-in.

Frontend tests: 360/360 green; type-check clean; lint clean.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
… into BuildingBlocks (Phase 2)

Replaces three drift surfaces (per-service BaseRepository in Auth and
Messaging; per-service OutboxRepository in Auth, Messaging, Category)
with two canonical implementations in
BuildingBlocks.Infrastructure.Persistence. Per-service classes survive
as [Obsolete] adapters for one release; they thin-wrap the canonical
implementation so behaviour is now identical regardless of which
import path a future caller picks.

BaseRepository<TEntity, TId, TContext> (BuildingBlocks):

  - Single source of truth for soft-delete filtering, AsNoTracking on
    read paths (INV-DATA-3 alignment — Todo/Messaging previously omitted
    it), pagination, and specification dispatch.
  - GetByIdAsync intentionally tracks (so callers can chain Update);
    every other read uses AsNoTracking.
  - Explicit !IsDeleted predicate plus services that also configure
    HasQueryFilter (Auth) get defence-in-depth — the SQL optimiser
    collapses the redundant predicate.

Auth.Infrastructure.Persistence.Repositories.BaseRepository<T>:
  - Now a thin [Obsolete] adapter inheriting from the canonical and
    preserving (a) the historical _context / _dbSet protected aliases
    used by the six Auth concrete repositories and (b) Auth's narrower
    Update semantics (Entry().State = Modified for root-only) so
    Include(u => u.RefreshTokens) flows do not accidentally overwrite
    refresh-token states.

Messaging.Infrastructure.Persistence.Repositories.BaseRepository<T>:
  - [Obsolete] adapter with no in-tree extenders. Preserved only for
    out-of-tree consumers; will be removed in the next release.

OutboxRepository<TContext> (BuildingBlocks):

  - Canonical polling query matches INV-COMM-3a: Pending OR
    (Failed AND NextRetryUtc <= now). DeadLettered is terminal and
    never re-picked.
  - The previous per-service queries had three different (and partially
    buggy) interpretations: Auth picked up Pending only, Messaging
    keyed retries on RetryCount<3 ignoring backoff, Category was
    correct. All three now share one implementation.

Category DI now registers the canonical OutboxRepository<CategoryDbContext>
directly. Auth/Messaging/Category legacy OutboxRepository classes
remain as [Obsolete] thin pass-throughs to the canonical for one
release so any code outside this repo that referenced them keeps
working with the corrected polling semantics.

Test: new CanonicalOutboxRepositoryTests asserts the canonical polling
predicate (Pending + Failed-due, ordered, DeadLettered excluded) and
the DeleteProcessed cut-off behaviour directly — independent of any
service-side adapter, so it survives the eventual adapter deletion.

Directory.Build.props: CS0612/CS0618 moved into WarningsNotAsErrors.
Deprecation cycles must show up in the compiler output for reviewer
visibility but must not break CI between the commit that adds
[Obsolete] and the commit that removes the last caller (typically
one release apart per the T2.3/T2.4 consolidation plan).

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…ashboard AbortController (T2.8 + H12 follow-up)

Phase 2 T2.8 + Phase 1.5 H12 closure:

T2.8 — Per-segment loading.tsx + error.tsx for tasks, dashboard,
categories, profile. Closes the "blank screen during streaming /
unrecoverable error" gap the audit flagged (P2-NO-LOADING-STATES-001).
A shared SegmentError component (components/ui/segment-error.tsx)
owns the retry + back-to-dashboard layout so each error.tsx is a
3-line client-component shim. Loading screens render skeleton rows
that match the page's eventual content.

H12 follow-up — Dashboard mount-time fetch trio now plumbs an
AbortController through fetchTodos / fetchStats / fetchCategories.
Promise.all runs them in parallel; cleanup aborts. axios.isCancel +
signal.aborted guard every catch and every setState past an await,
matching the tasks-page pattern shipped in wave F.

Categories and profile pages were also reviewed for the same race:
their fetch chains are simpler (single mount-time fetch wrapped in
its own effect) and the existing error handling is adequate. They
benefit only from the new loading.tsx / error.tsx.

Note on T2.7 (per-route force-dynamic removal): deferred. The current
nonce-based CSP requires per-request rendering for every route that
includes Next.js framework bootstrap scripts (which is every route).
Lifting force-dynamic safely needs either (a) a per-route nonce strategy
or (b) accepting 'unsafe-inline' in script-src — both meaningful
trade-offs that warrant a dedicated ADR before the change.

Tests: 360/360 green; type-check clean.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…guard, gRPC trust-context pin (T3.10, T3.11)

Phase 3 T3.10 + T3.11:

T3.10 — Frontend security-header expansion:
  - Referrer-Policy: strict-origin-when-cross-origin → strict-origin.
    The previous policy sent the full pathname + query to external sites
    on outgoing navigations (e.g. a todo description containing a URL
    with a token). strict-origin sends only the origin on cross-origin
    and full URL on same-origin — internal analytics keep the path,
    third parties get only the scheme + host.
  - Permissions-Policy expanded from 3 deny rules to 22, covering every
    sensitive browser API surface (payment, usb, vr, screen-wake-lock,
    publickey-credentials-get, browsing-topics, etc.). Planora uses
    none of them today; explicit deny narrows the attack surface for
    compromised third-party scripts.
  - Cross-Origin-Opener-Policy: same-origin — isolates this top-level
    window from unrelated cross-origin windows (Spectre-class leak
    mitigation, hardens postMessage flows).
  - Cross-Origin-Resource-Policy: same-origin — declares the page's
    resources are not intended for cross-origin loading.

T3.11 — Gateway forwarded-headers + gRPC trust-context audit:
  - The gateway now registers UseForwardedHeaders ONLY when
    ForwardedHeaders:KnownProxies contains at least one entry. With an
    empty list (default), external clients cannot spoof X-Forwarded-For
    to poison the rate-limit partition key. Production deployments
    behind Fly must configure the Fly edge range explicitly.
  - UseForwardedHeaders runs BEFORE HttpsRedirection so the latter sees
    the true client protocol and does not double-redirect HTTPS edge
    traffic.

  - New ServiceKeyInterceptorTests::
    ClientInterceptor_DoesNotLeakAuthorizationHeaderIntoOutgoingMetadata
    pins INV-AZ-6: outbound gRPC metadata contains exactly x-service-key
    and never the inbound HTTP Authorization or Cookie. Keeps the trust
    contexts (user JWT vs peer-service identity) cleanly separated.

INVARIANTS.md — two new closed-form rules:
  - INV-AZ-6: gRPC client never propagates inbound HTTP credentials.
  - INV-AZ-7: gateway processes X-Forwarded-* only when KnownProxies is
    configured.

Tests: 360/360 frontend; type-check clean.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
planora.cache.operations{prefix,outcome} counter emitted on every
CacheService.GetAsync call. Outcomes: hit_l1 (in-process MemoryCache),
hit_l2 (Redis), miss, error. Prefix is the first colon-delimited
segment of the cache key — entity name when callers use the
CacheKeyBuilder.ForEntity<T>(id) convention, so cardinality is bounded
by the codebase's entity set. Defence-in-depth: prefixes >48 chars or
empty collapse to "_other_" so a future buggy callsite cannot blow up
the time-series cardinality budget.

Hit ratio is derived in the metrics back-end with a Prometheus query:

  sum by (prefix) (rate(planora_cache_operations_total{outcome=~"hit_.*"}[5m]))
  /
  sum by (prefix) (rate(planora_cache_operations_total[5m]))

Tests: CacheServiceMetricsTests pins (a) miss emission when L2 empty,
(b) hit_l1 after a Set with local cache on, (c) hit_l2 with local cache
off, (d) the unbounded-prefix fallback to "_other_". MeterListener
subscribes to the published instrument and records every measurement;
runs against an in-memory IDistributedCache so no Redis required.

Documentation:
  - docs/caching.md "Observability" section rewritten with the metric
    definition + the Prometheus hit-ratio query; the "future work" note
    deleted.
  - INVARIANTS.md INV-OBS-6 instrument list extended.

Closes the open question in docs/caching.md flagged at the audit; the
master plan's T4.3 line item is now complete.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
Adds N1SentinelInterceptor at BuildingBlocks.Infrastructure.Persistence
plus the INV-PERF-1 invariant that pins it to the integration test
contract.

How it works:

  - The interceptor hooks every EF Core command lifecycle (Reader /
    NonQuery / Scalar, sync + async). Each command is fingerprinted by
    stripping parameter placeholders ($N, @pn) and collapsing whitespace,
    so per-row reads collapse to a single SQL shape.
  - Recording is gated by an AsyncLocal scope. Outside BeginScope() the
    interceptor is a complete no-op — production runtime cost is zero.
  - Inside a scope, fingerprints whose repeat count exceeds the threshold
    raise N1SentinelException on dispose. Tests wrap the request under
    test in BeginScope; a real N+1 in the handler fails the test
    deterministically.
  - Whitelist substrings exempt legitimately repeated reads. Callers
    declare intent by name, not by removing the gate.
  - Custom onViolation callback supports shadow-mode rollout: collect
    and report violations without throwing.

Test coverage (8 tests, all green):

  - 6 reads, threshold 4 → throws.
  - 5 reads, threshold 5 → passes.
  - Three distinct shapes ×2 each, threshold 3 → no shape crosses, passes.
  - 8 reads of a whitelisted shape, threshold 3 → passes.
  - 1000 reads with no active scope → no-op.
  - Custom onViolation callback collects instead of throwing.
  - Fingerprint normalisation (placeholders + whitespace).
  - Nested scopes restore the outer scope cleanly after an inner throw.

Integration suites will adopt the gate per request handler in a
follow-up commit. The N1Sentinel is registered via DbContext options:

  options.AddInterceptors(new N1SentinelInterceptor());

INVARIANTS.md additions:
  - New "Performance" section + INV-PERF-1 binds the sentinel to the
    request-scoped data-path test contract.
  - INV-CI-3 updated to reflect the wave-B / wave-H tightening:
    npm-audit threshold high, Trivy fail-on-high pass.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…L autobuild, nuget vuln auto-PR (T3.8/T3.9/T4.4/T4.7)

T4.7 — RabbitMQ publisher confirms + mandatory publish.
RabbitMqEventBus.PublishAsync now creates its channel with
CreateChannelOptions(publisherConfirmationsEnabled: true,
publisherConfirmationTrackingEnabled: true) and publishes with
mandatory=true. The combination guarantees that PublishAsync's task
completion equals broker durability commitment: nacks throw and
unroutable messages no longer silently disappear. Outbox processor
relies on this — a successful PublishAsync return is now an honest
signal to mark the outbox row Processed.

T4.4 — Connection pool sizing baseline.
docker-compose connection strings now carry Maximum Pool Size=10 and
Connection Idle Lifetime=60 for every per-service Postgres database.
With 6 services × 10 connections × N replicas, the math stays under
Neon-free's 100-connection cap with headroom for the migrator and
autovacuum. .env.production.example and deploy/fly/.env.fly.example
mirror the convention with a comment block explaining the math so
operators bumping replica count know to revisit the limit.
appsettings.json local-dev defaults (MaxPoolSize=100) are left alone —
they target a single-developer machine where collisions are not a
concern.

T3.9 — CodeQL build-mode: csharp now uses autobuild (was none) so the
data-flow taint queries that need compiled IL actually run.
javascript-typescript stays buildless. The matrix expanded to per-
language build-mode so the two languages don't share a setting that
suits neither. Timeout bumped 20 → 30 min to absorb the compile step;
setup-dotnet with cache: true keeps the second-build restore cheap.

T3.8 — Nightly NuGet vulnerability auto-PR workflow.
Compensates for Dependabot being disabled for the NuGet ecosystem
(CPM + per-project PR fan-out). The new
.github/workflows/nuget-vuln-pr.yml runs `dotnet list package
--vulnerable --include-transitive` at 03:00 UTC daily; on a hit it
opens (or updates) a single tracking PR on a stable
security/nuget-vuln-tracking branch with the report body. The PR is
explicitly a tracking artefact — maintainer applies the version bump
in a separate PR against Directory.Packages.props. A clean scan
closes the tracking PR automatically. Concurrency group prevents
overlapping runs.

Frontend tests: 360/360 green; type-check clean.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
…rs (T4.8, T4.11)

T4.8 — Redis maxmemory + allkeys-lru in docker-compose.
The local Redis container previously had no memory cap; once a
runaway prefix filled it, the container OOM'd. Now: maxmemory=256mb
plus maxmemory-policy=allkeys-lru evicts the least-recently-used keys
under pressure so the cache stays bounded. AOF persistence is kept
on so session data and rate-limit counters survive container
restarts. Production hosts (Upstash / Fly Redis) size via the
provider plan and the directive is moot there; this only affects
local dev and CI integration runs.

T4.11 — Remove unoptimized from next/image avatars.
Avatar component dropped the unoptimized flag so /_next/image now
resizes + reformats the 64/128/512 px WebP variants to the actual
display size (sizes={`${size}px`} prop). For a typical 40 px UI
avatar this is roughly a 20× bytes-on-the-wire reduction vs serving
the source variant as-is. remotePatterns in next.config.js already
whitelists the API origin in production (and all HTTP/HTTPS hosts in
dev), so the optimizer proxy can reach the avatar URL. The existing
onError handler still falls back to the initials block if the
optimizer pipeline ever fails.

Test fix: todo-small-components avatar render test now asserts a
substring of the URL-encoded optimizer src instead of the bare
resolved URL — pinning the exact serialization was a brittle
assertion that Next.js's optimizer URL shape can change between
minor versions.

Frontend tests: 360/360 green; type-check clean.

https://claude.ai/code/session_01B59DWTDTzpx4yRCLYZEhgQ
… bundle

Wires actions/attest-sbom into the security workflow so the frontend
CycloneDX SBOM is signed via the GitHub OIDC token and registered on the
public Rekor transparency log. Downstream consumers can verify the
supply-chain inventory with `gh attestation verify --owner 4Keyy
planora-frontend.cdx.json`.

Backend SBOMs (per-project, emitted by the CycloneDX .NET tool) are not
attested here — the follow-up CD pipeline will issue one attestation per
built container image, which is the right granularity. The frontend
bundle is the only public-facing artefact today, so signing it satisfies
the T3.7 audit line.

Runs only on `push` so external-fork PRs do not consume an OIDC token
they cannot use.
…livery + Outbox schema

Adds the persistence layer for the Realtime service so notifications survive
pod restarts (master plan T2.5, Phase 2). This is the **additive scaffold**
half of T2.5 — the EF migration itself and the NotificationService rewire
land in a follow-up commit that requires `dotnet ef` tooling.

Scope of this commit:

* `Planora.Realtime.Domain.Entities.Notification` — durable record of every
  consumed NotificationEvent, deduplicated by SourceEventId (unique index).
* `Planora.Realtime.Domain.Entities.NotificationDelivery` — per-recipient
  delivery state (Pending → Delivered | NotConnected | Failed) decoupled
  from the parent so reconnect-replay is cheap.
* `Planora.Realtime.Infrastructure.Persistence.RealtimeDbContext` with the
  two entities + OutboxMessages table (canonical shape matching sister
  services). Same domain-event dispatch pattern as CategoryDbContext.
* `Planora.Realtime.Infrastructure.Persistence.Configurations.*` — EF entity
  configurations including the SourceEventId uniqueness, per-user indices,
  global soft-delete filter, and the standard OutboxMessage indices.
* `Planora.Realtime.Infrastructure.DesignTime.RealtimeDbContextFactory` so
  `dotnet ef` commands resolve the context without booting ASP.NET.
* `tools/Planora.Migrator/Program.cs` registers the `realtime` service in the
  one-shot migration runner; csproj reference added.
* `Planora.Realtime.Infrastructure.csproj` adds EF Core + Npgsql parity with
  sister services.
* `DependencyInjection.cs` conditionally registers the DbContext on
  `ConnectionStrings:RealtimeDatabase`. Test and dev hosts without the DB
  still start clean; production wiring activates when the migration ships.
* `OutboxRepository<RealtimeDbContext>` (canonical, T2.3) registered when
  the DbContext is registered; no per-service duplicate exists for Realtime.
* DbContextCheck health probe registered when the DbContext is registered.
* docker-compose connection string left commented with an explanatory note
  — flipping it on without the schema applied would crash startup.
* New INV-DATA-5 codifies the durability contract.

Deferred to the next T2.5 commit (requires `dotnet ef`):
- `InitialRealtimeSchema` migration files + ModelSnapshot.
- NotificationService rewire (persist-before-push, idempotent on replay).
- docker-compose connection string activation.
Extends INV-AUTH-4 to spell out which future handlers must rotate the
security stamp (role assignment/revocation, admin force-logout, manual lock,
admin email override) and pins the policy with a source-file contract test
so a regression cannot slip past CI.

* `SecurityStampUsageContractTests` — scans every `*CommandHandler.cs` under
  `Services/AuthApi/Planora.Auth.Application/Features/**/Handlers/`. Any
  handler whose constructor injects `ISecurityStampService` must also
  invoke `SetStampAsync` somewhere in its body; missing call → fail. A
  sanity assertion catches regex drift so the test cannot become vacuous.
* `docs/INVARIANTS.md` — INV-AUTH-4 rewritten with three sections:
  shipped rotation points (now 7, including the refresh-reuse path from
  INV-AUTH-6), the forward-looking policy, and the explicit opt-outs
  (profile updates, single-session revocation).
* `docs/auth-security.md` — table updated; new "Forward-looking rotation
  policy" subsection mirrors the invariant and cites the contract test.
* `tests/Planora.UnitTests/Architecture/ArchitectureTests.cs` — adds
  `Planora.Realtime.Domain` to the enforced no-infrastructure-dependency
  set so the new T2.5 entities are covered.

No production code changes: `UpdateUserCommandHandler` (profile-only) and
`RevokeSessionCommandHandler` (user-scoped single-session) are deliberate
opt-outs and now documented.
Closes the two scoped halves of master-plan T4.10 that don't require a
wider bundle refactor.

* `MotionPreferencesProvider` wraps the root layout with a single
  `MotionConfig reducedMotion="user"`. Every nested framer-motion
  component now honours the OS prefers-reduced-motion preference
  automatically — transforms/physics collapse, opacity/colour stay.
  No per-component useReducedMotion() needed. Closes the gap for
  `loading.tsx` and `celebration.tsx` which animated transforms
  unconditionally.
* `color-bends-layer.tsx` — `useAdaptiveIterations` picks 1/2/3
  fragment-shader iterations from navigator.hardwareConcurrency
  (≤2 / 4–7 / ≥8 cores). Cuts low-end mobile GPU load in half versus
  the previous hard-coded 2 while giving desktops a richer effect.
  Returns 1 during SSR so hydration is deterministic; runtime upgrade
  happens silently on mount.
* `color-bends.test.tsx` — parameterised smoke test pins that the
  layer keeps rendering across all three core-count buckets.

Deferred: full dynamic-import of framer-motion per route — that is a
larger refactor than this commit, tracked in the master plan.
Adds the Postgres-side backstop for the per-service Npgsql pool. A leaked
DbContext or a client crash mid-transaction would otherwise hold a
connection open indefinitely, starving the pool (`Maximum Pool Size=10`,
T4.4) and surfacing as cascading timeouts on unrelated endpoints. 30s
leaves headroom for legitimate long batches while bounding the
starvation window.

* `docker-compose.yml` — postgres command adds
  `-c idle_in_transaction_session_timeout=30000`.
* `deploy/fly/README.md` — new "Postgres tuning" section documents the
  one-time `flyctl postgres config update` command for Fly Postgres.
…n flow

First slice of master-plan T2.6 (Phase 2). Lands the scaffolding plus the
login flow; remaining critical flows (register UI, forgot-password,
reset-password, verify-email-link, todo CRUD, sharing/hidden, profile,
2FA) land as additional `*.ui.spec.ts` files using the same scaffold.

* `playwright.config.ts` — two projects: `api` (existing request-context
  tests, `*.api.spec.ts`) and `ui` (new, Chromium + Desktop Chrome
  device, `e2e/ui/*.ui.spec.ts`). Independent base URLs, both coexist.
* `e2e/ui/_helpers.ts` — `requireFrontendReachable` skips the suite if
  Next.js is not up; `registerVerifiedUser` reuses the API path for setup
  so UI specs don't re-drive registration; `submitLoginForm` locates by
  visible label.
* `e2e/ui/auth-login.ui.spec.ts` — happy-path login routes to `/tasks`
  with the user's name in the navbar; wrong-password keeps the user on
  the login page with the error banner visible.
* `.github/workflows/e2e.yml` — installs Chromium, builds the frontend
  (production, not dev), starts `next start` on :3000, waits for ready,
  runs both projects, cleans up the PID. `E2E_FRONTEND_URL` exported.
* `e2e/README.md` — operator doc on the two-project setup.

The scaffold is additive: any CI matrix entry that doesn't export
`E2E_FRONTEND_URL` falls through to API-only (UI specs gracefully skip).
Second UI spec on the T2.6 scaffold: drives the registration form end-to-end
through the real Next.js render and asserts the post-submit redirect. A
second scenario exercises the Zod confirm-password mismatch and pins that
the page does NOT route away on a validation failure.

* `e2e/ui/auth-register.ui.spec.ts` — happy path types the form fields by
  visible placeholder and submits; mismatch scenario pins that submission
  is short-circuited client-side.

Validation behaviours (weak password, missing fields) stay in unit tests
against the Zod resolver — this spec focuses on the *browser submission
contract* so a future regression on form wiring is caught immediately.
Two more UI flows on the T2.6 scaffold.

* `e2e/ui/auth-forgot-password.ui.spec.ts` — happy path types a
  registered email and asserts the success banner replaces the form.
  Anti-enumeration scenario submits an unknown email and pins that the
  *same* success banner appears, so the UI cannot leak whether an
  account exists.
* `e2e/ui/tasks-page.ui.spec.ts` — verifies post-login arrival on
  `/tasks`, opens the create-task panel via its aria-labelled toggle,
  fills the title input, and closes the panel. Full create-flow
  validation (category selection) lands in a dedicated follow-up spec
  so this one stays robust against category-UI churn.
…g config

Targeted index improvements landing as EF entity configurations.

* All four outbox tables (Auth, Category, Messaging, Realtime) gain
  `HasIndex(Status, NextRetryUtc, OccurredOnUtc).HasFilter("Status IN
  ('Pending', 'Failed')")` named `ix_outbox_messages_active`. Directly
  covers the canonical polling predicate in
  `OutboxRepository<TContext>.GetPendingMessagesAsync`. Excluding the
  terminal `Processed` and `DeadLettered` rows keeps the index small
  even when the table accumulates ahead of the cleanup sweep.
* Messaging `OutboxMessageConfiguration` added — Messaging declared the
  `OutboxMessages` DbSet but never applied an explicit configuration, so
  EF used defaults and the outbox table had no non-PK index. The new
  config matches sister services exactly.
* `MessagingDbContext.OnModelCreating` now calls
  `ApplyConfigurationsFromAssembly` so future entity configurations are
  picked up automatically, matching the Auth/Category/Todo pattern.
* `TodoItemComment.AuthorId` gains a non-unique index. Audit views and
  account-deletion cascade scans previously seq-scanned this column.
* New INV-COMM-5 codifies the partial-index convention so future
  services holding an outbox table inherit the pattern.

Migration files generate when the next `dotnet ef migrations add` runs
against a developer environment with EF tooling available. The
schema-drift guard (INV-FLOW-5) ensures the desync surfaces as a hard
stop rather than silent partial application.
Closes the open question called out in master plan T2.7 ("Needs ADR on
CSP nonce trade-off") and the audit finding P0-FORCE-DYNAMIC.

* New ADR-0006 walks the fork in the road (static prerender + per-request
  nonce is impossible by construction; hash-based CSP is the only unblock
  without weakening script-src), documents the decision to keep
  force-dynamic + nonce until one of three sunset conditions ships
  (hash-based CSP wiring, a Next.js minor with a stable hash manifest, or
  a vetted community plugin), and rejects the alternatives:
  - 'unsafe-inline' — regression in security posture.
  - per-route opt-in — every route boots the framework runtime, so the
    set of nonce-free routes is empty until hash-CSP lands.
  - hand-rolled hash pipeline today — too tightly coupled to Next.js
    internals; breakage mode (white page on deploy) is unacceptable for
    a single-maintainer project.
* `layout.tsx` comment on the force-dynamic line now points at the ADR
  so future contributors see the rationale at the call site.

P0-FORCE-DYNAMIC is reclassified from "fix immediately" to "open
contingent on hash-CSP work" in the tracking.
Two threads in one commit since they're additive and unblock each other
in CI (the profile-update spec depends on the reset-password loop being
validated).

**T3.6 IDOR coverage baseline.**

* `docs/security-idor-coverage.md` (new) — hand-curated map of every
  `[Authorize]` endpoint with a resource-identifier path param. Pairs
  each with the IDOR protection mechanism (owner check, viewer filter,
  friend gate, role gate) and pins the verifying test. Zero `gap` rows
  in the current pass.
* `INV-AZ-8` — codifies the contract that future authorized endpoints
  must update the table + ship an explicit cross-user test.
* Forward step (auto-generation per T3.6) lands once T2.1's OpenAPI
  source-of-truth ships; this table is the curated interim baseline.

**T2.6 cont. — reset-password + profile-update UI specs.**

* `e2e/ui/auth-reset-password.ui.spec.ts` — end-to-end forgot → reset
  → login loop. Triggers the reset via API, scrapes the auth-api logs
  for the reset token (disambiguated from the verification token by
  matching the `Reset` subject in the log line), opens the reset page
  with the token in the query string, sets a new password, signs in
  with the new password to prove the rotation took effect.
* `e2e/ui/profile-update.ui.spec.ts` — log in, navigate to /profile,
  rename via first-name, reload to confirm persistence.
* `e2e/ui/_helpers.ts` — adds `requestPasswordResetAndCaptureToken`.
…mmits

Five defects in commits I shipped this session, fixed in one pass:

1. **T2.5 — Realtime DI startup crash on configured DB.** RealtimeDbContext
   takes IDomainEventDispatcher in its constructor (parity with sister
   DbContexts), but Realtime API does not call AddBuildingBlocksInfrastructure
   so the service was never registered. With ConnectionStrings__RealtimeDatabase
   set, the DI container would have failed to resolve the DbContext on
   startup. Fix: register a NoOpDomainEventDispatcher inside the conditional
   block (Realtime entities emit zero domain events today; TryAddScoped
   leaves room for a future upgrade if domain events get added).

2. **T3.7 — SBOM attestation subject-digest mismatch.** Passed
   `sha256:${{ github.sha }}` which is a 40-char git SHA, not a 64-char
   SHA-256. actions/attest-sbom would have rejected at runtime. Fix:
   switch to `subject-path` so the action computes the SBOM file's own
   SHA-256, with a comment explaining the path will tighten ("attest the
   built artefact, SBOM as predicate") once a single bundle exists to
   digest.

3. **T4.10 — Double WebGL scene build on mount.** useAdaptiveIterations
   used `useState(1) + useEffect(setIterations)` — the first render shipped
   `iterations=1`, the effect upgraded it, and the WebGL renderer (which has
   `iterations` in its useEffect deps) rebuilt the scene a second time. Fix:
   move the detection into the useState initializer so the first render
   already has the final value.

4. **T3.5 — Contract test regex too narrow.** Scanned only
   `*CommandHandler.cs`, so future handlers with other naming (`*Handler.cs`,
   query handlers) would slip past. Fix: broaden the glob to `*Handler.cs`.

5. **T3.6 — IDOR coverage doc cited fabricated test names + had a real
   gap.** The doc named tests like `FriendshipAcceptHandlerTests` that
   don't exist; the actual file is `FriendshipHandlerTests.cs` with
   combined transition tests. Also flagged a real gap on
   `DELETE /users/sessions/{tokenId}` — no cross-user xUnit existed for
   the `RevokeSessionCommandHandler` Forbidden path. Fix: rewrote the
   doc to cite real test file paths + named test methods; added the
   missing `RevokeSession_WhenTokenBelongsToAnotherUser_ReturnsForbidden`
   test to UserSecurityHandlerTests with explicit Update/SaveChanges
   verification that the victim's token is untouched. IDOR doc now shows
   zero gaps.
* `e2e/ui/auth-verify-email.ui.spec.ts` — two scenarios. The link-click
  path opens `/auth/verify-email?token=<real>`, the page auto-submits via
  its useEffect, and the success state with the "Go to dashboard" CTA
  replaces the form. The invalid-paste path types a bad token and pins
  that the form stays mounted for retry (no success banner appears).
* `e2e/ui/_helpers.ts` — adds `registerUserAndCaptureVerificationToken`:
  registers a fresh user via the API gateway, scrapes the Auth-API logs
  for the verification token, returns the token **uncomsumed** so the UI
  spec can drive verification through the actual page.
* `docs/INVARIANTS.md` — INV-DATA-5 rewritten to be explicit that this
  branch ships only the **scaffold** (entities, DbContext, configs,
  migrator wiring, conditional DI). The behavioural rewire of
  `NotificationService` and the initial EF migration land in a follow-up
  commit that requires `dotnet ef`. The previous wording implied the
  invariant was already enforced — it is not.
…ed reduced-motion

`Toaster` (`@/components/ui/toast`) renders `motion.div` slide/fade
animations for every toast. In the previous T4.10 commit it sat below
the `MotionPreferencesProvider` boundary, so the global
`reducedMotion="user"` setting did not apply to its animations — users
who set `prefers-reduced-motion: reduce` at the OS level still saw the
slide-in transitions.

Move the Toaster inside the provider. Comment in `layout.tsx` updated
to explain the placement (Toaster inside; ColorBendsLayer outside
because it owns its own WebGL `prefers-reduced-motion` check).
Previous comment said the dispatcher "upgrades" via TryAddScoped, which
was misleading — `TryAddScoped` only adds if no registration exists, it
does not replace one. The actual semantics are:

* If `AddBuildingBlocksInfrastructure` is called BEFORE
  `AddRealtimeInfrastructure`, the BB real dispatcher is already
  registered and the TryAddScoped here is a no-op.
* If `AddRealtimeInfrastructure` runs first, NoOp gets registered, and
  a later `AddScoped` from BB appends — last-one-wins on resolution
  picks the real dispatcher.

Either ordering yields the right runtime resolution; the comment now
spells that out instead of claiming an "upgrade" that wouldn't have
worked.
…OR map

Reality-checked the coverage map against the actual controllers:

* Auth API rows used `PATCH /users/{userId}` / `DELETE /users/{userId}` /
  `POST /users/{userId}/avatar` — wrong paths. Real routes are `/me`,
  `/me`, `/me/avatar` (self-scoped, no path parameter). Updated.
* Added missing `GET /users/{userId:guid}` / `GET /users` /
  `GET /users/statistics` (all admin-only) so the role-gated surface is
  visible in the same table.
* `POST /analytics/events` added — Authorize-only, no path parameter.
* Realtime API: added the two missed routes — `POST /notifications/send`
  (self-only, derived from JWT sub claim) and `GET /connections/active`
  (self-only, returns the actor's own connection ids).
* Session row now lists the correct path (`/me/sessions/{tokenId}`) and
  the GET sessions row points at the right test class.

No production code changed — this is doc-only accuracy fixup.
Single-document source of truth for the audit branch state. Covers:

* What landed (grouped by wave, with verifier for each).
* What was deliberately deferred and why (with unblocker per item).
* How to verify each piece (backend / frontend / CI / operational).
* All new invariants and ADRs introduced in the branch.
* Honest statement of known limitations (T2.5 scaffold-only,
  T4.2 configs-without-migrations, T2.6 coverage gap, etc.).
* Recommended next-PR sequencing for the deferred work.

Linked from the master plan; reviewers can open this one file to
understand the full branch shape without spelunking through 25+
commits.
4Keyy and others added 26 commits May 31, 2026 15:14
Applies the Dependabot dotnet/sdk and dotnet/aspnet 9->10 image bumps as a real
framework migration (a net9 app cannot run on the aspnet:10.0 runtime, so a
tag-only change would ship broken containers). .NET 10 is LTS; .NET 9 was STS.

WHAT changed:
- TargetFramework net9.0 -> net10.0 in Directory.Build.props and all 32 .csproj.
- Runtime-aligned Microsoft.* packages 9.0.15 -> 10.0.8 (EF Core, ASP.NET Core,
  Extensions.*, Diagnostics.HealthChecks.*, Mvc.Testing); Npgsql EF provider
  9.0.4 -> 10.0.2.
- Dropped explicit System.Text.Json / System.Text.Encodings.Web references: they
  are framework-provided in net10 and the 10.0.7 pin was a downgrade from the
  framework's 10.0.8 (fixed NU1605). Removed the unused Microsoft.AspNetCore.OpenApi
  reference from all 6 service APIs (the app uses Swashbuckle; that package pulled
  Microsoft.OpenApi v2 and broke Swashbuckle 6.9.0 -> CS7069). NU1510 (package
  pruning advisory) kept as a non-blocking warning, matching the repo's existing
  advisory policy.
- API deprecations promoted to errors under -warnaserror, fixed:
  * ForwardedHeadersOptions.KnownNetworks -> KnownIPNetworks (gateway, ASPDEPR005).
  * Rfc2898DeriveBytes ctor -> static Rfc2898DeriveBytes.Pbkdf2 (Auth PasswordHasher,
    SYSLIB0060). Output is byte-identical (same salt, 100k iterations, SHA-512,
    UTF-8), so existing stored password hashes still verify.
- All 7 service Dockerfiles + the Migrator runtime image -> dotnet 10.0 tags.
- actions/setup-dotnet pinned to 10.0.x across every workflow.
- Docs (README, architecture, codebase-map, deployment, development, getting-started,
  overview, ARCHITECTURE) updated to .NET 10; CHANGELOG entry added.

HOW verified: dotnet build -warnaserror is clean (0 errors) and all 791 backend
tests pass on net10.0 (SDK 10.0.300, runtime 10.0.8).

Security: password hashing parameters and output are unchanged; only the obsolete
API was swapped for its supported static equivalent.
Refs: #67, #69, #71, #72, #73, #74, #75, #76, #77, #78, #79
Applies Dependabot PR #70 (npm-patch-minor group). All patch/minor, no API changes:

- @tanstack/react-query           5.100.9  -> 5.100.14
- @tanstack/react-query-devtools  5.100.9  -> 5.100.14
- date-fns                        4.1.0    -> 4.3.0
- react-hook-form                 7.76.0   -> 7.76.1
- shadcn                          4.7.0    -> 4.8.0
- @vitest/coverage-v8             4.1.5    -> 4.1.7
- vitest                          4.1.5    -> 4.1.7
- postcss                         8.5.14   -> 8.5.15 (dependency + override)

HOW verified: npm install regenerated the lockfile, then lint, type-check,
370 frontend tests, and the production build all pass, with npm audit reporting
0 vulnerabilities at the high level.

Refs: #70
The .NET 9 -> 10 migration swapped the obsolete Rfc2898DeriveBytes constructor
for the static Rfc2898DeriveBytes.Pbkdf2. The two are byte-identical by spec,
but nothing locked the stored hash format, so a future change to the salt/hash
size, iteration count, algorithm, or password encoding could silently invalidate
every stored credential.

Added a golden-vector test: a base64(salt || hash) value computed independently
of HashPassword (PBKDF2-SHA512, 100k iterations, 16-byte salt, 32-byte hash,
UTF-8 password) that VerifyPassword must accept, and reject for a wrong password.
This guarantees hashes stored by older builds still verify and pins the format
against accidental drift. Full PasswordHasher suite (5 tests) passes on net10.0.

Security: regression lock on the credential storage format; no behavior change.
After the net10 migration, running Start-Planora-Local.ps1 on a machine whose
default dotnet is still .NET 9 failed the backend build with NU1202 (net10
packages incompatible with net9). Two changes make the net10 requirement
explicit and self-healing:

- global.json pins the SDK (10.0.100, rollForward latestMajor). A machine without
  a .NET 10 SDK now gets a clear 'install the 10.x SDK' error from any dotnet
  command in the repo instead of a confusing NU1202 mid-build. CI is unaffected:
  setup-dotnet installs the latest 10.0.x, which latestMajor accepts.
- Start-Planora-Local.ps1 resolves a .NET 10 SDK during preflight in priority
  order: the system dotnet if it already exposes a 10.x SDK, else a side-by-side
  SDK under %USERPROFILE%\.dotnet, else a one-time local auto-install via the
  official dotnet-install script. The resolved SDK is prepended to PATH (and
  DOTNET_ROOT) for this process, which the 'dotnet run' child processes inherit.

HOW verified: ran the launcher from a fresh shell whose default dotnet is 9.0.306
(global.json present). Preflight reported 'Using .NET 10 SDK from ...\.dotnet',
the solution built, and all 7 backend services passed their health checks.

The Docker launcher is unaffected — it builds .NET inside the sdk:10.0 image and
never compiles on the host.
Two user-reported bugs.

1) A task with a description showed 'No messages yet' in its Collaboration
   timeline ('ветка'). The timeline is materialised asynchronously: TodoApi
   publishes TaskCreatedIntegrationEvent via its outbox, and Collaboration's
   consumer writes the 'created the task' system comment plus the genesis
   (description) comment. The shared OutboxProcessor and Messaging's
   OutboxProcessorJob polled every 30s, so opening the task within that window
   showed an empty timeline even though the data was on its way. Reproduced
   end-to-end (the comments are created correctly, just late). Reduced the poll
   cadence to 5s — the query is indexed and Take(20)-bounded — so the timeline
   and message delivery are near-live. Re-verified: ~11s after creating a task
   the endpoint returns both comments (totalCount: 2).

2) The navbar avatar sat a few px above the other pill items. Its wrapper was a
   block container and the child <button> defaulted to inline-block, aligning to
   the text baseline. Made the wrapper 'flex items-center' so the button centers
   like the flex-aligned logo and tabs.

HOW verified: backend builds and 792 tests pass on net10.0; frontend lint,
type-check, and production build pass; the timeline fix was confirmed against a
live local stack.
… live author identity

Removes a cross-service data-duplication bug class. The task description was
stored twice — TodoItem.Description (the card) and a 'genesis' comment in the
Collaboration DB (the branch) — synced only at creation via an async event. So
pre-Collaboration tasks had an empty branch, new tasks' descriptions lagged the
outbox cycle, and the two edit paths could diverge.

P1 — description = single source of truth (Todo):
- CheckTaskCommentAccess gRPC now returns the live description + task_created_at.
- GetCommentsQueryHandler synthesises the pinned 'Author's Note' from it on read
  (page 1 only; id = task id, author = owner) instead of reading a stored genesis.
  Instant, always matches the card, present for old tasks.
- TaskCreatedEventConsumer no longer materialises a genesis (only the 'created
  the task' system comment). Removed the POST /genesis endpoint, the
  AddGenesisComment command/handler/validator, and Comment.CreateGenesis /
  UpdateGenesisContent. The read query excludes any legacy genesis rows.
- Frontend edits the description on the task (PUT /todos) via a new
  onSaveDescription path in the edit modal; branch-feed no longer calls genesis
  endpoints. Removed the dead addGenesisComment API client function.

P2 — author identity resolved live:
- Comment.AuthorName was a stored copy of the Auth-owned name that went stale on
  rename. Added AuthService.GetUserProfilesBatch (name + avatar); Collaboration
  resolves comment + genesis author identity live (60 s cache via CachingUserService),
  keeping the stored name only as an offline fallback. IUserService is now
  profile-based (GetUserProfilesAsync) instead of avatar-only.

HOW verified: end-to-end on a live local stack — a task created with a description
returns the Author's Note on an immediate (0s) fetch with the author name resolved
live, and editing the description on the task reflects in the branch. Backend builds
under -warnaserror; 784 backend + 370 frontend tests pass on net10.0. Docs (API,
features, database, architecture) updated to the new model.

Refs: #84
The Create/Update/Delete/GetUserCategories handlers caught all exceptions and
returned ex.Message in the failure Result, surfacing internal/DB detail (e.g.
'database unavailable') to API consumers. They now log the full exception and
return a safe generic message — consistent with the other services, which let
domain exceptions bubble to the sanitising global exception middleware.

Found during the repository-wide audit. Updated the GetUserCategories handler
test to assert the generic message and that the internal detail does not leak.

Security: removes an information-disclosure vector in API error responses.
Audit follow-ups (P1 + P2).

P1 - consumer idempotency (INV-COMM-4) was documented but not implemented.
RabbitMqEventBus delivers at-least-once (nack+requeue on failure), but nothing
deduped - the IdempotentMessageHandler/Inbox machinery was dead code, so a
redelivered or restart-replayed event produced duplicate system comments
(Collaboration) / notifications. The bus now dedups centrally on the stable
@event.Id via IInboxRepository: skip the handler when the id is already recorded,
record it after success. Graceful + defensive - a service with no inbox (or an
inbox error) falls back to the previous behaviour, never worse. Added an
InboxMessages table + repository to Collaboration (PK = event id); Realtime is a
follow-up. Also fixed the pre-existing bug where IdempotentMessageHandler checked
ExistsAsync against the PK while storing the id in MessageId (never matched) - the
new InboxMessage(Guid eventId,...) ctor makes the PK the event id.

P2 - AsNoTracking on CategoryApi reads. BaseRepository already does this for its
generic reads, but the custom CategoryRepository did not. Added it to the
read-only methods (list/get/paged); kept tracking on GetByIdAsync (load-then-update)
and FindAsync (also used for fetch-then-RemoveRange).

HOW verified: build clean under -warnaserror; 784 tests pass on net10.0; live
stack confirmed the inbox records the processed TaskCreatedIntegrationEvent and
exactly one 'created the task' system comment is produced. Docs (database.md,
features.md) updated; the Collaboration inbox table is created by EnsureCreated on
a fresh DB.

Refs: #84
Audit follow-up. LoginHistory and PasswordHistory are append-only tables read
only for display / lockout checks / password-reuse comparison — never mutated —
so their read queries do not need EF change tracking. Added AsNoTracking to
LoginHistoryRepository.GetByUserIdAsync / GetRecentFailedAttemptsAsync and
PasswordHistoryRepository.GetByUserIdAsync.

Left tracking on all token / recovery-code / user reads (RefreshToken, User,
UserRecoveryCode, Friendship): those are load-then-mutate or security decisions
where AsNoTracking would risk breaking the flow, for negligible gain.

Build clean under -warnaserror; Auth + repository-behavior tests pass on net10.0.
Audit follow-up. The auth forms (login, register, forgot/reset password,
verify-email) rendered each <label> as a sibling of its input with no htmlFor or
wrapping, so screen readers did not announce the field name on focus. Now every
field is programmatically labelled: the reusable register InputField wraps its
control in the <label>; the inline forms use htmlFor + matching id. The icon-only
show/hide-password buttons gained an aria-label so they have an accessible name.

Also documented (architecture.md) the deliberate decision NOT to add inbox dedup
to Realtime: it is a stateless SignalR fan-out (no DB write), so a redelivered
event is at most a duplicate transient toast — persistent inbox infrastructure
there would be disproportionate, and the event-bus dedup no-ops gracefully.

Verified: frontend lint, type-check, 370 tests, and the production build pass.
Audit follow-up (final a11y touch-up). The category create/edit form labelled
Name and Description with sibling <label>s; added htmlFor + matching id on the
Input controls so screen readers announce the field. The Icon/Color pickers and
the advanced-search Priority/Category button groups use labels as visual headings
for custom widgets (no single native input), so they are left as-is.

Verified: frontend lint, type-check, and production build pass.
The Completed Tasks page (/tasks/completed) had no way to narrow the archive by category, unlike the active /tasks feed. Replicated the exact same filtering UX here: the "F" hotkey toggles the category filter modal (ignored while typing in inputs), an active-filter chip lists the chosen categories with a one-click clear, and the keyboard hint appears until first use. Selection is read from and written to the same shared localStorage store used by /tasks, so the filter stays consistent when moving between the two pages. Filtering is applied client-side over the loaded archive page, matching the active feed behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… modal

Two issues in the branch/edit modal surfaced when a non-owner opened a public task. First, the priority, due-date and visibility tokens were fully editable for viewers: the lock keyed off canManageViewerCategory, which is true for shared tasks (a viewer may set their own category), so it wrongly granted write access to fields owned by the author. Each token now takes a muted flag and the popovers a readOnly flag; for non-owners the priority/date/visibility tokens render muted and open a greyed, non-interactive read-only preview on click, while the category token stays editable for viewers as designed. The obsolete disabled prop was removed from InlineTokenStrip. Second, the modal previously resized to its content; it is now a fixed size (height 90vh, capped at 880px, overflow hidden) with the branch timeline flex-filling the middle and scrolling internally, so the modal is always the same maximum size whether the branch is empty or full.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… menu

The Author's Note now lives inside the scrollable branch rail and scrolls away naturally. Once it passes out of view a condensed frosted-glass bar (avatar + truncated first line + animated bounce chevron) appears at the top of the feed via Framer Motion AnimatePresence spring. Clicking the bar smooth-scrolls back to the full card and fires a violet attention-pulse CSS animation (genesis_highlight keyframe) so the note is easy to re-read from anywhere in a long branch. The compose + menu now shows two task-action items for all participants: Take into work (flips to Leave task when already in progress) and Complete task (flips to Reopen when done). Both mirror the existing join/leave/complete flow precisely without adding new API surface. The Description attachment item is now hidden for non-owners. An optimistic workOverride flag in the modal makes the in-progress header pill flip instantly on take/leave before the parent refetch propagates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ted filter plate

Resolves four reported issues across the task branch and completed page.

1) fix(collaboration): editing or deleting a branch comment always failed with a 409 'record has been modified by another user'. CommentRepository overrode the base GetByIdAsync solely to add AsNoTracking(); the Comment aggregate uses PostgreSQL xmin as an optimistic-concurrency token (a shadow property captured only on a tracked read), so the no-tracking load dropped it and the UPDATE/soft-delete issued WHERE xmin = 0, matched no rows, and threw DbUpdateConcurrencyException. Removed the override so mutations inherit the tracking base. Author-only edit (Comment.UpdateContent) and the isOwn-gated edit button were already correct.

2) feat(frontend): the branch now updates live without re-opening the modal. With no realtime socket, BranchFeed polls the newest page every 5s (paused while editing) and merges by comment id, plus schedules short catch-up merges (~0.6/1.5/3s) after take/leave/complete so the asynchronously-materialised status system-comment appears within a second or two.

3) feat(frontend): the branch opens pinned to the newest message; load-earlier and description edits preserve scroll position.

4) feat(frontend): /tasks/completed shows the same standalone active-filter chip and Quick Filter plate (with Open Menu button) as /tasks, instead of burying the chip inside the completed-archive stats card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The collapsed pinned-note bar (shown when the branch is scrolled past the full card) used borderRadius '12px 12px 0 0', so its top corners were rounded but the bottom corners were sharp. Made it a floating rounded pill instead: all four corners radius 14, a full subtle border, and a small inset from the feed edges so it reads as a tidy chip rather than a flush header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… instantly

Task-lifecycle system comments (started working / left / completed) took up to ~20s to show in the branch because the OutboxProcessor only polled every 5s, so the producing event waited out a poll tick before publishing. Added event-driven dispatch on top of the existing poll: OutboxSignal is an in-process singleton the OutboxProcessor waits on (with the 5s interval as a timeout/safety net), and OutboxNotifyInterceptor — an EF SaveChangesInterceptor on TodoDbContext — pulses it the moment a transaction that inserted an outbox row commits. The processor now wakes in milliseconds, drains a full batch in a tight loop before idling, and publishes the event; RabbitMQ consumption is already push-based, so the Collaboration system comment lands in well under a second. The signal is resolved optionally, so services that do not register it keep the previous pure-poll behaviour unchanged.

Performance: branch status system-comment latency reduced from ~5-20s to sub-second

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wners

The applied-filter summary on /tasks and /tasks/completed used to render as a separate block that pushed the page layout around. Extracted a shared QuickFilterBar component where the active filter (category chips + count + clear) crossfades into a fixed-height subtitle row inside the same Quick Filter plate, so toggling a filter animates smoothly and never grows or jolts the block. Removed the duplicated inline plates plus the standalone Filter Active chip and the F hint blocks from both pages, and the now-dead hint state. Separately, the read-only date popover shown to a non-owner viewer now omits the Today/Tomorrow/+3 days/Next week quick-pick row entirely (it was previously rendered but disabled), leaving only the read-only calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…us-comment catch

The header pill's Leave action called onClose(), so stopping work closed the entire branch modal. Removed it: leaving work via either the header pill or the compose + menu now keeps the modal open, so the 'left the task' system comment is read in place. Also densified the post-action catch-up merge schedule (250ms..5.6s) so the status system-comment surfaces almost immediately once the signal-driven outbox dispatch publishes it, instead of waiting for the next idle poll. The remaining perceived delay against a still-running pre-change Todo API is the old 5s poll cadence — it requires the Todo service to be restarted on the rebuilt binary to pick up the instant OutboxSignal dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-safe)

Adds -Lan to Start-Planora-Local.ps1 so a teammate on the same Wi-Fi can open the running app with zero setup on their end. The launcher resolves the host's physical LAN IPv4 via Get-NetAdapter -Physical (which excludes VPN virtual adapters, so a split-tunnel VPN's tunnel address is never used), opens a Windows Firewall inbound rule for ports 3000 and 5132 scoped to Profile Any + RemoteAddress LocalSubnet (self-elevating once via UAC if needed), and prints the shareable http://<lan-ip>:3000 URL with VPN guidance. The frontend already binds 0.0.0.0 and getApiBaseUrl() auto-targets the gateway on whichever host served the page, so peers need no configuration.

To make LAN work end-to-end in dev without per-IP wiring: the gateway's dev CORS policy now accepts loopback plus RFC1918 private-LAN origins via a bounded SetIsOriginAllowed predicate (dev policy only; production stays an explicit allow-list), and the frontend's dev CSP connect-src now allows http/https/ws/wss (mirroring the existing dev img-src). Production CORS and CSP are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

Brought the local launcher's self-documentation fully in line with its actual behaviour and refreshed the docs that describe it.

Start-Planora-Local.ps1: rewrote the comment-based help (.SYNOPSIS/.DESCRIPTION/.PARAMETER/.EXAMPLE/.NOTES) to document the complete 10-step startup pipeline, every flag including -Lan, the default ports/URLs, the per-service schema bootstrap (no separate migration step), secret handling, and logs/lifecycle. Expanded the -Help usage with an option table, a URLs block, annotated examples, a pointer to Get-Help -Full, and the Docker-launcher tip.

Docs: corrected the README local-dev section (it wrongly stated the launcher applies schemas through the migrator) and added a flag table + port list; updated docs/OPERATIONS.md and docs/codebase-map.md; and rewrote the docs/configuration.md LAN section to reflect that LAN sharing is now automatic via -Lan plus the dev CORS/CSP allowances and runtime getApiBaseUrl(), with only the email-link Frontend__BaseUrl still requiring the LAN IP. Verified: the script parses cleanly, Get-Help renders, and -Help exits 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tem markers

Reworked the task-branch activity timeline so it looks cleaner and stays unbroken. The rail line was an absolute element anchored to the scroll viewport (top/bottom), so once the feed scrolled it stopped partway; it now lives in a content-height wrapper as a single continuous gradient line that spans every item. All markers share one geometry (RAIL_GUTTER/RAIL_CENTER) and are centered exactly on the line instead of sitting off to the side. System-event markers gained meaning: getSystemEventMeta maps each event to an icon + color (created=Sparkles/violet, started working=Zap/indigo, left=LogOut/red, completed=CheckCircle2/emerald) shown in a tinted ring on the rail, replacing the generic grey dot. Removed the dead getSystemEventColor helper, which only matched Russian phrases and always fell back to grey for the real English event sentences. Updated the CSP middleware test to assert the (intended) relaxed dev connect-src.

Docs: reviewed the last 20 commits and reconciled docs/features.md with the new rail + typed markers; verified the Outbox/Inbox, signal-dispatch, LAN, and launcher docs already match the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… text, greyscale event icons

1) The branch modal closed when leaving the in-progress status from the dashboard: handleLeave called setEditingTodo(null). It now updates the open todo in place (status / isWorking) without closing, so leaving via any path (header pill, + menu, dashboard or active feed) keeps the modal open and the 'left the task' event is read in place.

2) A very long message with no spaces forced a horizontal scrollbar in the branch. Added overflow-wrap: anywhere / word-break: break-word to the message body, the Author's Note, and system-event text so unbreakable words/URLs wrap onto the next line.

3) System-event rail markers are now greyscale and simpler: getSystemEventIcon returns a single grey marker with simple glyphs (created=Plus, started=Play, left=LogOut, completed=Check, other=Circle) instead of the previous per-event colours.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrote README.md into a polished landing page that serves both engineers and a product audience: a centered hero, a 'Why Planora' advantages section, a feature tour, the architecture diagram + service table, and engineering principles. Added an explicit tech-stack section that links every major backend (NuGet) and frontend (npm) dependency with its pinned version, sourced from Directory.Packages.props and frontend/package.json. Expanded the configuration reference into Required + Common-optional tables grounded in .env.example (secret-generation commands included), corrected the Auth service port to 5030 (gRPC 5031), kept the Docker + Windows launcher + LAN-sharing guides, and added the full documentation index. Enabled MD041:false in the markdownlint config so the centered HTML hero header is allowed; the README lints clean with zero errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aserror build

The CI build errors in the referenced log (CS0105 duplicate using in DeleteTodoCommandHandler, and the missing AddPlanoraSwaggerGen/UsePlanoraSwagger in Collaboration's Program.cs) predate the .NET 10 migration and are already resolved in the current net10.0 code — the exact CI command (dotnet restore && dotnet build --no-restore --configuration Release -warnaserror) builds green. This commit removes the only remaining build noise: three PackageReferences the .NET 10 SDK reports as redundant via NU1510 because the shared framework already provides them — Microsoft.Extensions.Caching.Abstractions (BuildingBlocks.Infrastructure), Microsoft.Extensions.Logging.Abstractions (BuildingBlocks.Application, which already references the Microsoft.AspNetCore.App framework), and Microsoft.Extensions.Diagnostics.HealthChecks (Planora.ApiGateway). After removal the whole solution builds with 0 warnings / 0 errors even under a strict restore + -warnaserror build, not just the split CI sequence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two CI jobs were red:

1) markdownlint lints all root *.md, so the historical asterisk (*) list bullets throughout CHANGELOG.md tripped MD004 (272 violations, Expected: dash). Normalised every bullet to '-' via markdownlint-cli2 --fix; the whole tree now lints with 0 errors.

2) Global branch coverage had fallen to 84.75% (below the 85% threshold) because the QuickFilterBar component shipped without tests (5.55% branch). Added frontend/src/test/components/quick-filter-bar.test.tsx exercising the idle vs active states, the single/plural summary, the +N chip overflow, the icon / colour-fallback chip branches, and the open/clear callbacks. Branch coverage is now 85.64% and the frontend suite is 374 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@4Keyy 4Keyy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@4Keyy 4Keyy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@4Keyy 4Keyy merged commit da090fe into main Jun 2, 2026
22 of 32 checks passed
@4Keyy 4Keyy deleted the develop branch June 2, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants