Story ING-005: Startup recovery + ARTCI feature-flag prod gate + structured logging¶
Module: ingestion
Slice: Tier 1 sub-slice ING-001-d (architecture §11)
Side: [BACKEND]
Version target: V0.0.0
Priority: 5
Depends on: ING-004 (ingestion_import table + status FSM exist; audit emission for ING_PARSE_FAILED wired)
Can develop concurrently with: ING-006 (integration-test pack — read-only on this slice's outputs)
Merge order: After ING-004; before ING-006
Estimated complexity: S
PRD User Stories: Derived from V0 PRD §6.4 (ARTCI feature-flag prod gate) and architecture §5.1 stage 4 (crash recovery)
Wireframe: N/A — backend only
Objective¶
Make the v0.0.0 cron production-safe: the IngestionStartupRecovery job clears stuck PICKED_UP rows orphaned by a crash mid-parse (architecture §5.1 stage 4 + §6.4), and the feature.artci.phone-auth.granted feature flag short-circuits the cron on prod profile while ARTCI authorisation is pending (architecture §0 + ADR-08). Structured logging makes the gated-skip visible to ops without burying the warning.
Backend Scope¶
Entities¶
None new. Reads + UPDATEs existing ingestion_import rows.
Migrations¶
None.
Service Layer¶
IngestionStartupRecovery(ApplicationRunner, runs ONCE at boot before@Scheduledactivates):- For every active tenant:
SELECT id, file_minio_inbox_key, filename FROM ingestion_import WHERE tenant_id=? AND status='PICKED_UP' AND picked_up_at < now() - INTERVAL '${INGESTION_STARTUP_RECOVERY_STALE_THRESHOLD_SECONDS:300} seconds'. - For each match: UPDATE
status='PARSE_FAILED',parse_error_detail = '{"reason":"recovered_from_crash"}'::jsonb,finished_at = now(). EmitING_PARSE_FAILEDwith the same reason. If the inbox file is still present, MinIO COPY+DELETE inbox→/<tid>/rejected/<ts>__<filename>with sidecar JSON{"reason":"ingestion crashed during parse, please re-drop the file"}. - Runs synchronously at boot, sequential per tenant, bounded by a 30 s total budget — if it takes longer the application still starts (recovery resumes next boot).
- WARN-level log per recovered row:
[ING-RECOVERY] tenant=<tid> import=<id> filename=<f> reason=recovered_from_crash. ArtciProdGate(@Component): wrapsIngestionService.tick(). ReadsEnvironment.acceptsProfiles(Profiles.of("prod"))ANDfeature.artci.phone-auth.granted. Logic:- On non-prod profiles (
dev,test,staging,local) → always proceed regardless of flag. - On
prodAND flag=false → log WARN[LEGAL-REVIEW] ingestion cron skipped — ARTCI phone-auth pending. files in inbox NOT processed., return without calling MinIO LIST. No tenant iteration, no DB read. - On
prodAND flag=true → proceed normally. - On startup (regardless of profile) when flag is false, log a single banner WARN
[LEGAL-REVIEW] feature.artci.phone-auth.granted=false → ingestion cron disabled in prod profile. - Structured logging hook: every cron iteration boundary emits a single-line MDC-enriched INFO with
{tenantId, objectsFound, durationMs, status}(logs are aggregated in CloudWatch / Loki perdocs/standards/java-spring-guidelines.md§ observability).
API Endpoints¶
None new.
Validation Rules¶
INGESTION_STARTUP_RECOVERY_STALE_THRESHOLD_SECONDSminimum 60 s (safeguard against marking healthy in-flight rows as failed). Default 300 (architecture §14.2).- Feature flag value must be a parseable boolean (
true/false); any other value treated asfalsewith a WARN log (fail-safe to closed).
Multi-Tenant Considerations¶
Startup recovery iterates TenantConfigCache.listActiveTenants() same as the cron. tenant_id scoping enforced on every SELECT and UPDATE. Audit emissions use actor_type=SYSTEM, actor_id=ingestion-cron (same as steady-state). Both DB profiles supported — recovery queries route through the same datasource resolver.
Audit & Logging¶
ING_PARSE_FAILEDemitted once per recovered row withparse_error_detail.reason="recovered_from_crash"(catalog already extended by OQ-X10 in ING-004's prerequisite).- WARN logs on every gated skip + every startup-recovery hit.
- No new event types added.
Frontend Scope¶
N/A — backend only.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | N/A — backend only | — |
| Empty | N/A — backend only | — |
| Error | The sidecar JSON written by recovery is operator-facing | {"reason":"ingestion crashed during parse, please re-drop the file"} (English; matches existing sidecar shape from ING-004) |
| Offline / low network | N/A — backend only | — |
| Success | N/A — backend only | — |
Responsive Behavior¶
N/A.
Interactions¶
N/A.
Acceptance Criteria¶
Architecture §11 ING-001 binding ACs 4–5:
AC-001: Prod profile + flag=false ⇒ cron tick is a no-op
Given the application running with `spring.profiles.active=prod`
And `feature.artci.phone-auth.granted=false`
And a file in `/<tenantA>/inbox/clean-5-rows.csv`
When `IngestionService.tick()` fires
Then no MinIO LIST is performed
And no `ingestion_import` row is created
And the file remains in `/<tenantA>/inbox/`
And the WARN log line `[LEGAL-REVIEW] ingestion cron skipped — ARTCI phone-auth pending. files in inbox NOT processed.` is emitted.
AC-002: Non-prod profiles ignore the feature flag
Given the application running with `spring.profiles.active=dev` (or `test`, `staging`, `local`)
And `feature.artci.phone-auth.granted=false`
When `IngestionService.tick()` fires
Then the cron proceeds normally (MinIO LIST + downstream FSM)
And no `[LEGAL-REVIEW] cron skipped` log line is emitted.
AC-003: Stuck PICKED_UP row >5 min old is recovered at boot
Given an `ingestion_import` row inserted with `status='PICKED_UP'` and `picked_up_at = now() - 10 minutes` for tenant A
And the corresponding file `stale.csv` still present in `/<tenantA>/inbox/`
When the application boots and `IngestionStartupRecovery` runs once
Then the row is updated to `status='PARSE_FAILED'` with `parse_error_detail.reason="recovered_from_crash"` and `finished_at` set
And the file is moved to `/<tenantA>/rejected/<ts>__stale.csv` with sidecar JSON `{"reason":"ingestion crashed during parse, please re-drop the file"}`
And `ING_PARSE_FAILED` is present in `audit_log` for that `import_id`.
Compliance Rules¶
- ARTCI loi 2013-450 Art. 14 (consentement préalable au démarchage) —
[LEGAL-REVIEW] PRÉLIMINAIREper V0 PRD §6.4. The flag default-false posture is the production-time mitigation. WARN log at startup ensures auditability. - Architecture ADR-08: feature flag default
falseonprodprofile; ignored elsewhere. - Critical rule #3: every recovered row emits
ING_PARSE_FAILED— catalog admits the event type.
Standards & Conventions¶
docs/standards/compliance-discipline.md—[LEGAL-REVIEW]warning posture.docs/standards/critical-rules.md(#3 audit catalog).docs/standards/java-spring-guidelines.md§ApplicationRunner, § MDC logging.docs/standards/multi-tenant-model.md§ cron context.
Testing Requirements¶
Unit Tests¶
ArtciProdGateTest— matrix{prod|dev|staging|test|local} × {flag=true|false|garbage|absent}; asserts skip vs proceed and exact log line.IngestionStartupRecoveryTest(mocked DB + MinIO) — threshold honored; younger PICKED_UP rows untouched.
Integration Tests (TestContainers)¶
IngestionStartupRecoveryIT— AC-003 end-to-end with real Postgres + MinIO.IngestionProdFlagClosedIT— AC-001 setup; assert zero MinIO requests via spy onIngestionInboxClient.IngestionDevFlagIgnoredIT— AC-002 assertion that the cron proceeds.
What QA Will Validate¶
- Boot with
SPRING_PROFILES_ACTIVE=prod FEATURE_ARTCI_PHONE_AUTH_GRANTED=false; drop a file; tail logs; confirm the[LEGAL-REVIEW] cron skippedline appears andmc ls .../inbox/still shows the file. - Insert a stale PICKED_UP row via psql; restart; confirm
audit_logentry andmc ls .../rejected/.
Out of Scope¶
- The cross-cutting integration test pack (tenant isolation, large file, magic byte, full crash-recovery matrix) — ING-006.
- ShedLock / multi-instance cron coordination — V1 (single-instance deploy in v0.0.0; ADR-03).
- Operator notification badge for rejected files — V0.0.1.
- Email / SMS alerting on recovery — V2.
- Auto-retry of recovered rows — never (operator must re-drop; architecture §6.4 explicit).
- Feature-flag UI in the admin console — V1+ (env-var only in v0.0.0).
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses (none added)
- [ ] All frontend pages render correctly at 360 / 768 / 1024 px (N/A — backend only)
- [ ] All 5 UI states implemented (N/A — backend only)
- [ ] French micro-copy matches design spec exactly (sidecar shape reused from ING-004; recovery reason string set)
- [ ] All acceptance criteria pass manually
- [ ] Unit + integration tests written and passing
- [ ] Tenant isolation verified (recovery iterates per tenant; SHARED + BYO smoke green)
- [ ] [CUSTOMER stories] payload + Slow-3G + resumability (N/A — backend only)
- [ ] Audit log entries present for every required event (
ING_PARSE_FAILEDper recovered row) - [ ] No silent failures on network errors (recovery's MinIO move failures logged WARN; row stays PARSE_FAILED — re-running recovery is idempotent)