fix/cfe-inputs
needs attentionviewing older commit6e0ff6f · fullPR #287reviewed 2026-07-09 17:17 UTC1H · 5M · 5L · 3I- Purpose
- CFE collect pipeline silently discarded two caller-supplied inputs (service_name + latest_total), deadlocking name-broken RPUs whose stored bills are stale. Confirmed live on Tiendas Neto RPU 144210900626.
- Goal
- Honor caller-supplied service_name and latest_total throughout the collect pipeline; add a confidence gate on stored bill totals; introduce LATEST_TOTAL_REQUIRED as a non-retryable user-actionable error code; add public API parity for these inputs on POST /v1/jobs.
- Sub-goals
- SG-1: decideCacheStatus honors caller latestTotal (wins over stored bill); confidence gate rejects stale stored totals
- SG-2: cacheCheckShell plumbs latestTotal from job.config through to decideCacheStatus
- SG-3: collector.lambda.ts surfaces LATEST_TOTAL_REQUIRED (non-retryable) instead of futile SFN retry when no trusted total is available
- SG-4: POST /v1/jobs accepts optional latest_total; service_name for existing contracts now persists via updateContractShell (API-first parity)
- What
- 15 files, +203/-12 across domains/utility (decisions + shell), services/utility/bills/cfe (error codes + collector), apps/platform (handler, validation, UI error map, i18n), packages/api (schema + types). Additive and backward-compatible: all new fields optional, no wire-type reshape.
- Why
- RPUs with a stale stored bill total and a rejected service name were deadlocked — registration needs a current total to accept the corrected name, but the only way to get a current total is to register. The caller (UI or API) can supply the latest bill total to break the chicken-and-egg.
- Areas
- domains/utility/src/cfe-job+73−3services/utility/bills/cfe+57−8apps/platform/src/api+56−1apps/platform/src/app+9−0apps/platform/src/messages+2−0packages/api/src+2−0
- Blast
- 15 files, +203/-12 lines. CFE collect pipeline (decision + shell + lambda), public-v1 jobs handler, validation, UI error mapping, i18n strings. No schema migrations, no new DB tables, no CDK/infra changes. Backward-compatible.
Findings · 14
correctness3
updateContractShell commits before createJobShell — partial write if job creation fails
apps/platform/src/api/handlers/public-v1/jobs.handler.ts:114
updateContractShell at ~line 114 runs in its own transaction and commits before createJobShell (~line 161). If job creation subsequently fails (e.g. JobAlreadyExists, secret-count guard → 500, DB error), the contract serviceName is permanently updated with no corresponding job. The caller sees a 500 with the service name already changed. FCIS (ADR-016) requires both writes to share one transaction boundary or be folded into a single shell. The service-name correction should be part of the job-creation shell, not a fire-and-forget pre-step.
Double RPU lookup — org-scoped read vs unscoped downstream read may return different contracts
apps/platform/src/api/handlers/public-v1/jobs.handler.ts:145
findByRpuAndOrgId (org-scoped, new, ~line 108) and findByContractNumber (unscoped, existing, ~line 145) both query utility_contracts for the same RPU in the same request. If the same RPU appears in multiple orgs (shared-resource / ownership-transfer scenario allowed by the domain model), findByContractNumber may return a different org's contract, causing the wrong existingContractServiceName to flow into runPostCreateChoreography. Consolidate: cache the findByRpuAndOrgId result and pass it to runPostCreateChoreography instead of the unscoped query.
LATEST_TOTAL_REQUIRED not in USER_INPUT_ERRORS — future CfeError throw would trigger SFN retry instead of terminal failure
services/utility/bills/cfe/src/handlers/collector.lambda.ts
USER_INPUT_ERRORS contains RPU_TOTAL_MISMATCH but not LATEST_TOTAL_REQUIRED. Both are non-retryable. Today LATEST_TOTAL_REQUIRED is only returned via the explicit fail() path, not thrown as a CfeError, so there is no runtime impact. But a future code path that throws CfeError(LATEST_TOTAL_REQUIRED) would not be caught by the USER_INPUT_ERRORS guard and would trigger a futile SFN retry. Defensive fix: add LATEST_TOTAL_REQUIRED to USER_INPUT_ERRORS.
security3
TOCTOU between findByRpuAndOrgId read and updateContractShell write → opaque 500 on version conflict
apps/platform/src/api/handlers/public-v1/jobs.handler.ts
Between the findByRpuAndOrgId read (~line 108) and the updateContractShell write (~line 114), a concurrent request can bump the contract version. The optimistic-lock in updateContractShell will catch this and return a VersionConflict, but the handler maps that to InternalError → HTTP 500, which is opaque to the caller. No cross-org leak (the lookup is correctly org-scoped), but the error surface is misleading. Consider returning 409 Conflict for VersionConflict, or folding both operations into a single atomic shell.
latest_total has no upper bound — Number.MAX_VALUE passes validation
apps/platform/src/api/utils/public-v1-validation.ts:398
isFinite(Number.MAX_VALUE) is true; n >= 0 is true. A caller can submit 1.7e308 as the bill total. CFE's form would reject it (RPU_TOTAL_MISMATCH), not a security risk. A practical upper bound (e.g. 10_000_000 MXN) would produce a cleaner 400 and guard against absurd inputs.
Authorization model is correctly org-scoped — no cross-org escalation path
apps/platform/src/api/handlers/public-v1/jobs.handler.ts
findByRpuAndOrgId scopes the query to the caller's orgId via parameterized JOIN through siteUtilityContracts → sites WHERE org_id = orgId. The returned publicId can only belong to a contract in the caller's org. updateContractShell does not re-verify orgId, but the auth gate is the lookup itself. Actor is integrationActor(orgId) — correct org-as-actor pattern. No cross-org escalation found.
conventions2
Handler calls query directly (findByRpuAndOrgId) without admin-required annotation
apps/platform/src/api/handlers/public-v1/jobs.handler.ts:108
ADR-016 / api-patterns.md: handlers never call queries directly — always through shells. The handler already annotates its other direct queries with '// admin-required: pre-shell context lookup' to document the exception. This new call lacks that annotation and also triggers a mutation (updateContractShell), going beyond the read-only exception pattern. Canonical fix: an updateContractServiceNameIfChanged(db, rpu, orgId, serviceName, actor) shell that does the read+decide+write atomically.
LATEST_TOTAL_REQUIRED not surfaced on the public API error surface
apps/platform/src/app/[locale]/(dashboard)/bills/descargas/_lib/job-errors.ts
LATEST_TOTAL_REQUIRED is mapped in the platform UI (job-errors.ts + i18n strings) but has no entry in PublicApiErrorCode or mapPublicJobError. A public API consumer polling a job that terminates with LATEST_TOTAL_REQUIRED would see a generic error rather than the actionable code. Whether to expose it on the public API surface is a product decision — if the intent is to guide API callers to re-POST with latest_total, adding the code to the public error schema would close the loop.
tests4
No test for the trustworthiness boundary (bill sitting at the último/penúltimo edge)
domains/utility/src/cfe-job/__tests__/cfe-job.decisions.test.ts
The confidence gate uses: periodEnd + 2×periodLength > today. Tests use staleBill (clearly old) and currentBill (clearly fresh). There is no test for a bill sitting exactly at the penúltimo boundary — one period-length past periodEnd. An off-by-one here means the wrong total is passed to CFE registration, burning an attempt on a stale number.
collector.lambda.ts LATEST_TOTAL_REQUIRED terminal path has no behavioral test
services/utility/bills/cfe/__tests__/unit/domain/errors.test.ts
The new needsLatestTotal → return fail(CfeErrorCode.LATEST_TOTAL_REQUIRED) branch at collector.lambda.ts ~line 197 is not exercised by any handler-level test. The errors.test.ts only verifies the code is in the enum. A test that mocks discoverMiEspacio to return { type: 'recoverable', needsLatestTotal: true } and asserts the collector returns LATEST_TOTAL_REQUIRED would confirm the wiring.
latest_total parsing branches not covered in public-v1-validation tests
apps/platform/src/api/utils/public-v1-validation.ts:391
parseJobCreateBody tests cover rpu, type, period_count, monitor, force_refresh but not latest_total. The three parsing branches (number passthrough, string coercion, rejection) and the boundary cases (n=0, n<0, 'abc', '38355.50') are unexercised. The logic is simple but follows the pattern of testing each parsed field.
cacheCheckShell plumbing (job.config.latestTotal → decideCacheStatus) has no integration test
domains/utility/src/cfe-job/cfe-job.shells.ts:874
The shell change connects the public API input to the pure decision. No integration test exercises cacheCheckShell with a non-null job.config.latestTotal. A bug in the field name or JSON column path would silently revert to the derived total without any test catching it.
improvement2
Trustworthiness formula should name the 2× multiplier (último/penúltimo CFE rule)
domains/utility/src/cfe-job/cfe-job.decisions.ts:956
The expression periodEnd + 2*(periodEnd - periodStart) > today encodes 'último o penúltimo' — CFE accepts either the most recent or the second-most-recent bill total. The 2× factor is non-obvious without that domain knowledge. Extract named variables: const periodLengthMs = periodEnd - periodStart; const twoPeriodsAfterEnd = periodEnd + 2 * periodLengthMs; and add a comment naming the CFE rule. The existing comment block describes WHAT the gate does but not WHY the multiplier is 2.
needsLatestTotal boolean flag could be a cleaner union variant
services/utility/bills/cfe/src/handlers/collector.lambda.ts:467
MiDiscoveryResult's recoverable variant carries optional code? and needsLatestTotal? to guide caller branching. This creates implicit sub-variants requiring ordered if/if guards. A dedicated { type: 'needs_latest_total'; message } variant would be type-safe and self-documenting. At minimum, remove the ? optionality from code/needsLatestTotal on the TOTAL_MISMATCH path (narrow via a union or overload) so TypeScript enforces the invariant.