A version-controlled record of the enterprise-readiness audit and the work that is still open, so it can be picked up in any future session or by any maintainer without relying on machine-local notes. Update this file whenever a deferred item is done or a new one is deferred.
Last updated: 2026-07-30.
A full-project audit (2026-07-30) mapped findings across security, correctness, performance, enterprise-readiness and test coverage, and shipped everything actionable.
- WP1 keyset cursor encoded type-faithfully —
Convert.ToStringcorruptedDateTime(dropped sub-second → duplicate rows) andbyte[]/rowversion(→"System.Byte[]") keys. - WP2 blocked path traversal in the Local destination via run-time
{param}values. - WP3 a failed upload now fails the run instead of reporting success.
- WP4 run-time parameters override same-named static ones;
@namematched on an identifier boundary. - WP5 health/sync-run error responses scrubbed of DB host/topology (logged server-side instead).
- WP6 opt-in per-attempt read timeout (
RetryOptions.Timeout). - WP7
ILoggerBeginScope{JobId,ReportName}+ failure logging (abort/retry/upload). - WP8 multi-artifact zip downloads stream via a
0600temp file, not aMemoryStream. - WP9
AddNeoReportsStartupValidation()compiles config reports at boot (fail fast). - WP10 behavioural tests for the Total/Rate/consecutive-reset abort thresholds.
- #1 Streaming XLSX (#226) — both writers rebuilt on
DocumentFormat.OpenXmlSAX + a hand-assembledZipArchive(Create mode) written straight to the output;System.IO.Packagingdeliberately bypassed (itsZipPackagebuffers each part in RAM). Constant memory proven by measurement (~1.5 MB flat writing 100k→2.4M rows); a regression test enforces it. ClosedXML removed from both writer packages. Resolves D14. Only behavioural change: dropped column auto-fit (can't stream). - #2 Auth startup warning (#227) —
MapNeoReports()warns when neither host auth norRequireAuthorizationis configured. Default unchanged (D20: auth inherits from the host). - #3 Removed dead ABI exceptions (#228) —
BatchFailedException,SourceFailedException,ThresholdExceededExceptionwere never thrown; removed (maintainer decision). Breaking, next major — seeCHANGELOG.md→ Unreleased → Removed. - #4 Retry default (#229) — parameterless
ReportBuilder<T>.Retry()(3 attempts, exponential, jitter); default stays off; docs flag the recommendation. - #5 Whole-job deadline + error scrub (#230) —
ReportBuilder<T>.Deadline(TimeSpan)bounds the whole run; and the persisted run error /RunFailed&Retryevents / worker catch now carry a driver exception's type name not its message (NeoReports' own curated messages are kept). - #6 Drain-loop caps (#231) — the 17 HTTP-family test drain loops now fail fast past 1000 pages (guard against the ~22 GB-testhost runaway shape).
- Remove the never-thrown ABI exceptions — already done in #228, tagged for the next major.
- CA1068:
CancellationTokennot last in three public health signatures — done: the token was moved to last (afterpingSql/content, both defaulted) inAdoSourceHealth.PingAsync,AdoSourceHealth.CheckConnectionStringAsyncandHttpHealthProbe.SendAsync, and all callers updated. Source-breaking for positional callers, so tagged next-major inCHANGELOG.md(Changed → breaking, public API) alongside the #228 removal.
- Fail (not skip) the Testcontainers integration tests when Docker is absent in CI. — done:
the five container
ServerFixtures now swallow a start failure only through an exception filter,catch (Exception) when (DockerGate.SkipWhenUnavailable).DockerGate(a file linked into all five integration projects,tests/Shared/DockerGate.cs) skips unlessNEOREPORTS_REQUIRE_DOCKER=1, which the CI/Sonar/release workflows set — so a broken image or Docker outage on the runner hard-fails instead of silently degrading to "all skipped", while localdotnet test(var unset) keeps skipping. Covered byDockerGateTests.
- The ~30 open repository code-scanning alerts predating the audit were cleared: 21 fixed (auto-closed
on the master CodeQL run) and 11 dismissed as false-positive / deliberate. Repo-level open count is
now 0. (Resolving a PR thread does not close a repo-level alert, so each was handled via
PATCH /repos/thiagoluga/NeoReports/code-scanning/alerts/{number}or a real fix.)
- The three Pro packages are enforced (Q1/Q2) and an issuing tool exists (Q3a), but publishing is
blocked on rotating the embedded placeholder public key: the maintainer must run the license
tool's
keygenlocally, store the private half in a vault, and commit only the new public key. The key generated during development is compromised (it appeared in a chat transcript) and must never be the production signing key.
A focused review of the keyset/cursor and resilience/failure paths surfaced these. One is fixed; the rest are recorded with a concrete repro because each needs a design decision or a fix that isn't locally verifiable.
- Oracle temporal keyset cursor crashed on page 2 — FIXED (PR pending/merged). The Pro
QueryBuilder'sSqlDialect.OracleCastemitted no cast forDate/DateTime/Timestampkeys, so the ISO-8601 cursor was implicit-converted by Oracle'sNLS_DATE_FORMAT→ORA-01858on the second page. Now casts with the codec's documentedTO_TIMESTAMP(:cursor, 'YYYY-MM-DD"T"HH24:MI:SS.FF7'). Unit test asserts the emitted SQL; an Oracle integration test (A_timestamp_keyset_cursor_round_trips_across_pages) empirically validates the model. - Postgres/Redshift
timestamptzkeyset boundary can shift under a non-UTC session.PostgresCastcasts the cursor to::timestamp(no zone). For atimestamptzkey that discards the offset and re-interprets it in the sessionTimeZone, silently skipping or duplicating a window of rows. Naïvely switching to::timestamptzjust moves the bug to plaintimestampkeys —ColumnTypedoesn't distinguish the two. Needs the catalog to carry the with/without-time-zone distinction (a design change). Same class as the OracleTIMESTAMP WITH TIME ZONEsub-case (the FF7 model has noTZH:TZM). Workaround today: key on a plaintimestamp/UTC column, or run the session in UTC. - QueryBuilder allows a non-unique keyset key. Single-column keyset with strict
>requires a unique, monotonic key; if the user picks a non-unique column, the tail of a duplicate group that straddles a page boundary is dropped. Not statically detectable (no PK/unique metadata in the model). Consider a builder warning when the key isn't a PK, or documenting the requirement more loudly. Needs a product decision. - Multi-output batch writes are not atomic (contradicts D11 batch-atomicity).
ReportRunnerwrites each batch to every output in a sequential loop with no per-batch buffer/transaction; if output k throws after output k-1 already appended, aSkipBatchAndLogbatch is "skipped" yet physically present in the earlier output's file, and an abort leaves a torn batch across outputs. Delivered files for "the same report" can diverge and the stats won't match the bytes. A real fix (buffer each batch per output, commit all-or-nothing) is a write-path change that should be a recorded decision. Only exercised with ≥2 outputs and a real writer (the single-outputFakeWriterFactorytests don't hit it). FailureRatethreshold has no minimum-sample guard. The ratio istotalFailures / batchesSoFarwith the current failing batch already counted, so an early failure degenerates: the first failing batch yields1/kand anyFailureRatebelow that aborts immediately (e.g.FailureRate: 0.5aborts if either of the first two batches fails). The arithmetic matches the documented definition, so this is a semantics choice — consider only evaluating the ratio after N batches if the intent is "fraction over a large run." Needs a decision.
A second hunt over the CSV/XLSX writers (output correctness) found these. The file-breaking / total-loss ones are fixed (PR pending/merged); two representation tradeoffs are recorded.
- XLSX cell edge cases — FIXED. In
XlsxCells.BuildCell(shared by the MIT and Pro writers): (a) an XML-illegal C0 control char in a string threw and aborted the whole file — now stripped; (b)NaN/Infinityproduced an invalid number cell Excel refused to open — now emitted as text; (c)byte[]stringified to"System.Byte[]"— now Base64 (also fixed in the CSV writer); (d)TimeOnlyused the machine's current culture — now an invariant round-trip string. CSV's RFC-4180 escaping and invariant number/date formatting were verified correct. - XLSX loses precision for 64-bit ints / high-precision decimals (deferred — representation
tradeoff).
long/ulong/decimalare funneled throughConvert.ToDouble, so a value beyond 2^53 (abigintkey) or adecimalpast double's ~15–17 digits is silently rounded. Excel stores numbers as IEEE-754 doubles, so preserving the exact value requires writing it as text — which loses Excel's numeric sorting/formatting. That number-vs-text tradeoff is a product decision, so it is left as-is with the value rounded (today's behaviour) pending a call. - XLSX
DateTimeOffsetdrops the offset and pre-1900 dates misrender (deferred — narrow).dtois stored viadto.DateTime(offset discarded) andDateTime.ToOADate()can't represent dates before 1899-12-30. Both are inherent to the OADate/no-tz cell model; revisit only if a real report needs sub-day-offset fidelity or pre-1900 dates.
A third hunt covered the destination (upload) and job/scheduling layers. Two fixes shipped; the rest need a decision.
- Job/schedule robustness — FIXED. (a) The worker's unfiltered
catch (OperationCanceledException)recorded anHttpClient.Timeout(TaskCanceledException, foreign token) as "Cancelled." and did not rethrow, so a genuine failure looked operator-initiated and Hangfire saw success — now filtered on the run's own token. Because a deadline also cancels through a linked token (the run's own token is not cancelled either), the runner now reports it asReportDeadlineExceededException— anOperationCanceledExceptionsubclass — so the worker keeps recording a deadline asCancelled(now with a reason saying so) while everything else becomesFailed. Both directions are covered by regression tests, each verified to fail without its fix. (b)FileScheduleOverrideStoreandFileReportConfigStorestaged every save through a fixed{name}.json.tmp, so concurrent saves for one name collided — both now use the sharedAtomicFileWrite(unique temp name, deleted if the save fails). Verified correct in the same pass:InMemoryJobStorethread-safety,EffectiveSchedule.Resolve(override/tombstone/fallback),ScheduleReconciliationHostedService(add/update/remove, no duplicate),CronValidation(UTC, Cronos, no off-by-one), and the whole Local/S3 upload path for stream position, disposal, failure mapping and atomicity. ⚠️ S3 key templating does not guard caller-controlled parameters (highest open security item).LocalDestinationpassesLocalPathSegment.EnsureSafetoPathTemplate.Expand(the WP2 guard);S3Destinationpasses none — deliberately, since/is a legitimate key separator. But that reasoning covers the author's template, not{param}values, which come from the run request body. With a key template likereports/{tenant}/{name}.{ext}, a caller postingtenant = "other"(or a value containing/) steers the object into another prefix — a cross-tenant write where a shared bucket relies on prefix isolation. Not an OS traversal (S3 keys are literal,..is not collapsed) and harmless in a single-tenant bucket. The fix is a decision because the safe version (reject/in substituted values while keeping it in template literals) would break anyone intentionally passing a hierarchy fragment as a parameter. Recommended: adopt that guard and note it as breaking, or document that S3 key templates must not interpolate untrusted parameters.- Upload swallows
OperationCanceledExceptioninto aFailresult (deferred — semantics). Both destinations'catch (Exception)also catch a cancellation, so a deadline firing mid-upload is reported as a destination error rather than a cancellation. The run still ends Failed, so this is attribution accuracy; rethrowing would also change multi-destination behaviour (today the loop continues and reports per-destination results). - Hangfire applies its default 10-attempt
AutomaticRetry(deferred — decision). The invoker carries no[AutomaticRetry(Attempts = 0)]and nothing configuresGlobalJobFilters, so a deterministically failing job (bad credentials, unreachable source) is re-run up to 10× — re-reading the whole dataset each time and flapping the stored status Failed→Running→Failed. Output integrity holds (temp-dir staging is idempotent), but it contradicts the "a job is atomic, one attempt" model (rule 6). Decide whether NeoReports should pinAttempts = 0or leave retries to the host. InMemoryJobScheduler.RegisterRecurringAsyncremove-then-add isn't atomic (deferred — narrow). Two concurrent registrations for one report can both start a loop; the loser is overwritten in the dictionary without its CTS being cancelled, so it keeps firing untracked for the process lifetime. Reachable only by racing two schedule updates (or one against startup reconciliation); the Hangfire path is safe (AddOrUpdateis idempotent). A lock around register/remove would fix it.- The in-memory recurring loop has no catch-all (deferred — narrow). Any non-cancellation throw faults the fire-and-forget loop and the schedule silently stops for the process lifetime, unlogged.
CompletedPartialsurfaces as aCompletedjob (by design, flagged). A run that skipped batches maps toReportJobStatus.Completed; the skip is visible only inStats.SkippedBatches. There is noPartialjob status. Worth confirming this is still the intent, since silent partial data reads as a green job.
Two more hunts (the nine HTTP-family source packages; the AspNetCore endpoint layer) produced these.
The unambiguous ones shipped — the engine's reserved cursor/pageSize bind names, and run-time
parameters arriving as JsonElement. What is left changes behaviour or semantics, so it is recorded
rather than decided:
- The page loop has no safety net.
ReportRunner's loop iswhile(true)driven purely byHasMore: no page cap, no "the cursor did not change" guard, no "zero rows but still more" guard. Every item below is only as dangerous as that. A generic guard here would bound all of them at once and is probably the highest-leverage single change — but it changes termination semantics for every source, so it needs a call. records.Count == pageSizeends a run early when the server caps the page. OData'sSkipstrategy (ODataBatchSource) and the HTTP source'sPage/Offsetstrategies infer "more data" from a full page. Services that clamp$top/limitbelow the engine's 1000 default (Dynamics, SAP Gateway, Business Central; many REST APIs silently reduce an over-maxlimit) return a short first page → the run stops there and reportsCompletedwith partial data.Skipis opt-in andNextLinkis the default, which limits blast radius. A fix means either honouring@odata.nextLinkinSkipmode too, or paging until a page returns zero rows — both change termination semantics.- Elasticsearch treats a partially-failed search as a short page. ES returns HTTP 200 with
timed_out: true/_shards.failed > 0and fewer hits; neither field is inspected, so the report silently ends early asCompleted. GraphQL already fails loudly on 200-with-errors; the ES equivalent would be consistent, but it turns today's silent success into a hard failure. - HTTP
Cursorstrategy has no non-advancing-cursor guard. If an API echoes the requested cursor on the last page (Facebook Graph'spaging.cursors.after, among others),hasMorestays true with an identical token → the same request repeats forever. GraphQL (D63) and Elasticsearch both guard this explicitly; the generic HTTP source — the most exposed, since the cursor path is author-configured — does not. Link-header parsing breaks on a comma inside the URL.HttpBatchSourcesplits the header on,unconditionally, but RFC 8288 permits commas in the target URI and in quoted parameters. A base URL like?fields=id,nameechoed into the next-page link makesrel="next"unparseable → paging stops silently after page 1.- A relative next-page URL throws. Both
HttpBatchSourceandODataBatchSourcecallnew Uri(nextUrl)(absolute-only) on a server-supplied link; RFC 8288 and OData both permit a relative one. Fails loudly (UriFormatException, opaque message) rather than silently. Salesforce is the only package that resolves this correctly. FIXED — the shared helper now concatenates under the base path (absoluteHttpHealthProbe.CombineUrlstill has the relative-Uribug — the 5th sighting of this class.http(s)paths still used as given), matching the four leaf packages; covered byHttpHealthProbeUrlTests, verified to fail against the old implementation. Original description:new Uri(baseUri, path)drops the base's last path segment when it has no trailing slash, and a leading/resets to the host root. Elasticsearch (D64), HubSpot, Airtable and Salesforce each independently rewrote away from it — with comments naming it — but the shared helper still does it, andHttpSourceHealthCheck+ODataSourceHealthCheckstill call it: a health check can probe the wrong URL and report a healthy source unhealthy (or vice-versa). Fixing the shared helper by concatenation, as the four leaf packages already do, is the cheapest win in this list.- Google Sheets: three data-fidelity bugs. (a) is FIXED — header cells are now decoded exactly
like data cells, so a numeric/boolean header indexes its column (covered by a test verified to fail
against the old code). (b) and (c) remain. Original description: (a) header cells that aren't JSON strings are dropped,
so a year-numbered column (
2024) never binds and every row's value for it is null/zero — the requests useUNFORMATTED_VALUE, which returns numeric headers as JSON numbers, while the data path already decodes all kinds; (b) a header range that comes back withoutvaluescaches an empty index, so a misconfiguredheaderRowproduces N rows of all-nulls reported as success instead of failing loudly; (c) an interior blank row is returned as[]and materialized as a phantom all-default row. (a) is the clearest and most contained. - HubSpot and Airtable default to a page size their API rejects. Both send the engine's 1000
default as
limit/pageSize, but both providers cap at 100 (recorded inDECISIONS.md), so a source built with defaults fails its very first request until the author calls.PageSize(100). Loud, but the default configuration is non-functional. Clamping vs. failing with a clear message is a product call. API:FIXED (routes throughPOST /reports/{name}/previewis the one data-plane endpoint that doesn't scrub driver exceptions.SchemaProblemlike its siblings). Original description: It catches onlyConfigurationException, so a bad filter value surfaces the rawSqlException/PostgresException(host, port, database) as a 500 — its siblings all route throughSchemaProblem. Should be a 400 (bad filter) or the scrubbed 502 the others return.- API: schedule/preview write paths reach a name-validating store without the guard. The
preview half is FIXED (a name no config store can hold is now treated as not-dynamic, so the
endpoint returns its intended 400).
SetScheduleAsync/ClearScheduleAsyncstill 500 — original description:SetScheduleAsync,ClearScheduleAsyncandReportPreviewRunner's config-store probe pass the report name straight through, so a legitimate code-first report whose name is outside^[a-zA-Z][a-zA-Z0-9_-]{0,99}$(e.g.sales.daily) gets a 500 fromArgumentException. The read paths already guard, which shows it is an oversight. - API:
GET /jobs/{id}returns raw destination-exception text (server paths, S3 bucket + key, AWS error strings). The read- and write-failure paths in the same method scrub; the upload path does not — and the sync endpoint deliberately suppresses the very same string, so one route hides what the other returns verbatim. API: sync mode's single-output guard ignores sectioned outputs.FIXED —OutputCountandOutputFormatsnow include sectioned outputs, so the guard rejects the mixed report it always claimed to and the listing reports every format. Original description:OutputCountcounts onlyOutputs, so a report with one plain and one sectioned output passes the guard, the runner writes two artifacts, and the caller silently receives one — which one decided by directory-enumeration order. The same undercount makesGET /reportsunder-report a sectioned report's formats.- API:
Location/Content-Locationheaders hardcode/api, ignoringMapNeoReports's configurable prefix — underMapNeoReports("/v2")the 202'sLocationis a 404 for any client that follows it. - Array/object run parameters still diverge by backend. Complex parameter values are documented
out of scope for v1, but nothing rejects them: sync/in-memory hand the source a
JsonElement(the very thing an ADO provider can't bind) while Hangfire hands it the raw JSON text. Either reject them at the boundary with a 400, or agree one representation — the current silence produces a driver error at read time. POST/PUT /sourcesproperty bags are not normalized.SourceRequest.Propertiesis the same caller-suppliedobject?bag as run parameters.FileSourceRegistryStorelaunders it on write and read, butInMemorySourceRegistryStorestores it as-is — so withAddInMemorySourceRegistry()a source created over HTTP fails later with "requires a non-empty 'connectionString' property" because the value is aJsonElement, not astring. Pre-existing; the same one-line normalization the run endpoint now does would close it.
Verified correct in the same pass (worth not re-auditing): artifact download path handling (no
caller-supplied filename reaches disk; no zip-slip; 0600 temp), job-id handling, SQL-injection
surface via run parameters, preview filter validation, list-endpoint pagination clamping, cursor
round-tripping in all nine source packages (OpaqueCursor is Base64-JSON, lossless), GraphQL /
HubSpot / Airtable / Salesforce termination logic, and the Elasticsearch "capture the last sort"
loop.
Machine-local agent memory under the session's memory/ directory holds the deeper running notes
(enterprise-audit-2026-07.md, roadmap-state.md, the SonarCloud/CI gotchas). This file is the
portable, always-available subset. Keep it in sync when the backlog changes.