Plan: Reports List Summary Projection (pre-1.0 wire fork)
On this page
- Status
- Context
- Design decisions
- Units (single MR; each unit compile-green)
- U0 — choreography + repo plan
- U1 — craig-search: Projection + list_sql + hard-forked executor
- U2 — craig-cases-fields: SUMMARY_COLUMNS
- U3 — craig-cases: row, DTO, consuming decrypt, handler, OpenAPI
- U4 — ONE consolidated integration test (keyed harness, devstack)
- U5 — consumers (same MR) + their deterministic tests
- U6 — docs + benchmark (final implementation commit)
- Gates & risks
- Pipeline
- Rejected options
|
Revision history. v1 was internally reviewed (17 findings folded: repo-plan lifecycle, dual-executor compat shim, label swap, issue amendment, typed-error layering, structure). v2 was externally REJECTED 2026-07-27 (~29 findings: compile-blocking error layering, production-seam binding, ciphertext typestate, runtime atomicity, benchmark rigor, mandatory consumer tests). This v3 folds every finding and was user-approved 2026-07-27. Rejected designs are recorded in Rejected options. |
Status
| Unit | Description | Status |
|---|---|---|
U0 |
GitLab choreography (#1158 description amended with the steer; label
|
Done (2026-07-27) — #1176 filed/related; plan + nav first commit |
U1 |
craig-search: validated |
Done (2026-07-27) — commit 16c0c847; red-proof: a projection-ignored mutation fails the declared-order test |
U2 |
craig-cases-fields: |
Done (2026-07-27) — commit 6fa075d6; red-proof: widening with RAW_SUBMISSION fails both pins |
U3 |
craig-cases: |
Done (2026-07-27) — commit 986b88e6; 9 unit tests (incl. the ciphertext-preview seam, keyless-Required fail-closed, wider-walk RowMissingColumn); red-proof: a byte-truncate mutation fails the multibyte test |
U4 |
ONE consolidated keyed-harness integration test: full-featured fixture (multibyte >160-char narrative, names/phone, non-empty children/adults/raw_submission, ip_hash path), list = exactly the 6 summary keys with a true decrypted preview, detail GET = all eight list-removed values round-tripping. |
Done (2026-07-27) — commit 0cff8593; passed live against devstack (also pins partner_id/received_request_id + a received_at list↔detail cross-check) |
U5 |
Consumers, same MR: web queue |
Done (2026-07-27) — commit 25160f04 |
U6 |
Docs + benchmark: CHANGELOG (fork entry + stop-the-world deploy procedure), ADR-049 amendment, shared-crates.adoc §craig-search, api/craig-cases.adoc regen with verification, thresholded 500-report benchmark (latency + bytes). |
Done (2026-07-27) — benchmark PASS decisively (protocol as specified but
hyperfine was unavailable on the box: identical parameters — 3 warmup |
Ship |
Battery push, MR |
Done (2026-07-27) — MR !1085 (merge caba5342); full battery green incl. 300 e2e (one pre-push abort at the fn-name-and gate → the 2750ef2c rename); #1158 closed with the AC walk; epic &73 ticked; this archive commit completes the plan |
Context
External audit F23 (2026-07-26), verified in-tree:
-
execute_search’s only projection is `SELECT *(crates/craig-search/src/exec.rs:79-86). -
The Report row model carries 4 encrypted TEXT + 3 encrypted JSONB columns (
services/craig-cases/src/store/models.rs:77-101), including multi-KBraw_submission. -
list_reportsdecrypts every row of the page (api/reports/crud.rs→api/encryption/report.rsoverreports::ALL): up to 7 AES-256-GCM-SIV decrypts per row in an all-fields row (4–7 in Required mode), × up to 500 rows/page, reachable by any authenticated operator (realm-flat scope). -
#1139 (!1061) fixed exactly this cost on the create-echo path; the list path was left as-is.
Precedent SubsidyAgreementSummary is post-fetch mapping; this plan is a
SQL-level projection — heavy columns are never fetched at all.
User steer (2026-07-27): option (a) — summary-only list, pre-1.0 hard fork, no
?projection param. Full shape stays on detail GET and on the create echo (the
creator already holds the submitted data — the fork governs collection-GET
semantics only).
Summary shape (6 wire keys): id, received_at, reporter_type, reporter_relation,
admin_unit, narrative_preview. partner_id + received_request_id are removed
too (no inventoried consumer uses them — least-data); received_ip_hash is removed
from the bulk-list surface (detail GET still returns it realm-flat — follow-up
issue #1176, not absorbed here).
Consumer inventory (drives U5)
| Consumer | Where | Uses from list items | Break mode if unfixed |
|---|---|---|---|
Web queue |
|
id, received_at, reporter_type, admin_unit, |
LOUD: serde fails → error page |
Chain timeline |
|
id, received_at, admin_unit, reporter_type |
SILENT: parse fail → |
CLI |
|
the above + reporter names (encrypted) |
blank cells, no crash |
Typed client / k6 / SDKs / mock |
— |
untyped |
none |
No existing test asserts heavy fields in list items. The seed contains zero reports — every fixture is test-created.
Design decisions
| # | Fork | Choice |
|---|---|---|
D1 |
Validated |
New |
D2 |
Executor: ONE hard-forked signature |
|
D3 |
Seam binding: production CANNOT pass |
|
D4 |
Ciphertext typestate: one consuming operation |
|
D5 |
Two-shape rule |
|
D6 |
Preview contract |
|
D7 |
Decrypt-count AC |
Structural (U2: encrypted-subset( |
D8 |
Docs of record |
ADR-049 amendment (read-projection leg); shared-crates.adoc §craig-search
(executor signature + |
D9 |
Deployment reality (NOT one-MR atomicity) |
Both skew orders break, and cases scales horizontally (deployment-guide:961, docker-compose.scale.yml). Pre-1.0 choice: a stop-the-world procedure documented in CHANGELOG + MR: stop craig-web + ALL craig-cases replicas → deploy both images → start; rollback = redeploy the previous image PAIR (also both-or-neither); CLI upgraded with the deploy (stale CLI degrades to blank cells, no crash). No transition-compat shapes built. |
D10 |
OpenAPI truth |
The generated page shows codes/descriptions, not property tables → add an
|
Units (single MR; each unit compile-green)
U0 — choreography + repo plan
First commit, token-gated, J-reviewed with the docs-only carve-out noted.
-
Amend #1158’s description with the steer (self-contained issue BEFORE implementation).
-
Label swap
Plan::NEEDED→Plan::RPT-SUMMARY. -
Author this plan + nav Active entry;
cargo xtask plan-lint. -
File the follow-up issue for the detail-GET
received_ip_hashquestion (/relateto #1158) — filed as #1176.
U1 — craig-search: Projection + list_sql + hard-forked executor
Files: crates/craig-search/src/{plan.rs,exec.rs,error.rs,lib.rs};
crates/craig-common/src/error.rs (:623 match arm + mapping test); the 5 call
sites (4× None + reports None-temporary).
Tests (NEW crates/craig-search/tests/list_sql.rs, sqlx-free; suffixed for the
coverage matrix):
-
@axis: happylist_sql_without_projection_renders_legacy_select_star_happy— exact legacy string incl.$n+1/$n+2(refactor-equivalence pin; a one-time text change would merely re-prepare once — this is NOT a cache-risk claim). -
@axis: happylist_sql_with_projection_renders_columns_in_declared_order_happy. -
@axis: sadprojection_rejects_empty_and_duplicate_columns_sad—Err(EmptyProjection)/ duplicate refusal; neverSELECT *. -
(craig-common)
@axis: sadsearch_error_empty_projection_maps_to_server_fault_sad.
U2 — craig-cases-fields: SUMMARY_COLUMNS
pub const SUMMARY_COLUMNS: &[&FieldSpec] = &[&ID, &RECEIVED_AT, &REPORTER_TYPE,
&REPORTER_RELATION, &ADMIN_UNIT, &NARRATIVE]; (consts exist under these names,
crates/craig-cases-fields/src/reports.rs:57-159).
Tests: @axis: happy encrypted-subset == exactly ["narrative"] (the structural
decrypt-count assertion); @axis: sad excludes
ip_hash/partner_id/received_request_id/names/phone/children/adults/raw_submission;
non-empty; subset-of-ALL exactly-once.
U3 — craig-cases: row, DTO, consuming decrypt, handler, OpenAPI
-
store/models.rs:ReportSummaryRow(FromRow only; doc: "storage-form narrative — deliberately not Serialize"). -
api/encryption/rows.rs:impl_encryptable_row!(store::models::ReportSummaryRow { "narrative" ⇒ TextRequired(narrative), } hmac: none);+ module-doc list updated. -
NEW
api/reports/summary.rs:SUMMARY_PROJECTIONstatic (D3),ReportSummary, the D4/D6 truncate, privatesummarize,decrypt_into_summaries,fetch_summary_page(app, plan, enc, mode, limit, offset) → Result<(Vec<ReportSummary>, i64), ApiError>. -
api/reports/mod.rs:pub(crate) mod summary;+ re-exportReportSummary; update the "four submodules" module doc. -
crud.rs::list_reports: reports call flips from the U1 temporaryNonetofetch_summary_page;PageResponse<ReportSummary>; utoipa body + #1158 doc paragraph.get_report/create/store/migrations UNCHANGED (create-echo scope per the steer). -
api/mod.rs: registerreports::ReportSummary.
Unit tests (matrix suffixes):
-
@axis: happynarrative_preview_short_and_exact_160_pass_through_happy -
@axis: happynarrative_preview_truncates_multibyte_and_appends_ellipsis_happy(200×'é' → 160×'é'+'…' = 161 chars) -
@axis: happynarrative_preview_empty_input_stays_empty_happy -
@axis: evilproptestnarrative_preview_prefix_property_never_panics_evil(∀ String: no panic; ≤161 chars; ≤160-char input ⇒ identity; else strip '…' leaves a 160-char prefix) -
@axis: sadsummary_projection_static_builds_and_sql_omits_heavy_columns_sad—SUMMARY_PROJECTIONconstructs; the REALReportsSearchplan rendered with it pins the exactSELECT id, received_at, reporter_type, reporter_relation, admin_unit, narrative FROM reportsprefix; excluded columns absent. -
@axis: happyopenapi_list_response_schema_has_exactly_the_six_summary_keys_happy(D10 contract test onApiDoc::openapi()).
U4 — ONE consolidated integration test (keyed harness, devstack)
NEW services/craig-cases/tests/api/report_summary_list.rs + alphabetical mod.
ONE #[ignore = "requires devstack"] test (single harness build — don’t triple the
expensive setup): report_list_summary_projection_and_detail_full_shape_happy
(@axis: happy), fixture = create-report with >160-char MULTIBYTE narrative,
names + phone set, non-empty children/adults/raw_submission, non-empty
received_ip_hash path, EncryptionMode::Required. Phases:
-
List: item key set is EXACTLY the 6 summary keys;
narrative_preview== submitted-plaintext first-160 + '…' (proves the one decrypt ran — cannot be a ciphertext echo); plaintext fields round-trip. -
Detail GET
body["report"]: ALL EIGHT list-removed values present and round-tripping (ip_hash, first/last/phone, full narrative, children, adults, raw_submission) — the fork boundary pinned on both sides.
U5 — consumers (same MR) + their deterministic tests
-
Web queue:
ReportSummaryView { id, received_at, reporter_type, admin_unit, narrative_preview };templates/intake/reports.html:52→{{ r.narrative_preview }};ReportViewunchanged (detail envelope). -
Chain: delete
narrativefromReportRowand its#[expect(dead_code)]
Default arm (the expectation becomes unfulfilled under-D warningsonce all remaining fields are used). Mandatory deterministic unit test (chain.rs tests mod): deserialize a new-shape list-item JSON (nonarrativekey) intoReportRow+ assert the timeline builder emits the Report row — the SILENT-OK degrade path is otherwise untestable by e2e (seed has no reports; the spec checks only heading/banner). No conditional e2e hardening. -
CLI: columns →
id, received_at, admin_unit, reporter_type, reporter_relation+ comment (names are detail-only by design). Table-render contract test in the CLI suite: table mode assertsreporter_relationheader present and reporter-name headers absent (JSON-mode tests ignore the column list — they cannot catch this). -
Web e2e preview assert: extend intake-review.spec.ts — submit a distinctive long multibyte narrative via the API fixture, assert the QUEUE renders the truncated preview text (blank/untruncated/wrong preview currently passes).
U6 — docs + benchmark (final implementation commit)
-
CHANGELOG
=== Changed: the fork entry — 6-key summary list (ONE decrypt/row, down from up-to-7 incl. raw_submission); partner_id/received_request_id/ received_ip_hash removed from the LIST surface; detail GET + create echo unchanged; prefix-160/wire-161 preview; the D9 stop-the-world deploy + rollback procedure. -
ADR-049 amendment; shared-crates.adoc §craig-search; api/craig-cases.adoc regen per D10 with verification.
-
Benchmark protocol (recorded in the MR; pass/fail bearing):
-
Dataset: reseed, then create EXACTLY 500 reports via the typed client (narrative 2,000 chars; raw_submission ≈4 KB; 2 children + 2 adults each; one unique
admin_unitvalue for isolation). Same dataset rebuilt identically per revision. -
Revisions: merge-base SHA vs head SHA; per revision:
dev reload, auth once, 3 warmup requests. -
Measure per_page=100 AND 500: hyperfine
-w 3 -r 15(report mean/min/max/σ)
downloaded bytes viacurl -H 'Accept-Encoding: identity' -w '%{size_download}'(Content-Length is encoding-dependent), idle host. -
Thresholds: PASS iff head max < base min at per_page=500 (non-overlapping — the JSONB removal should dominate noise) AND head bytes ≤ 20% of base bytes; any latency overlap → investigate before merging, never close on a regression.
-
Gates & risks
Gates: fmt; clippy -D warnings; quality-budgets (no new serde_json::Value;
extraction keeps list_reports in budget); plan-lint; full battery + e2e.
Pipeline cancellation ONLY per the documented red-CI infra-failure exception after
a green local battery.
| Risk | Mitigation |
|---|---|
U1 error layering |
Solved by construction: validation at |
Production regresses to |
D3 seam: |
Summarize-before-decrypt emits ciphertext |
D4 consuming typestate — no public summarize; single-pass |
chain SILENT-OK masks the break |
Mandatory deterministic serde/timeline unit test (U5) |
Deploy skew (both orders break; cases scales horizontally) |
D9 documented stop-the-world + image-pair rollback; no compat shapes |
Residual exposure (honest) |
The list still decrypts up to 500 narratives/page into previews for realm-flat operators — a large reduction, NOT elimination; the per-page cap is #1170’s; the detail-surface ip_hash question is #1176 |
Empty/duplicate projection |
Typed constructor + U2 pins |
Future ciphertext leak via refactor |
Wire type lacks the field; U4 exact-key-set pin; D4 typestate |
Pipeline
-
U0 commit (plan + nav; token + J-review, docs-only carve-out stated).
-
U1→U6 compile-green; gates; each substantive commit gets its own token
staged-diff J-review. -
Battery push (background, harness);
git ls-remoteverify. -
MR
Closes #1158(Summary/Changes/Test Plan + benchmark tables + deploy procedure + CLI UX note). -
Merge per the documented procedure (green local battery is the gate; cancel/force only under the recorded red-CI exception); J-record MR note; verify merge SHA.
-
Closing comment (SHAs + AC walk); epic &73 tick; branch cleanup.
-
Post-merge close-out commit (docs-only, direct-to-main per git-workflow): plan Status → Done, archive move + archive-index row citing the REAL MR number (ADR-030: Done = shipped).
Rejected options
-
(b)
?projection=summary|fullparam — still a default-shape change, keeps the heavy path reachable in bulk, and adds a permanent query-param surface for a pre-1.0 system that can simply fork. -
(c) keep shape, decrypt lazily/stream — leaves multi-KB
raw_submissionon the wire for a list nobody renders it in; complexity without the least-data win. -
Dual executor (
execute_search_projectedtwin) — a second fn name kept so call sites don’t move is itself a backwards-compat shim; pre-1.0 forks the ONE signature. -
Fallible
list_sqlrendering — pushesResultthrough every call site for a validation that can happen once atProjection::new; rendering from a shape-validated value is infallible by construction. -
Separate summarize-then-decrypt passes — row.rs:322 decrypts in place; a public summarize on undecrypted rows would truncate ciphertext; the consuming single-pass makes the wrong order unrepresentable.