Files
summercms/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-PLAN.md
Jakub Zych 9c87a59532 docs(13): create phase plan
Six sequential plans: framework gaps, notifications/credentials/onboarding, wishlist, CSV, public views, unit tests and gate. Research open questions marked resolved per the plan-count checkpoint.
2026-10-02 20:12:35 +02:00

39 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
13-p-ytarium-api-wishlist-notifications-csv-credentials-public 04 execute 4
13-03
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/contract.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/pipe_codec.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/detector.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/mapper.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/canonical_id.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/errors.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/testdata/
../fonoteka.go/plugins/golem15/fonoteka/classes/csv_export.go
../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go
../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go
../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml
../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go
../fonoteka.go/plugins/golem15/fonoteka/go.mod
../fonoteka.go/plugins/golem15/fonoteka/go.sum
../fonoteka.go/go.mod
../fonoteka.go/go.sum
../fonoteka.go/go.work.sum
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/fixtures/routes/
../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.yaml
../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.rows.json
../fonoteka.go/parity/fixtures/nuxt/files/
../fonoteka.go/parity/fonoteka_seed_test.go
../fonoteka.go/parity/fonoteka_reset.php
../fonoteka.go/parity/fonoteka_flows_test.go
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/README.md
true
API-05
tokens raw_tokens tasks confidence
300000 300000 3 low
truths artifacts key_links prohibitions
`GET export/csv` on the JWT group and on the token group (`inv.scope:read`) streams `text/csv; charset=UTF-8` with `Content-Disposition: attachment; filename=plytarium-kolekcja-<Y-m-d>.csv`, a UTF-8 BOM, the CsvAlbumContract HEADERS row and one row per album from the same SQL filters as the authenticated album index, written by a port of PHP `fputcsv(..., ',', '"', '')`: fields containing a comma, quote, CR, LF, tab or space are quoted, quotes doubled, null and false empty, true `1`, and formula-leading cells neutralised by the FORMULA_PATTERN guard.
Per RESEARCH Finding 6, the export never uses `encoding/csv.Writer`, and its recorded fixture replays on any later day through the 13-01 Content-Disposition date mask.
The `classes/csv` package ports CsvAlbumParser, CsvColumnDetector, CsvAlbumContract, CsvPipeCodec, CsvColumnMapper and CsvCanonicalIdMatcher: BOM strip, UTF-8 then Windows-1250 then ISO-8859-2 decoding (golang.org/x/text/encoding/charmap, approved at the checkpoint), the `\p{L}` check, delimiter guess, Polish and English header aliases, combined `Artist - Title`, MAX_ROWS 5000 and NUL rejection, each pinned by a truth table whose expected values were produced by the PHP classes.
`POST import/csv` (JWT, `throttle:10,1`) refuses a missing, non-csv/txt or unreadable file with 422 `{"result":"error","code":"csv_unreadable","message":...}`, a file over 5242880 bytes with `csv_too_large`, a parse failure with the exception's errorCode and its English message, invalid canonical rows with `validation_failed`; otherwise it stores the upload in a private bucket at `fonoteka-csv/<user id>/<uuid>.csv` (never under the public uploads prefix), creates the import in status `preview` (canonical) or `mapping` with mode `fill_empty`, writes the rows and answers 202 with the serialized import.
`GET import/csv/{id}` returns the import with rows paginated by `page` and `per_page` clamped to 1..50, `summary` as an object of status counts or `[]` when empty, and progress from row counts (uploaded, mapping, matching) or from the summer_jobs row; imports are visible only when `user_id` is the caller and the target collection is still accessible, otherwise PHP's not-found body.
Per D-03 and the 13-01 job contract, a non-canonical `PATCH import/csv/{id}/mapping` (only before commit: mapping, preview or failed; else `csv_already_committed`) cancels the previous match job, re-parses and replaces the rows, sets status `uploaded` and dispatches `CsvMatchArgs` on `fonoteka.csv.match` with label `fonoteka.csv.match`, Count row_count and Metadata `{"csv_import_id":N}`, storing match_job_id; a canonical mapping sets `preview` without a job.
Per D-03, `POST import/csv/{id}/commit` compare-and-swaps `preview` → `importing` (with the requested import_mode) and dispatches `CsvImportArgs` on `fonoteka.csv.import` with label `fonoteka.csv.import`, storing import_job_id and answering 202 `{"data":{"import_job_id","status","import_mode"}}`; a replay while importing or done answers 202 with the current job; any other status answers `csv_match_not_ready`.
Per D-04, no worker exists for either kind: the queued River jobs stay unworked, and `show` reports the status and progress PHP reports before its worker runs; `POST import/csv/{id}/cancel` calls conga.CancelJob for both job ids (is_canceled, status stopped 4) and sets the import `canceled`.
Per D-05, `PATCH import/csv/{id}/rows/{rowId}` ports `skip` (status skipped) and `accept_csv` (status matched_csv); the `selected_discogs_id` branch validates a digit string from the row's `candidates_json` allow-list (else `validation_failed`), checks DiscogsAllowed (else `discogs_unavailable`) and then asks the `ReleaseFetcher` seam, whose Phase 13 implementation always fails, so the route answers 422 `discogs_unavailable` with `Nie udało się pobrać tego wydania z Discogs.` and never writes release data; the successful-pick case stays pending for Phase 14.
The 8 CSV routes are ported with re-recorded fixtures (`expectedPortedRoutes` 151), and `TestFonotekaNuxtFlows/nuxt-csv` (recorded with QUEUE_CONNECTION=database: store, show, mapping, row edit, commit to its 202, show, cancel, export on both groups) replays green with its job rows (label, status, progress_max, metadata, is_canceled) equal to the recorded PHP `golem15_apparatus_jobs` rows.
Edge (API-05 boundary): a 5242880-byte file is accepted and 5242881 bytes answer `csv_too_large`; 5000 data rows are accepted and 5001 are refused as PHP refuses them; `per_page=0` reads 1 and `per_page=51` reads 50.
Edge (API-05 encoding): a Windows-1250 file and an ISO-8859-2 file containing `Łódź` import with the same row values as their UTF-8 twin; a file with a NUL byte answers `csv_unreadable`.
Edge (API-05 concurrency): two concurrent commits of one preview import dispatch exactly one import job; the loser answers 202 with the same import_job_id.
Edge (API-05 empty): an import whose rows carry no status has `summary` `[]`, and an export of an empty collection is the BOM plus the header row.
statement verification
Edge (API-05 ordering): exported rows follow the album index's default order and the header columns follow CsvAlbumContract::HEADERS exactly. backstop
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go WriteRecord, the fputcsv port func WriteRecord(
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go Parse, ParseResult, MaxRows, MaxBytes charmap
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go StoreCsvImport, CsvImportFor, UpdateCsvMapping, UpdateCsvRow, CommitCsvImport, CancelCsvImport, SerializeCsvImport, ReleaseFetcher, SetReleaseFetcher, ErrDiscogsUnavailable CsvMatchLabel
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go CsvImportStore, CsvImportShow, CsvImportMapping, CsvImportRow, CsvImportCommit, CsvImportCancel
path provides
../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.yaml recorded Nuxt CSV journey
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go Dispatch CsvImportArgs and CsvMatchArgs with their queues and labels; CancelJob on cancel and remap CsvImportQueue
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go BOM, HEADERS, then WriteRecord per album row WriteRecord
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml private CSV bucket opened from golem15.fonoteka.csv.bucket_url csv.bucket_url
requirement_id category statement status verification
API-05 transparency The selected_discogs_id branch MUST NOT fabricate, guess or copy release data; until Phase 14 it answers discogs_unavailable (D-05) resolved test
requirement_id category statement status verification
API-05 transparency No CSV import or match job may be worked, marked done or faked in Phase 13; show reports the queued state PHP reports (D-04) resolved test
requirement_id category statement status verification
API-05 privacy An uploaded CSV MUST NOT be stored where the static uploads handler or any public URL can serve it resolved test

Phase Goal

ROADMAP Phase 13 goal (verbatim, not in user-story form): The remaining core API surface — wishlist, notifications, CSV import/export, per-user/org credentials, and onboarding/public/invitation routes — is ported with byte-compatible shapes and their own public rate-limit buckets.

This plan's slice: a collector downloads their collection as a spreadsheet-safe CSV, uploads a CSV from another app in any of the encodings Polish users have, maps its columns, fixes rows, commits it and sees it waiting for the importer, exactly as the Nuxt import wizard sees PHP before its worker runs (API-05; ROADMAP SC-3).

Port CSV export on both groups with an `fputcsv` writer, the `classes/csv` parser package with PHP truth tables, the private upload bucket, and the six import-session routes with the commit compare-and-swap, the job dispatch and cancellation of the 13-01 contract and the D-05 seam; record the routes and the nuxt-csv flow with job-row goldens.

Purpose: the import wizard is how users bring collections into Płytarium; the jobs it queues are the contract Phase 14 workers fulfil. Decisions implemented: D-03, D-04, D-05, D-12 (nuxt-csv), D-13 (row goldens), C-01, C-02, C-03, C-07; RESEARCH Findings 3, 4 and 6. Output: csv package, services, handlers, routes, config key, recordings, flow; ported count 151.

Repo: fonoteka.go only. golang.org/x/text becomes a direct requirement of the fonoteka plugin module (user-approved at the plan-count checkpoint; already v0.42.0 in the graph). Commits path-scoped; never add co-author tags.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-CONTEXT.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md @.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-01-SUMMARY.md @../fonoteka.go/plugins/golem15/fonoteka/routes.go - From 13-01: job contract `classes.CsvImportKind`, `CsvImportQueue`, `CsvImportLabel`, `CsvMatchKind`, `CsvMatchQueue`, `CsvMatchLabel`, `CsvImportArgs{CsvImportID}`, `CsvMatchArgs{CsvImportID}`; conga unregistered-kind dispatch; tide Content-Disposition date mask; `php_parity.sh rows`; `QUEUE_CONNECTION` override. - From 13-03: `classes.JobDispatcher`, `SetJobDispatcher` (Dispatch and Enqueue on a transaction). - conga: `(*Manager).Dispatch(ctx, db, args, DispatchOpts{Label, Count, Metadata, Queue})`, `(*Manager).CancelJob(ctx, id)`, `(*Manager).Get(ctx, id) (Record, error)` with Record{Status, Progress, ProgressMax, IsCanceled, Metadata}; statuses equal Apparatus constants (record.go). - Existing: `classes/album_search.go` unexported `searchSQL` and filter parsing used by the authenticated album index; `classes.DiscogsAllowed`; `uploadBucket(app)` (request.go:192) for the public bucket; tide multipart `Request.Parts` with sha256 part files; models `CsvImport{UserID, CollectionID, Status, ImportMode, StoragePath, RowCount, ColumnMap Jsonable, MatchJobID *uint, ImportJobID *uint}`, `CsvImportRow{Status, Aggregate, CandidatesJSON, ...}`. - PHP contract: /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/{controllers/api/CsvImportApiController.php (whole file), controllers/api/CsvExportApiController.php, classes/csv/*.php, models/CsvImport.php, models/CsvImportRow.php, lang/pl/lang.php (csv_import keys, lines 138-147), lang/en/lang.php, routes.php (lines 78, 324-329, 455)}; /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php (dispatch, cancel); Nuxt /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/ (CSV import wizard calls).

Artifacts this phase produces

(This plan's share.)

  • Package classes/csv (package name csv): Headers, ExportRow, FormulaSafe, WriteRecord, Parse, ParseResult, ParseError{Code, Message}, MaxRows, MaxBytes, DetectColumns, MapColumns, PipeEncode, PipeDecode, MatchCanonicalID.
  • classes: ExportAlbums (csv_export.go), StoreCsvImport, CsvImportFor, UpdateCsvMapping, UpdateCsvRow, CommitCsvImport, CancelCsvImport, SerializeCsvImport, ReleaseFetcher (interface), SetReleaseFetcher, ErrDiscogsUnavailable, ErrCsvAlreadyCommitted, ErrCsvMatchNotReady.
  • controllers/api: CsvExport, CsvImportStore, CsvImportShow, CsvImportMapping, CsvImportRow, CsvImportCommit, CsvImportCancel; csvBucket(app) (private bucket helper in request.go).
  • Config key: golem15.fonoteka.csv.bucket_url (default file://./storage/app).
  • Routes: JWT POST /import/csv (throttle:10,1), GET /import/csv/{id}, PATCH /import/csv/{id}/mapping, PATCH /import/csv/{id}/rows/{rowId}, POST /import/csv/{id}/commit, POST /import/csv/{id}/cancel (all [0-9]+), GET /export/csv; token GET /export/csv (inv.scope:read).
  • Parity: seed state csv; fixtures/nuxt/nuxt-csv.yaml, nuxt-csv.rows.json, part files under fixtures/nuxt/files/; TestFonotekaNuxtFlows/nuxt-csv.
  • Tests: TestPHPFputcsv, TestCsvParserTruthTable, TestCsvDetectorTruthTable, TestCsvExport, TestCsvStoreAndShow, TestCsvImportScope, TestCsvCommitCAS, TestCsvJobRows, TestCsvCancel, TestCsvRowPickSeam.
Task 1: A collector downloads their collection as a CSV that matches PHP's file byte for byte The isolated PHP parity instance can be reset (`php -v` exits 0). ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/pipe_codec.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_export.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvExportApiController.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvAlbumContract.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvPipeCodec.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (authenticatedDatabaseQuery), ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go (searchSQL, filters), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/albums_controller.go (index filter parsing), ../fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_export_csv_jwt.yaml, ../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_export_csv_personal_token.yaml, .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-RESEARCH.md (Finding 6 point 3, fputcsv probe) Per RESEARCH Finding 6 and C-01.

(1) classes/csv: contract.go ports CsvAlbumContract's HEADERS, exportRow and the FORMULA_PATTERN guard (^[\x09\x0A\x0D ]*[=+\-@]) as Headers, ExportRow, FormulaSafe; pipe_codec.go ports CsvPipeCodec as PipeEncode/PipeDecode; writer.go WriteRecord(w io.Writer, fields []any) error ports PHP fputcsv with delimiter ,, enclosure " and an empty escape: quote a field containing comma, quote, CR, LF, tab or space, double embedded quotes, write nil and false as empty, true as 1, numbers in PHP's string form, and end the line with LF as PHP does. Do not use encoding/csv.Writer. TestPHPFputcsv table includes the research probe line (Kind of Blue, LP, a tab, x"y, empty, =SUM(1), plain, semi;colon, café, and null/false/true/0) with PHP's exact bytes.

(2) classes/csv_export.go ExportAlbums(ctx, db, user, token, filters, fn func(row []any) error): the authenticated album query (port authenticatedDatabaseQuery over the existing searchSQL and filter parsing, SQL path only, exporting what is needed from album_search.go without changing its callers) streamed in batches of 100 in the index's default order. controllers/api/csv_export_controller.go CsvExport(app): validate filters as PHP does, set Content-Type: text/csv; charset=UTF-8 and Content-Disposition: attachment; filename=plytarium-kolekcja-<today Y-m-d>.csv, write the BOM, the Headers row and each ExportRow through WriteRecord. Route: JWT GET /export/csv.

(3) Seed state csv (alice's collection with albums whose fields exercise quoting, formula cells, Polish letters and empty values; imports for Task 2-3 added there later) in both seeds. Re-record the JWT export fixture from the reset (the old one carries the {{id:token}} collision); flip it; expectedPortedRoutes 144.

(4) csv_smoke_test.go TestCsvExport: the body starts with the BOM and the header row, a formula cell is neutralised, a field with a space is quoted, an empty collection yields only BOM and header. go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/csv -run '^(TestPHPFputcsv)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvExport)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS: TestPHPFputcsv" and "--- PASS: TestCsvExport"; the parity run reports FAIL for the export route or lacks "--- PASS: TestParityCorpus/coverage".</fails_when> <acceptance_criteria> - grep -rc 'encoding/csv' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/writer.go prints 0. - grep -c 'plytarium-kolekcja-' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_export_controller.go prints 1. - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 144. - The re-recorded JWT export fixture contains no {{id:token}} placeholder. </acceptance_criteria> The export works end to end with PHP's exact quoting, BOM, header and filename, proving the csv package, the query and the recording path before the import session lands.

Task 2: A collector uploads a CSV in any Polish encoding and sees the parsed preview or the mapping step, stored privately The isolated PHP parity instance can be reset and `php` can run the PHP csv classes for truth-table generation. ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/detector.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/mapper.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/canonical_id.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/errors.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/csv_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/testdata/, ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/plugins/golem15/fonoteka/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/go.sum, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/go.work.sum, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/files/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/parity_test.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvAlbumParser.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvColumnDetector.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvColumnMapper.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvCanonicalIdMatcher.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/csv/CsvParseException.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvImportApiController.php (store, show, replaceRows, hasInvalidCanonicalRows, findVisible, serializeImport, serializeRow, jobProgress, notFound, error), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/CsvImport.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/CsvImportRow.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php (lines 138-147), ../fonoteka.go/plugins/golem15/fonoteka/models/csv_import.go, ../fonoteka.go/plugins/golem15/fonoteka/models/csv_import_row.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collection_media_controller.go (multipart handling), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/request.go (uploadBucket), ../fonoteka.go/config/storage.yaml, ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go (1) Parser package, per RESEARCH "Parser classes to port": parser.go `Parse(content []byte, columnMap map[string]any) (ParseResult, error)` ports CsvAlbumParser (BOM strip, UTF-8 validity then Windows-1250 then ISO-8859-2 via golang.org/x/text/encoding/charmap, the `\p{L}` plausibility check, `fgetcsv` logical records with enclosure `"` and an empty escape, MaxRows 5000, MaxBytes 5242880, NUL rejection), detector.go, mapper.go, canonical_id.go and errors.go (`ParseError{Code, Message}` carrying PHP's error codes and English messages). Promote golang.org/x/text to a direct requirement (`go get` in the plugin module, `go mod tidy`, `go work sync`). Truth tables in csv_test.go `TestCsvParserTruthTable` and `TestCsvDetectorTruthTable` read fixture CSVs from classes/csv/testdata/ and compare against expected JSON produced once by running the PHP classes (`php artisan tinker` against the isolated instance or a small php script under parity/, documented in a testdata README line); cover canonical exports, combined `Artist - Title`, semicolon and tab delimiters, Polish and English header aliases, the three encodings with `Łódź`, 5000 versus 5001 rows, a NUL byte and an empty file.

(2) Private bucket: config.yaml key csv.bucket_url (merged as golem15.fonoteka.csv.bucket_url, default file://./storage/app); request.go csvBucket(app) opens it separately from uploadBucket; keys are fonoteka-csv/<user id>/<uuid>.csv, generated server-side only.

(3) csv_import_service.go: StoreCsvImport (size and extension checks, Parse, canonical invalid rows → validation_failed, ActiveCollection resolve, write the blob, create the import (status preview when canonical, mapping otherwise, mode fill_empty), replace rows with PHP's per-row status), CsvImportFor (findVisible: user_id is the caller and the collection is still AccessibleBy the caller), SerializeCsvImport (PHP key order; summary an object of counts or []; progress per jobProgress, reading the summer_jobs row through conga when a job id is set; can_retry). Port the csv_import lang keys (pl and en) to lang.yaml for the error messages. controllers/api/csv_import_controller.go CsvImportStore (202 {"data":...}; errors {"result":"error","code","message"} with PHP's statuses; a CSV parse error uses the English exception message as PHP does) and CsvImportShow (page, per_page clamped 1..50, rows_per_page; not visible → PHP's not-found body). Routes: JWT POST /import/csv with throttle:10,1, GET /import/csv/{id} with [0-9]+.

(4) Recordings via tide multipart parts (part files under parity/fixtures/nuxt/files/ pinned by sha256): store canonical 202, store non-canonical 202, unreadable 422 (binary), missing file 422 (Polish message), too large 422, parse error 422 (English message); show of each import, a foreign import (bob) and a missing id. Flip both routes; expectedPortedRoutes 146.

(5) csv_smoke_test.go TestCsvStoreAndShow (canonical → preview with rows, non-canonical → mapping, blob key under fonoteka-csv/ in the private bucket and absent from the public bucket) and TestCsvImportScope (another user's import and an import whose collection the caller lost access to both answer not found). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka/classes/csv -count=1 -v -run '^(TestPHPFputcsv|TestCsvParserTruthTable|TestCsvDetectorTruthTable)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvStoreAndShow|TestCsvImportScope|TestCsvExport)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus)$' -count=1 -v <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCsvParserTruthTable, TestCsvDetectorTruthTable, TestCsvStoreAndShow and TestCsvImportScope; the parity run reports FAIL for an import route or lacks "--- PASS: TestParityCorpus/coverage".</fails_when> <acceptance_criteria> - grep -c 'golang.org/x/text' ../fonoteka.go/plugins/golem15/fonoteka/go.mod prints 1 and that line has no // indirect marker. - grep -c 'charmap' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/parser.go prints at least 1. - grep -c 'csv.bucket_url\|bucket_url' ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml prints at least 1 and grep -c 'uploads/public' ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml prints 0. - ls ../fonoteka.go/plugins/golem15/fonoteka/classes/csv/testdata/ lists at least one Windows-1250 and one ISO-8859-2 sample. - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 146. </acceptance_criteria> Uploads in UTF-8, Windows-1250 and ISO-8859-2 parse exactly as PHP parses them, land in private storage, and show the preview or mapping state with PHP's bodies and errors.

Task 3: A collector maps columns, fixes rows, commits and cancels an import, sees it queued for the Phase 14 importer, and exports through a personal token Writes the D-03 job kinds, queues, labels and args (13-01 contract) into live summer_jobs and river_job rows; signed off 2026-10-02, recorded without a new checkpoint. The isolated PHP instance can serve with `QUEUE_CONNECTION=database` (13-01 override) and `php_parity.sh rows` works. ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/csv_import_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/csv_smoke_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.yaml, ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.rows.json, ../fonoteka.go/parity/fixtures/nuxt/files/, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/README.md /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/CsvImportApiController.php (updateMapping, updateRow, commit, cancel, cancelJob, allowedCandidateIds, isBeforeCommit), /media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/classes/JobManager.php (dispatch, cancel lines 59-97 and 222-233), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/DiscogsGate.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php (lines 324-329, 455), ../fonoteka.go/plugins/golem15/fonoteka/classes/job_contract.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/wishlist_notifications.go (JobDispatcher), summercms.go modules/conga/conga.go (Dispatch, CancelJob, Get), summercms.go modules/conga/record.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go (DiscogsAllowed), ../fonoteka.go/parity/fonoteka_flows_test.go, ../fonoteka.go/parity/README.md (Phase 13 recording) Per D-03, D-04 and D-05.

(1) Mapping: UpdateCsvMapping requires isBeforeCommit (mapping, preview, failed; else ErrCsvAlreadyCommitted → csv_already_committed), reads column_map or the whole body, re-parses the stored blob, cancels the previous match job (conga.CancelJob when match_job_id is set), replaces rows, sets preview (canonical) or uploaded (non-canonical) and, when non-canonical, Dispatches CsvMatchArgs{CsvImportID} with CsvMatchQueue, CsvMatchLabel, Count row_count and Metadata {"csv_import_id":N} on the same transaction, storing match_job_id. Handler CsvImportMapping.

(2) Row edit: UpdateCsvRow ports skip and accept_csv; for selected_discogs_id it validates a digit string present in the row's candidates (else validation_failed), checks DiscogsAllowed (else ErrDiscogsUnavailable), then calls the ReleaseFetcher interface (FetchRelease(ctx, user, discogsID string) (map[string]any, error)) installed with SetReleaseFetcher; the Phase 13 default returns ErrDiscogsUnavailable with a doc comment naming Phase 14 (INTG-01), so the handler answers 422 {"result":"error","code":"discogs_unavailable","message":"Nie udało się pobrać tego wydania z Discogs."} and writes nothing. Handler CsvImportRow.

(3) Commit: CommitCsvImport runs an UPDATE ... SET status = 'importing', import_mode = ? WHERE id = ? AND status = 'preview'; one updated row → Dispatch CsvImportArgs{CsvImportID} with CsvImportQueue, CsvImportLabel, Count row_count, Metadata {"csv_import_id":N} in the same transaction and store import_job_id; zero rows and status importing or done → 202 with the current import_job_id (replay); otherwise ErrCsvMatchNotReady → csv_match_not_ready. Handler CsvImportCommit answers 202 {"data":{"import_job_id","status","import_mode"}}.

(4) Cancel: CancelCsvImport cancels both job ids through conga.CancelJob and sets status canceled; handler CsvImportCancel answers PHP's body. Routes JWT PATCH /import/csv/{id}/mapping, PATCH /import/csv/{id}/rows/{rowId} (both [0-9]+), POST /import/csv/{id}/commit, POST /import/csv/{id}/cancel; token group GET /export/csv with inv.scope:read sharing the Task 1 handler.

(5) Recordings with QUEUE_CONNECTION=database: route cases for mapping (canonical, non-canonical with job, committed → csv_already_committed), row edit (skip, accept_csv, validation_failed pick, discogs_unavailable pick with the gate off), commit (202, replay 202, csv_match_not_ready), cancel (200, foreign 404), token export. The successful-pick case is not recorded (Phase 14). nuxt-csv flow: store → show → mapping → row edit → commit (202) → show (importing, progress from the job row) → cancel → export JWT → export token; after recording, dump golem15_apparatus_jobs (label, status, progress, progress_max, metadata, is_canceled ordered by id) with php_parity.sh rows into nuxt-csv.rows.json. Align the csv_imports id sequence on both sides as Phase 12 did for collections so metadata ids match. TestFonotekaNuxtFlows/nuxt-csv replays and compares the same columns of Go's summer_jobs with the golden. Flip the remaining 5 routes; expectedPortedRoutes 151. README: the nuxt-csv recipe.

(6) Tests in csv_smoke_test.go: TestCsvCommitCAS (two concurrent commits: one dispatch, both 202 with the same import_job_id), TestCsvJobRows (match and import dispatches write the contract's label, count and metadata and leave river jobs unworked on unserved queues), TestCsvCancel (both jobs stopped, is_canceled, River jobs cancelled, import canceled), TestCsvRowPickSeam (a candidate pick answers discogs_unavailable and changes nothing; a non-candidate answers validation_failed; a test ReleaseFetcher returning data is never installed outside the test). go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestCsvCommitCAS|TestCsvJobRows|TestCsvCancel|TestCsvRowPickSeam|TestCsvStoreAndShow)$' -count=1 -race -v && go -C ../fonoteka.go test ./parity -run '^(TestParityCorpus|TestFonotekaNuxtFlows)$' -count=1 -v && go -C ../fonoteka.go run ./parity/check_corpus.go --manifest parity/manifest.yaml --routes /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php --require-recorded --check-secrets <fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL", "--- SKIP" or "DATA RACE", or lacks "--- PASS" for TestCsvCommitCAS, TestCsvJobRows, TestCsvCancel, TestCsvRowPickSeam and "--- PASS: TestFonotekaNuxtFlows/nuxt-csv"; check_corpus reports a secret, an unrecorded route or a ported case-status mismatch.</fails_when> <acceptance_criteria> - grep -n 'const expectedPortedRoutes' ../fonoteka.go/parity/parity_test.go shows 151. - grep -c 'CsvImportLabel' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go and grep -c 'CsvMatchLabel' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go each print at least 1. - grep -cE 'csv_import|csv_match|CsvImportKind|CsvMatchKind' ../fonoteka.go/plugins/golem15/fonoteka/jobs.go prints 0 (no CSV worker in Phase 13). - test -f ../fonoteka.go/parity/fixtures/nuxt/nuxt-csv.rows.json succeeds and it contains the label fonoteka.csv.import. - grep -c 'discogs_unavailable' ../fonoteka.go/plugins/golem15/fonoteka/classes/csv_import_service.go prints at least 1. </acceptance_criteria> The whole import wizard runs against Go up to a queued import job that waits for Phase 14, cancel and commit replays behave as in PHP, the Discogs pick never invents data, and both groups export.

<threat_model>

Trust Boundaries

Boundary Description
Uploaded file → parser Untrusted bytes of up to 5 MiB are decoded and parsed
Upload → storage The file is kept for re-parsing and must never be web-served
Import id in URL → import row Imports are per user and per accessible collection
Album data → exported CSV opened in spreadsheets Cells can carry formulas
Request → queued jobs Commit and mapping write rows Phase 14 workers will act on

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-12 Elevation of Privilege import routes by id high mitigate CsvImportFor requires user_id = caller and AccessibleBy(collection_id); not visible → PHP not-found body; TestCsvImportScope (Task 2).
T-13-13 Information Disclosure CSV upload storage high mitigate Private bucket from golem15.fonoteka.csv.bucket_url, server-generated fonoteka-csv/<uid>/<uuid>.csv keys, never the public uploads prefix; TestCsvStoreAndShow asserts absence from the public bucket (Task 2).
T-13-14 Tampering exported cells medium mitigate FormulaSafe port of FORMULA_PATTERN on every exported cell; TestCsvExport and TestPHPFputcsv (Task 1).
T-13-15 Denial of Service CSV parse medium mitigate 5 MiB cap, MaxRows 5000, NUL rejection, throttle:10,1 on store; truth-table boundary cases (Task 2).
T-13-16 Tampering row edit Discogs pick high mitigate Candidate allow-list; ReleaseFetcher seam always fails in Phase 13 → discogs_unavailable, nothing written; TestCsvRowPickSeam (Task 3).
T-13-17 Tampering double commit / double writer high mitigate Single-statement compare-and-swap preview → importing; one Dispatch per swap; replay answers the existing job; TestCsvCommitCAS (Task 3).
T-13-31 Repudiation queued jobs without workers medium mitigate Unserved queues from the 13-01 contract; cancel stops summer_jobs and River rows; TestCsvJobRows and TestCsvCancel (Task 3).
T-13-SC Tampering golang.org/x/text direct import medium mitigate Go-team module already in the graph (v0.42.0 via go-i18n), go.sum pins the hash, approved by the user at the plan-count checkpoint; no npm/pip/cargo installs.
</threat_model>
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green; parity corpus at 151 ported and passing; `TestFonotekaNuxtFlows/nuxt-csv` passes; check_corpus `--require-recorded --check-secrets` green.

<success_criteria>

  • 8 CSV routes ported with PHP bodies, quoting and error envelopes.
  • Parser, detector and writer match PHP truth tables across encodings and limits.
  • Commit and mapping queue the contract's jobs, unworked until Phase 14; cancel and the D-05 seam behave as decided. </success_criteria>
Create `.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-04-SUMMARY.md` when done.