M3-15: File text extraction¶
Status: ready | Depends on: M3-14 | Milestone: M3
Goal¶
After this ticket search with type files finds an attached document by the words inside it, not only by its name and transcript. When a file of purpose attachment with a supported detected type becomes deliverable, the transaction that makes it deliverable creates its row in the new table file_text_extracts (state = 'pending', the search language of the instance) and inserts the job files.extract_text; the job reads the stored object once outside every transaction, runs the pure Go adapter internal/files/adapters/extract behind the outbound port TextExtractor under search.extract.timeout, and stores at most search.extract.max_text_bytes of text with truncated, the extractor name and version and extracted_at, or ends failed with a code without content, or unsupported. Supported are plain text (Markdown, CSV, JSON, source code), HTML, OOXML (DOCX, XLSX, PPTX) and ODF (ODT, ODS, ODP). Archives are opened only in memory and only within search.extract.max_input_bytes, search.extract.max_uncompressed_bytes and search.extract.max_archive_entries, so a zip bomb ends failed before its parts are inflated, and an XML part with a document type declaration, the carrier of every external entity, ends failed without any fetch. A periodic maintenance job re-extracts rows whose extractor version is older than the current one. The generated vector of 03 5 (stems weight C, words weight D) joins SearchFiles, so file text ranks below names and transcripts and passes the same rights filter (14 2, 14 4.3). This is 14 "File text extraction" and R-SU-07 without the optional Tika adapter, which PLAN 9 "Out of scope" leaves to V3.
Read first¶
docs/spec/14-search.md: 1 "Goals and limits" (not searchable: files that are not deliverable), 2 "What is searchable" (rowsfileswith weights A to D), 3.1 "Language per object" ("File text gets the language of the instance"), 3.2 "Expressions" (file_text_extracts,STORED), 4.5 "Ranking and sorting", 4.6 "Highlighting", 5 "File text extraction" with 5.1 "Flow", 5.2 "Types and tools", 5.3 "Limits and protection", 8 "Index maintenance", 10 "Module and port" (file_text_extractsbelongs tofiles), 12 "Tests" (row "Office file with a zip bomb, XML with an external entity"), 13 R-SU-07, 15 "Configuration values of this chapter" (search.extract.*).docs/spec/12-files.md: 2 "States of a file" (definitions "servable" and "final"; which changes writefile.updated), 3.4 "Completion and verification pass" (last paragraph: extraction follows a deliverable file), 3.7 "Purpose of a file", 4.1 "Port" (Getwith range,ErrNotFound), 6.1 "Type" (detectedmime_type), 10 "Attaching and agent invocation", 12 "Retention, deletion, deduplication", 14 "Observability".docs/spec/03-data-model.md(core contract, read only): 1 "Principles" items 2, 12 and 13; 4.7 tablesfiles,message_attachments(with the copiesthread_idandchannel_idof R-DM-43) andfile_text_extracts; 5 "Search vectors" (rowfile_text_extracts); 6.3 "Lock order".docs/spec/02-architecture.md: 4.2 (rowfiles), 4.3 (rowfiles: portTextExtracts, see Design note 3), 4.4 (rowfilesmay useidentity), 5 "Layers within a module", 6 "Code layout" (test helpersinternal/<module>/adapters/...test), 7.2 "go-arch-lint" (vendors per adapter), 8.5 "What never happens in a transaction", 12.1 "Usage" (job arguments only IDs), 12.2 "Queues" (files,maintenance), 12.3 "Job catalogue" (rowfiles.extract_text), 12.4 "Rules for jobs" (rules 1, 2, 5 and 6), 13 "Configuration loading" item 5.docs/spec/19-security.md: 7 "Upload hardening" SEC-UP-5 and SEC-UP-7 (Design note 6 amends both), 9 "Logs without secrets and without content" (SEC-LOG-4), 14 "Tests" SEC-T08 and SEC-T10.docs/spec/20-privacy.md: rowfile_text_extracts(classcontent, "with the file") and DP-T01.- ADRs: 0007 (sqlc and goose), 0008 and 0050 (module register, vendor groups), 0010 (object store), 0032 (search in PostgreSQL), 0033 (only for the scan states this ticket builds on).
docs/DECISIONS.md: D-K-G15 (file_text_extractstaken into the core contract), D-SU-04 (R-SU-07), D-FI-04, D-W-100, D-W-104 (R-DM-43), section 10 (format ofD-M3-15-<n>).docs/security/threat-model.md5.8 "Files, object storage, virus scanner" (rows D and E) and T-UP-01;docs/RISKS.mdRK-38 (context only: a vector expression change rewrites a table).docs/observability/signals.md: 2 "Cardinality", 4.4 (bothy.job.duration,bothy.job.queuewithbothy.job.kind), 5.2 "Spans" (job <bothy.job.kind>), 5.3 "Attributes in spans and logs" (bothy.file.id,bothy.reason).docs/operations/configuration.md: section 4 "14 Search" (search.extract.max_input_bytes,search.extract.max_text_bytes,search.extract.max_uncompressed_bytes,search.extract.max_archive_entries,search.extract.timeout,search.extract.tika_url),jobs.queue.files.max_workers,jobs.queue.maintenance.max_workers,database.statement_timeout,file.scan.mode.docs/operations/upgrade.md4 "Rules for migrations (Expand/Contract)".docs/PLAN.md: 3 "Way of working per ticket"; 5 "Test strategy" (row "Security": Go fuzzing of the parsers); 9 "M3 Platform services" (Goal, Scope, Out of scope "Tika (V3)", Exit criteria 1, 2 and 4, Test strategy "decompression bombs and archive limits", Red team T-UP-01, Risks) and the rows M3-03, M3-04, M3-07, M3-13, M3-14 (before), M3-06, M3-08 (beside), M3-16 (after). No ticket of the plan depends on M3-15.- Briefs:
docs/briefs/M3-04-clamd-scan.md(the outcome write of its note 6 and the place its note 13 reserves forfiles.extract_text,domain.Deliverable,adapters/jobs,HandlerOptions.Timeout,clamdtest),docs/briefs/M3-14-search-coordinator.md(SearchFiles,searchkit.Query,searchkit.Page, its note 6 on files and note 15 on attachment facts),docs/briefs/M3-13-search-vectors.md(identity.Directory.InstanceSearchLanguage,kernel/searchlang, the text-and-cast rule of its note 7),docs/briefs/M3-03-upload-sessions.md(migration and grant pattern,app/events.go),docs/briefs/M3-01-object-storage-fs.md(ObjectStore,ErrNotFound),docs/briefs/M3-06-image-metadata-thumbnails.md(only for the boundary: images become deliverable infiles.thumbnail), M1 briefs for the interfaces used: M1-07 (KindInt,KindDuration,Min), M1-08 (tools/configdoc/planned.yaml), M1-12 (migrations, grants of D4,pgtest), M1-13 (txn.UnitOfWork), M1-14 (internal/platform/jobs:Args,Register,HandlerOptions,PeriodicJob,Inserter.InsertTx, the argument and kind checks), M1-70 (tools/lint/sql_visibility.yaml, categorysystem_job), M1-71 (test/harness,BuildMatrixWorld). - Dependencies: the header names M3-14 (D-W-106); M3-04 comes through M3-14 and M3-07. M3-04 implies M3-03, M3-01, M1-55 and their chain; M3-14 implies M3-13 and M1-56. This brief also builds on what M3-14 itself needs, which its header names or implies (D-W-106): M3-07 (
message_attachments,MessageCreate.attachment_file_ids), M1-70 (SQL visibility lint) and M1-71 (harness). Design note 0 checks them. - Code (paths under
services/server/unless they start withdocs/ortools/):internal/files(port,domain,app,adapters/postgreswithqueries/search.sql,adapters/jobs,adapters/fs,adapters/sniff),internal/identity(portDirectory),internal/kernel/searchlang,internal/kernel/txn,internal/platform/jobs,internal/platform/pg/pgtest,internal/wire(configkeys_search.go,configchapters.go,configchecks.go),migrations/,sqlc.yaml,architecture/modules.yaml,test/harness,tools/lint/sql_visibility.yaml,tools/configdoc/planned.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Table
file_text_extractsexactly as 03 4.7, with the generated column of 03 5 writtenSTORED, owned and read only byfiles(03 1 item 12, 14 10, 14 3.2). - When a file becomes deliverable (12 2), has a supported detected type and purpose
attachment, the same transaction inserts its row withstate = 'pending'and the jobfiles.extract_textin queuefileswithInsertTx; the job arguments hold only the file id (14 5.1 item 1, 02 12.1, 12.3). - The job calls the port
TextExtractor(Extract(ctx, reader, mime)giving text andtruncated) and endsdonewith at mostsearch.extract.max_text_bytesof text,truncated,extractor(name and version) andextracted_at;failedwith a code without content inerror; orunsupported(14 5.1 items 2 and 3, 03file_text_extracts). - Types and tools exactly as 14 5.2 without the row
tika: text formatsbuiltin(valid UTF-8, invalid bytes replaced),text/htmlwithgolang.org/x/net/html(text nodes only, scripts and styles dropped), DOCX, XLSX and PPTX witharchive/zipandencoding/xml(document, shared strings, slides), ODT, ODS and ODP (content.xml); pure Go, no cgo, no external process, no network (R-SU-07). - Limits (14 5.3): only files up to
search.extract.max_input_bytes; archives at mostsearch.extract.max_uncompressed_bytesinflated and at mostsearch.extract.max_archive_entriesentries, otherwisefailed; at mostsearch.extract.timeoutper file. - XML only through
encoding/xmland never with an external entity loaded; an Office file with a zip bomb and XML with an external entity endfailed, and nothing is fetched (14 5.3, 14 12). - A rise of an extractor's version re-extracts the older rows through a maintenance job (14 5.1 item 4).
search_languageof a row is the language of the instance (14 3.1, 03).- File text is content of class
content: never in logs, spans, metrics, job arguments, events, audit details or problem bodies, and deleted with the file (14 5.3, 20, SEC-LOG-4, 02 12.1). SearchFilesmatchesfile_text_extracts.search_vector(weight C for stems, D for words) next to name (A) and transcript (B), under the same rights filter, the same deliverable and attached rules and the same candidate limit (14 2, 4.3, 4.5, 10).- Parsers of untrusted formats run only in the worker's jobs, never in a request of the API process; the server never writes archive entries to storage and never opens nested archives (SEC-UP-5, SEC-UP-7 as amended in Design note 6).
- No object store call inside a database transaction (02 8.5); every job run repeats without double effect (02 12.4 rule 1).
Scope¶
All paths under services/server/ unless they start with docs/ or tools/. Where M3-04 or M3-14 already created a file named below, extend it.
Create:
migrations/<next>_files_text_extracts.sql(Design note 1) and its line inmigrations.sha256throughmake migrations-lock.internal/files/domain/extract.go(standard library andkernel-coreonly): the states, the closed set of failure codes of Design note 4,ExtractApplies(Design note 2),RefreshDecision(Design note 9) andHalveText(Design note 8).internal/files/domain/textbuf.go: the boundedTextBuilderof Design note 5.internal/files/app/extract.go: the outbound portTextExtractorandExtractResult(Design note 3), the outbound portExtractQueue, the schedule function of Design note 2, the use caseExtractText.Run(ctx, fileID ids.ID) error(Design note 4) and the repository methods it needs.internal/files/app/extract_refresh.go: the use caseExtractRefresh.Run(ctx, after ids.ID) error(Design note 9).internal/files/adapters/extract/:doc.go,registry.go(Registryimplementsapp.TextExtractor;Config{MaxTextBytes, MaxUncompressedBytes int64; MaxArchiveEntries int}),text.go,html.go,zipread.go,xmltext.go,ooxml.go,odf.go, tests, fuzz targets with seed corpora,testdata/golden/(Design note 3), andextracttest/(fixture builders of Design note 14).internal/files/adapters/jobs/extract.go: argsExtractText struct { FileID ids.ID }withKind() = "files.extract_text"andExtractRefresh struct { After ids.ID }withKind() = "files.extract_text_refresh", their handlers, and theExtractQueueimplementation overjobs.Inserter.internal/files/adapters/postgres/queries/text_extracts.sql: the queries of Design notes 2, 4, 8, 9 and 10; regenerated sqlc code.
Change:
- The outcome write of M3-04 (one function in
internal/files/app, M3-04 note 13): call the schedule function of Design note 2 for a deliverable outcome inside its unit of work. Do the same in every other place where merged code makes a file deliverable (Design note 2). - Every merged code path of
filesthat setsfiles.deleted_at: delete the file's extract row in the same unit of work (Design note 10). internal/files/adapters/postgres/queries/search.sqland theSearchFilesread operation of M3-14: the file text branch, the combined rank and the headline input of Design note 11.internal/wire: inconfigkeys_search.go(M3-13, M3-14) the keys of Design note 12 withchapter14; the expected keys inTestConfigSchemaBuilds; construction of theextract.Registryfrom the configuration;identity.Directoryfor the files app if not yet passed; registration offiles.extract_text(queuefiles, no River job timeout) and the periodicfiles.extract_text_refresh(queuemaintenance,Interval = search.extract.refresh_interval,RunOnStart = true) for the rolesworkerandall; regeneratetestdata/txn_registrations.goldenonly if a registration changes.internal/platform/jobs: no change; after M3-04HandlerOptionshas the per-kind timeout that M3-04 note 7 describes (zero: River default; negative: none), with its test and README line.architecture/modules.yaml: a vendor group forgolang.org/x/net/html(golang.org/x/net/html,golang.org/x/net/html/**; reuse a group of the same import path if one exists) in theadapter_vendorsoffiles; regenerate.go-arch-lint.ymlwithcd services/server && go generate ./tools/archgen.go.mod,go.sum:golang.org/x/netas a direct dependency at a released version (keep the version already ingo.sumif one is there and it has thehtmlpackage); BSD-3-Clause is on the allowed list (make licenses).sqlc.yaml: the new query file in thefilesentry, if the entry lists files.tools/lint/sql_visibility.yaml(M1-70): one exception with categorysystem_joband a reason for each new read of the jobs (Design note 13); the search query needs none.tools/configdoc/planned.yaml(M1-08): an exact rulesearch.extract.tika_urlwith milestoneV3; remove every rule that becomes stale.docs/spec/14-search.md(not core contract): 5.1 (trigger only for purposeattachment,unsupportedat run time, the refresh job with its staleness rule and its keys, deletion withfiles.deleted_at), 5.2 (the type list of Design note 3, the parts read per format, adapter names with versions,tikabuilt in V3), 5.3 (the archive and XML rules of Design notes 6 and 7, memory bound, the vector limit of Design note 8), 4.6 or 10 (headline input and configuration for files, Design note 11), 15 (rowssearch.extract.refresh_intervalandsearch.extract.refresh_batch_size).docs/spec/02-architecture.md: 4.3 rowfileswithout the portTextExtracts(Design note 3); 12.3 rowfiles.extract_textwith trigger "aftercleanorskipped, purposeattachment, supported type" and a new rowfiles.extract_text_refresh(files, periodic everysearch.extract.refresh_interval,maintenance, 14).docs/spec/12-files.md3.4 last paragraph: the extraction is the jobfiles.extract_textfor attachments, with a link to 14 5.docs/spec/19-security.md: SEC-UP-5 and SEC-UP-7 as Design note 6 words them.docs/operations/configuration.mdregenerated withmake config-reference.internal/files/README.md(English, like the other module READMEs): jobs, the outbound portTextExtractor, the adapterextract, the table, the memory bound, the deletion rule, the residuals;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M3-15-<n>(Design note 15).
This ticket enables no operation, adds no action, event, audit entry, metric, span name and no top-level Makefile target.
Out of scope¶
- The Tika adapter,
search.extract.tika_urlin the schema, PDF, RTF, EPUB and the older Office formats (PLAN 9 "Out of scope": V3). - The search coordinator, the rights filter, filters, cursor, highlighting segments and
searchThreadTitles(M3-14); this ticket changes only the SQL ofSearchFiles. - Upload, scan, type detection and the outcome states (M3-03, M3-04); images and
files.thumbnail(M3-06); attachments,message_attachmentsandFileFinalized(M3-07); the deletion and purge jobsfiles.cleanup_pendingandfiles.purge_objects(M3-08), except the extract row deletion of Design note 10. - Transcripts (M3-09); avatars and logos (M3-10): both are never extracted.
- The offline search of the client, which holds no file text (14 6, M4-23); the search load test (M3-16).
- Reassigning
search_languageof existing rows after a change oflocale.default(14 8 "Reassigning the language"): no ticket of the plan owns it (M3-13 reported it). - Export and erasure of file text for a person (
personaldata.Participant, M7-06); the threat model update for the milestone (PLAN 4 item 5). - A metric or span for extraction:
signals.mdnames none; the job wrapper of M1-14 and the job metrics of M1-68 cover the job. - Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M3-15-file-text-extraction; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(files): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same rule applies to the merged code of M3-01, M3-03, M3-04, M3-07, M3-13 and M3-14, whose briefs were written together with this one: follow their names (outcome write, repositories,
SearchFiles, job adapter package, test helpers) and list each difference. - No binary fixture in the repository: every Office, ODF, zip bomb and XML test file is built at test time from Go code (Design note 14); fuzz seed corpora are the text files
go testwrites. - The only network listener in the tests besides PostgreSQL is the loopback HTTP server of the XXE test, started and stopped by that test; it proves that nothing calls it. No Tika container, no real third-party service. Files reach their scan state through
file.scan.mode = offor the fake clamd of M3-04 (clamdtest). - No SQL of
filesnames a table of another module (03 1 item 12, 02 7.3); the instance language comes only throughidentity.Directory. - The extraction never reads a file outside its job and never reads more than
search.extract.max_input_bytesof an object; no extracted text, no part name of an archive and no XML content in any log line, span attribute, metric attribute, job argument or error message.
Design notes¶
-
Start check.
make checkgreen on the fresh worktree, elseVerdict: BLOCKED base branch red. Merged and present, elseVerdict: BLOCKED dependency <id> missing: M3-04 (jobfiles.scanand its outcome write), M3-14 (SearchFilesininternal/filesand the operationsearchenabled), M3-13 (identity.Directory.InstanceSearchLanguage), M3-07 (tablemessage_attachments,MessageCreate.attachment_file_idsenabled), M1-70 (tools/lint/sql_visibility.py), M1-71 (test/harness). -
Migration
<next>_files_text_extracts.sql(numbering and slug by the rules ofmake migrations-lint), one transaction; the table is new and empty, so its indexes are built with it (as M3-03 did forfiles); if the merged lint demandsCONCURRENTLYfor every index, move the two indexes into a second migration with-- +goose NO TRANSACTIONas M3-13 did. Columns, defaults and the CHECK verbatim from 03 4.7, the expression verbatim from 03 5; constraint and index names follow the merged convention (grep -n 'CREATE INDEX\|CONSTRAINT' migrations/*.sql). Add no column, CHECK or index that 03 does not name.-- +goose Up CREATE TABLE file_text_extracts ( file_id uuid NOT NULL, state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','done','failed','unsupported')), extractor text, text_content text, truncated boolean NOT NULL DEFAULT false, search_language regconfig NOT NULL DEFAULT 'simple', extracted_at timestamptz, error text, search_vector tsvector NOT NULL GENERATED ALWAYS AS ( setweight(to_tsvector(search_language, coalesce(text_content, '')), 'C') || setweight(to_tsvector('simple'::regconfig, coalesce(text_content, '')), 'D') ) STORED, PRIMARY KEY (file_id), FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE ); CREATE INDEX file_text_extracts_search_vector_idx ON file_text_extracts USING gin (search_vector); CREATE INDEX file_text_extracts_pending_idx ON file_text_extracts (state) WHERE state = 'pending'; GRANT SELECT, INSERT, UPDATE, DELETE ON file_text_extracts TO bothy_app; -- +goose Down DROP TABLE file_text_extracts;The grant follows M1-12 D4 (no default privileges).
search_languagegoes to SQL as text with a cast (sqlc.arg(search_language)::text::regconfig) and no query selectssearch_vector(M3-13 note 7), so pgx needs no codec forregconfigortsvector. Retention classcontentgoes into the module README. -
Trigger (14 5.1 item 1; Ruling for docs/DECISIONS.md for purpose, row creation and language).
domain.ExtractApplies(purpose string, deliverable, supported bool) boolis true exactly forpurpose = 'attachment', a deliverable file (domain.Deliverableof M3-04, 12 2:upload_state = 'complete',scan_status IN ('clean','skipped'),deleted_at IS NULL) andTextExtractor.Version(mime_type)reporting support. Only attachments are searchable (14 2, M3-14 note 6); the text of an avatar, a logo or a GDPR export package would be a copy of content no search can reach.- The schedule function in
internal/files/app(scheduleTextExtract(ctx, fileID, purpose, mimeType), name free) runs inside the caller's unit of work: whenExtractAppliesholds, it readsidentity.Directory.InstanceSearchLanguage(ctx)(M3-13; the same unit), runsINSERT INTO file_text_extracts (file_id, search_language) VALUES (...) ON CONFLICT (file_id) DO NOTHING RETURNING file_id, and only when a row was inserted callsExtractQueue.EnqueueExtract(ctx, fileID)(InsertTxoffiles.extract_text). An unsupported type gets no row (14 5.1 item 1);unsupportedis written only by the job (note 4b). - Callers: the outcome write of M3-04 for
cleanandskipped, after the state update, in the same unit of work (M3-04 note 13). If the merged code makes a file deliverable anywhere else (M3-06 setsupload_state = 'complete'for image types at the end offiles.thumbnail), call it there too; it is a no-op for unsupported types and stays correct when a later extractor version supports more types.infected,error,failedandpendingnever schedule. - The language is taken once, when the row is created; re-extraction keeps it (14 3.1: a later change of
locale.defaultaffects only new rows, as for messages). - Lock order (03 6.3): the outcome write holds the
filesrow; the insert locks only the new extract row (and takes the foreign key's share lock on thefilesrow it already holds).
-
Port, adapters, types and versions (14 5.1 item 2, 5.2; Ruling for docs/DECISIONS.md for identifiers, the type list and the golden rule).
// TextExtractor is the outbound port of 14 5.1; internal/files/adapters/extract implements it. type TextExtractor interface { // Version names the adapter and version that extracts mimeType, for example // "builtin-ooxml/1"; ok is false when no adapter supports the type. Version(mimeType string) (version string, ok bool) // Extract reads r and returns at most search.extract.max_text_bytes of text. Content // problems return *domain.ExtractError with a code of note 4; errors of r come back // wrapped and unchanged; a done ctx returns ctx.Err(). Extract(ctx context.Context, r io.Reader, mimeType string) (ExtractResult, error) } type ExtractResult struct { Text string; Truncated bool; Version string }- Adapters and types (the detected
mime_typewithout parameters, M3-04 note 4f):builtin-textfor everytext/*type excepttext/htmlandtext/rtf, plusapplication/json(14 5.2:text/plain,text/markdown,text/csv,application/json, source code; source code is what the detector reports undertext/, for exampletext/x-python;text/rtfis in thetikarow);builtin-htmlfortext/html;builtin-ooxmlforapplication/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheetandapplication/vnd.openxmlformats-officedocument.presentationml.presentation;builtin-odfforapplication/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.spreadsheetandapplication/vnd.oasis.opendocument.presentation. Every other type is unsupported. Verify each OOXML and ODF name against what the merged detector (adapters/sniff,github.com/gabriel-vasile/mimetype) returns for the fixtures of note 14 and use the detector's names. - The value stored in
extractoris<adapter>/<n>with an integernper adapter, a constant in code (03: "name and version of the extractor"). - Golden rule:
testdata/golden/<adapter>.txtholds the version and the SHA-256 of the adapter's output over a fixed fixture set of note 14.TestExtractorGoldenfails when the output hash differs from the golden; its-updateflag refuses to write a new hash under an unchanged version and asks to raise the version. So every change of an adapter's output raises its version, and the refresh job of note 9 re-extracts what that adapter extracted before. - No inbound port
TextExtractsis built: no other module reads file text (search reads throughSearchFiles, 14 10), and a port without a consumer would be dead code (M1-05: interfaces enter with their implementing ticket). Remove it from 02 4.3 rowfilesand from the module README. Ruling for docs/DECISIONS.md.
- Adapters and types (the detected
-
One run of
files.extract_text(ExtractText.Run; Ruling for docs/DECISIONS.md for the codes and the error rule). The closed set of codes inerror(03: "code without contents"; constants indomain):input_too_large,archive_invalid,archive_entries_exceeded,archive_size_exceeded,xml_doctype,xml_invalid,timeout,object_missing.a. Read without a transaction the row and the file facts (
purpose,mime_type,size_bytes,object_key,upload_state,scan_status,deleted_at). No row, or a row that is notpending: returnnil(a duplicate job, 02 12.4 rule 1). File deleted or not deliverable: delete the row in a short unit of work (note 10) and returnnil.b.
TextExtractor.Version(mime_type)without support: writeunsupported(extractor,text_contentanderrorNULL,truncatedfalse,extracted_atnow).c.
size_bytesabovesearch.extract.max_input_bytes: writefailedwithinput_too_largeand the version, without reading the object.d. Outside every unit of work (02 8.5): a context with the deadline
search.extract.timeoutderived from the job context;ObjectStore.Get(ctx, object_key, nil);Extractover the body wrapped in a reader limited tosearch.extract.max_input_bytesplus one byte (more bytes giveinput_too_large); close the body. Mapping:*domain.ExtractErrorgivesfailedwith its code; the extraction deadline givesfailedwithtimeout;ErrNotFoundfrom the object store givesfailedwithobject_missingand one Error log (a deliverable file must have its object); a cancelled job context (shutdown) returnsctx.Err()(M1-14); any other error of the object store or the reader is transient: return it to River, the row stayspending, River's default retry applies (02 12.4 rules 4 and 5), and note 9 re-queues a row whose job River discarded.e. Write the result (note 8). Time comes from
kernel/clock(extracted_at), never SQLnow().Register
files.extract_textin queuefileswith no River job timeout (HandlerOptions.Timeoutnegative): the use case enforcessearch.extract.timeoutitself on read and parse, and River's default job timeout could cut the result write (M3-04 note 7); database work stays bounded bydatabase.statement_timeout. -
Plain text and HTML (14 5.2 rows 1 and 2).
domain.TextBuilder(pure) collects text up tosearch.extract.max_text_bytesbytes: invalid UTF-8 becomes U+FFFD, NUL and every other C0 control character except tab, line feed and carriage return become a space (PostgreSQLtextrejects NUL), separators are written once (no run of separators), a cut never splits a rune,truncatedis set when anything was dropped, andFull()tells the adapter to stop reading.builtin-textstreams the reader into the builder and stops atFull(): a UTF-8 byte order mark is removed; a UTF-16 byte order mark (little or big endian) switches to UTF-16 decoding withunicode/utf16; everything else is read as UTF-8. No charset guessing.builtin-htmluses the tokenizer ofgolang.org/x/net/html: only text tokens; everything insidescriptandstyleis dropped; comments and doctype tokens are dropped; entities are decoded by the tokenizer; a start or end tag of a block element (address,article,aside,blockquote,br,dd,div,dl,dt,fieldset,figcaption,figure,footer,form,h1toh6,header,hr,li,main,nav,ol,p,pre,section,table,tbody,td,tfoot,th,thead,title,tr,ul) writes a line feed, inline elements write nothing, soRech<b>nung</b>stays one word. No charset conversion: the bytes are read as UTF-8 through the builder. The tokenizer holds at most the input, whichsearch.extract.max_input_bytesbounds.
-
Archives (14 5.3; Ruling for docs/DECISIONS.md for every rule here).
archive/zipneeds random access, and ranged reads through the object store would cost one request per small read, so the OOXML and ODF adapters read the whole object into memory (at mostsearch.extract.max_input_bytes, enforced by note 4c and 4d) and open it withzip.NewReaderover abytes.Reader.- Any error of
zip.NewReader, including the insecure path error, givesarchive_invalid. - More entries in the central directory than
search.extract.max_archive_entriesgivesarchive_entries_exceededbefore any entry is opened. - Only the fixed parts of note 7 are read, matched by exact name; a part name that occurs twice gives
archive_invalid(an ambiguous archive may be read differently by other tools). Embedded objects and nested archives are never opened, and nothing is written to the file system. - Before inflating, the sum of the declared uncompressed sizes of the parts this format reads is compared with
search.extract.max_uncompressed_bytes; above it givesarchive_size_exceededwithout inflating a byte. While reading, a counter shared by all parts of one file counts the bytes actually inflated and givesarchive_size_exceededwhen it passes the limit; a part that inflates beyond its declared size fails inarchive/zipand givesarchive_invalid, so a lying header never inflates more than it declared. - An entry with the encryption flag or a compression method other than store and deflate gives
archive_invalid. - Peak memory of one extraction is about
search.extract.max_input_bytesplus the run guard of note 7; the README names it together with the parallelismjobs.queue.files.max_workers. - 19 changes (English, same change): SEC-UP-5 names the text extraction of 14 5 (linked) beside the image previews as pure Go parsers that run only in the worker's jobs with a time limit, never in a request of the API process; SEC-UP-7 reads "Archives are never unpacked to storage in the server and never opened nested. The only exception: the text extraction reads single XML parts from OOXML and ODF containers in the memory of the worker, within the limits
search.extract.*." followed by a link to 14 5.3. Reason: 14 5.2 prescribesarchive/zipfor Office files, which contradicts the unconditional SEC-UP-7 today.
- Any error of
-
XML and the parts per format (14 5.2, 5.3; Ruling for docs/DECISIONS.md).
- Decoder:
encoding/xmlwithStrict = true,Entitynil (only the predefined entities) andCharsetReadernil, so a part declaring an encoding other than UTF-8 givesxml_invalid. The firstxml.Directivetoken that is a document type declaration givesxml_doctypeat once: OOXML and ODF parts never need one, and it carries every external entity, parameter entity and internal entity expansion. An undefined entity reference or any other syntax error givesxml_invalid.encoding/xmlhas no way to fetch anything; the adapter package imports no network package (test below). - Run guard: a reader between the part and the decoder fails the part when more than
search.extract.max_text_bytesbytes pass without a<, becauseencoding/xmlbuffers a whole text, comment or attribute token before returning it. When the guard trips, the part ends, the text collected so far is kept andtruncatedis set: that run alone exceeds the text limit. So a part never holds more than about the text limit in one token. - The context is checked at every token; a done context ends with
ctx.Err(). - Elements match by local name only (both the transitional and the strict OOXML namespaces work).
- DOCX:
word/document.xml(missing givesarchive_invalid); text oftelements;tabwrites a tab,brandcra line feed, the end ofpa line feed.delTextandinstrTextare nott, so deleted revisions and field codes stay out. - XLSX:
xl/sharedStrings.xml(missing givesdonewith empty text: a workbook may hold only numbers); text oftelements outsiderPh; a line feed after eachsi. - PPTX: every part named
ppt/slides/slide<N>.xmlwith a decimalN, in ascending numeric order (slide2beforeslide10); text oftelements; a line feed after eachpand between slides. - ODF:
content.xml(missing givesarchive_invalid); insidebody, all character data;swrites a space,taba tab,line-breakand the end ofpandha line feed; the subtreesannotationandtracked-changesare skipped (comments and deleted revisions). - Fixed part names instead of resolving package relationships keep the parser surface small; a document whose producer uses other part names gives
archive_invalidor empty text, never a wrong text. Record the reason.
- Decoder:
-
Writing the result (one unit of work per write).
done:UPDATE file_text_extracts e SET state = 'done', text_content = $text, truncated = $t, extractor = $v, extracted_at = $now, error = NULL FROM files f WHERE e.file_id = $id AND e.state = 'pending' AND f.id = e.file_id AND f.deleted_at IS NULL; the empty string is stored as'', not NULL.failed: the same withtext_content = NULL,truncated = false,error = $code,extractor = $v.unsupported:extractor,text_contentanderrorNULL. No row updated (deleted file, row gone, or another run was first) is no error.- Vector limit (Ruling for docs/DECISIONS.md): PostgreSQL rejects a
tsvectorwhose lexemes exceed its size limit with SQLSTATE54000, which a text ofsearch.extract.max_text_byteswith many distinct tokens (identifiers, hashes in CSV or JSON) can reach. The postgres adapter maps54000on this update toapp.ErrVectorTooLarge; the use case then cuts the text to half its bytes at a rune boundary (domain.HalveText), setstruncatedand writes again in a new unit of work, until the write succeeds (an empty text always does). Never retry the same text. - No
file.updated: 12 2 names the changes that write it, and extraction is not among them;Filehas no extraction field. No audit entry, no in-transaction event. - Logs: one Warn for
failedwithbothy.file.idandbothy.reasonequal to the code, one Error forobject_missing, nothing fordoneandunsupportedbeyond the job wrapper of M1-14. Never the text, a part name, a file name or XML content.
-
Re-extraction (
files.extract_text_refresh, 14 5.1 item 4; Ruling for docs/DECISIONS.md for the job, both keys, the staleness rule, keeping text during a refresh and recovering discarded jobs).- Periodic in queue
maintenancewithInterval = search.extract.refresh_intervalandRunOnStart = true(a new binary brings new versions; River enqueues periodic jobs on the leader only). ArgsExtractRefresh{After ids.ID}; the periodic run starts at the zero id. -
One run, in one unit of work: read the next
search.extract.refresh_batch_sizedeliverable files withpurpose = 'attachment'andid > After, ordered byid, each with its extract row if any (state,extractor,error; aLEFT JOIN), and decide each with the puredomain.RefreshDecision(row, current string, supported bool, maxInputBytes int64):Row Decision none, type supported create the row as note 2 does and enqueue files.extract_textnone, type unsupported nothing pendingenqueue files.extract_textagain (recovers a job River discarded after its attempts; a duplicate finds the row no longerpending)done,failedorunsupported, type supported,extractordiffers from the current version (NULL differs from every version)reset to pendingand enqueuefailedwithinput_too_large, type supported,size_bytesnow withinsearch.extract.max_input_bytesreset to pendingand enqueueany other nothing -
Reset is a compare-and-set:
UPDATE ... SET state = 'pending' WHERE file_id = $id AND state = $seen_state AND extractor IS NOT DISTINCT FROM $seen_extractor. It keepstext_content,truncated,extractorandextracted_at, so the file stays findable by its old text until the job writes the new result (note 11 reads the vector of any state). - In the same unit, delete up to
search.extract.refresh_batch_sizeextract rows whose file hasdeleted_atset (catch-up for note 10). - When the batch was full, insert the next
ExtractRefresh{After: <last file id>}withInsertTxin the same unit; otherwise the walk ends.
- Periodic in queue
-
Deletion (14 5.3, 20, DP-T01; Ruling for docs/DECISIONS.md). The text goes when the file goes, not only when the row is purged: every merged code path of
filesthat setsfiles.deleted_at(message deletion with attachments through M3-07,deleteFileand the cleanup of M3-08 if merged) deletes the extract row in the same unit of work, after thefilesrow (03 6.3), through one repository function. The message deletion of M3-07 is always such a path (M3-07 note 7 c keeps thedeleted_atwrite in one function offiles/appfor this). The job (note 4a) and the walk (note 9) remove rows of deleted files as a catch-up;ON DELETE CASCADEremoves the row with thefilesrow. -
SearchFiles(14 2, 4.5, 4.6, 10; extends M3-14 note 6; Ruling for docs/DECISIONS.md for the union, the rank and the headline). The file text joins the candidates through a second branch with its own GIN-indexed predicate, so neither branch needs anORacross tables:WITH by_file AS ( SELECT f.id, f.created_at FROM files f JOIN message_attachments a ON a.file_id = f.id WHERE f.search_vector @@ @tsq::tsquery AND a.channel_id = ANY(@channel_ids::uuid[]) -- plus the deliverable, attached, filter and reference-time conditions of M3-14 ORDER BY f.created_at DESC, f.id DESC LIMIT @candidate_limit ), by_text AS ( SELECT f.id, f.created_at FROM file_text_extracts e JOIN files f ON f.id = e.file_id JOIN message_attachments a ON a.file_id = f.id WHERE e.search_vector @@ @tsq::tsquery AND a.channel_id = ANY(@channel_ids::uuid[]) -- plus the same conditions ORDER BY f.created_at DESC, f.id DESC LIMIT @candidate_limit ), cand AS ( SELECT id, created_at FROM by_file UNION SELECT id, created_at FROM by_text ORDER BY created_at DESC, id DESC LIMIT @candidate_limit )@candidate_limitissearch.max_candidatesplus one, exactly as M3-14 computescandidates_capped; the limit applies to the union, so a file found by name and by text counts once (14 4.5 item 1). Use the attachment columns the merged M3-14 query uses (R-DM-43).- Score:
ts_rank(f.search_vector || coalesce(e.search_vector, ''::tsvector), tsq, search.rank_normalization)over aLEFT JOINof the extract row, then the recency division of M3-14; the weights of 03 5 make a name hit (A) rank above a transcript hit (B) above a text hit (C, D). - Headline input:
original_name, the transcript andtext_content, joined by line feeds, cut tosearch.headline_input_max_chars; configurationcoalesce(e.search_language, 'simple')instead ofsimple, so a stem match in the document text is highlighted. Segments, markers and options stay as M3-14 builds them. - The extract row's state is not filtered:
failedandunsupportedhave an empty vector, apendingrefresh keeps its old text (note 9). - The query keeps
channel_id = ANY(...)in every branch, so the SQL lint needs no exception for it.
-
Configuration (register in
internal/wire/configkeys_search.gowithchapter14; kinds of the M1-07 schema,Min1 for sizes and counts and a positive minimum for durations):search.extract.max_input_bytes,search.extract.max_text_bytes,search.extract.max_uncompressed_bytes,search.extract.max_archive_entries(KindInt),search.extract.timeout(KindDuration), and two new keys this ticket adds to 14 15 in the same change:search.extract.refresh_interval(KindDuration, default24h, "interval of the maintenance job that re-extracts rows of older extractor versions") andsearch.extract.refresh_batch_size(KindInt, default500, "files per run of the maintenance job"). No cross-check: no combination of these values is unsafe.search.extract.tika_urlstays out of the schema (V3); a configuration that sets it is rejected as an unknown key by the loader of M1-07, andtools/configdoc/planned.yamlshows it as planned for V3. Regeneratedocs/operations/configuration.md;make config-doc-checkcompares schema and chapter. -
Observability and lint. No new signal: the job wrapper of M1-14 gives the span
job files.extract_textandjob files.extract_text_refreshwithbothy.job.kindandbothy.outcome, and M1-68 recordsbothy.job.durationper kind. Failed extractions are not a separate metric becausesignals.mdnames none (Ruling for docs/DECISIONS.md). The reads of the job and the walk overfilesandfile_text_extractshave no principal and no channel set: list each underexceptionsintools/lint/sql_visibility.yamlwith categorysystem_joband a reason; writes are not checked by the lint. -
Fixtures and seams.
internal/files/adapters/extract/extracttestbuilds every test file witharchive/zipand string templates at test time: DOCX, XLSX, PPTX, ODT, ODS and ODP with given texts, written in the entry order Office and LibreOffice use ([Content_Types].xml,_rels/.rels, then the parts; for ODF a storedmimetypeentry first), so that the detector of M3-04 recognises them; the zip bomb variants of note 6 (declared size over the limit, a lying header written withzip.Writer.CreateRaw, too many entries, encrypted flag, unknown method, duplicate part name); and the XML variants of note 7 (externalSYSTEMentity with anhttpURL and with afileURL, parameter entity, internal entity expansion, plain document type declaration, undefined entity, a non-UTF-8 encoding declaration). Every test that relies on detection asserts the detected type first, so a fixture the detector misreads fails loudly instead of testingunsupported.- The version-bump test constructs the
filesuse cases directly with the real repository (pgtest), the file system driver in amktemp -droot and a wrapper around the realextract.Registrythat reportsbuiltin-ooxml/2and supports one more type (text/rtfthrough the text adapter); production code has no option for this. - Integration tests through
internal/wireuse the real registry with the configuration keys set low where a test needs a limit to trip.
-
Rulings for
docs/DECISIONS.mdsection 10 (one row each,D-M3-15-<n>, with reason): trigger only for deliverable attachments of supported types, row and job in the transaction that makes the file deliverable, language taken once (note 2);unsupportedwritten only by the job (notes 2 and 4); the type list,<adapter>/<n>identifiers and the golden rule that forces a version rise (note 3); no inbound portTextExtracts(note 3); the closed code set,object_missingand the transient error rule, no River job timeout (note 4); text normalisation, UTF-16 by byte order mark only, HTML block elements and no charset conversion (note 5); the whole object in memory for archives, declared sizes of the read parts against the inflation limit, duplicate names, encryption and methods, and the wording of SEC-UP-5 and SEC-UP-7 (note 6); rejection of every document type declaration, UTF-8 only, the run guard, local-name matching and the fixed parts per format (note 7); halving on the vector limit, no event and no audit (note 8); the refresh job, its two keys, the decision table, keeping text during a refresh and the catch-up deletion (note 9); deleting the text whendeleted_atis set (note 10); the union of two branches, the combined rank and the headline input and configuration (note 11); no extraction metric (note 13).
Tests (write them first)¶
Unit:
internal/files/domain:TestExtractApplies(table over purposeattachment,avatar,branding,gdpr_exporttimesclean,skipped,infected,error,pending,failedand deleted, times supported: true only for a deliverable attachment of a supported type);TestRefreshDecision(one row per line of the table in note 9, plusdonewith the current version andfailedwith another code under the current version, both nothing);TestTextBuilder(cut exactly at the limit with a multi-byte rune at the boundary,truncatedset only when something was dropped, invalid UTF-8 to U+FFFD, NUL and C0 controls to a space with tab and line feed kept, separators never doubled,Full());TestHalveText(rune boundary, empty input stays empty, repeated halving reaches the empty string).internal/files/adapters/extract:TestVersions(each listed type maps to its adapter;text/html,text/rtf,image/png,application/zip,application/pdfandapplication/octet-streamare mapped or unsupported exactly as note 3 says; identifiers have the form<adapter>/<n>);TestExtractorGolden(note 3, including the refusal of-updateunder an unchanged version);TestBuiltinText(plain UTF-8, UTF-8 with byte order mark, UTF-16 LE and BE with byte order mark, invalid bytes, NUL, CSV, JSON,text/x-python, truncation atsearch.extract.max_text_byteswithtruncatedand no read beyond what the builder needed);TestBuiltinHTML(script and style text absent, entities decoded, block elements separate words,Rech<b>nung</b>givesRechnung, comments absent, broken markup still gives text);TestDOCX(paragraphs, tabs and breaks,delTextandinstrTextabsent, strict namespace, missingword/document.xmlgivesarchive_invalid);TestXLSX(shared strings in order,rPhabsent, no shared strings part gives empty text);TestPPTX(slide2beforeslide10, text of all slides);TestODF(ODT, ODS and ODP;s,tab,line-break; annotations and tracked deletions absent; missingcontent.xmlgivesarchive_invalid);TestZipLimits(too many entries givesarchive_entries_exceededand no entry opened; declared size of the read parts oversearch.extract.max_uncompressed_bytesgivesarchive_size_exceededwith zero bytes inflated; a lying header givesarchive_invalidafter at most the declared size plus one byte inflated; the shared inflation counter trips across two parts; encrypted entry, unknown method, duplicate part name and non-zip bytes givearchive_invalid);TestXMLDoctypeRejected(each XML variant of note 14:xml_doctypefor every document type declaration,xml_invalidfor the undefined entity and the non-UTF-8 declaration, in DOCX and in ODT);TestXMLRunGuard(a text run longer thansearch.extract.max_text_bytesends the part with the earlier text kept andtruncatedset; a counting reader shows the decoder consumed at most the limit plus one read buffer);TestExtractContextDeadline(a deadline already passed and a stalling reader both end with the context error, for every adapter);TestExtractImportsNoNetwork(go list -deps ./internal/files/adapters/extractcontains neithernet,net/httpnoros/exec).- Fuzz (seed corpora run in every
go test):FuzzExtractText,FuzzExtractHTML,FuzzExtractOOXML,FuzzExtractODF: never panic; the result is either text of at mostsearch.extract.max_text_bytesbytes, valid UTF-8 and without NUL, or an*domain.ExtractErrorwith a code of note 4. internal/files/appwith fakes (object store counting reads, scripted extractor, fake clock, fake repository, fakeidentity.Directory):TestScheduleTextExtract(row with the instance language and one job for a supported attachment; nothing for an unsupported type or another purpose; a second call inserts no second job);TestExtractTextRun(notpending: no effect; file deleted: row removed and no object read; unsupported at run time:unsupportedwith NULLextractor; size oversearch.extract.max_input_bytes:failedinput_too_largewithout a read; success:donewith version and clock time; an extractor code:failedwith that code and NULL text;ErrNotFound:failedobject_missing; another store error: error returned and row stillpending; extraction deadline:failedtimeout; cancelled job context:ctx.Err());TestExtractTextVectorLimit(repository answersErrVectorTooLargetwice: two halvings at rune boundaries,truncatedtrue, three writes);TestExtractRefreshRun(each decision of the table, the compare-and-set skips a row changed in between, the next job is inserted only after a full batch, deleted files lose their rows).
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain and the M1-71 harness with BuildMatrixWorld as the M3-14 tests use it; the file system driver in a mktemp -d root; file.scan.mode = off unless a test names the fake clamd; uploads with createUpload, uploadPart and completeUpload and attachment with createMessage and attachment_file_ids, all by Alex in Support/Allgemein (G-1 gives upload); Sam reads only Marketing; jobs run through the real platform/jobs runtime in role all or by calling the handlers directly, never by sleeping for a window):
TestMigrationFileTextExtracts: catalog check of columns, types, defaults, the CHECK, the primary key, the foreign key withON DELETE CASCADE, the GIN index and the partial index; the generated column is stored (attgenerated = 's') with the expression of 03 5;bothy_apphas exactlySELECT, INSERT, UPDATE, DELETE;make migrations-lintis green.TestDOCXFoundByContentWords(plan row, 14 2): a DOCX namednotes.docxwhose document holds "Die Rechnung fur den Lieferantenvertrag ist fertig." with U+00FC (u with diaeresis) as the second letter of "fur" is uploaded, attached and extracted withlocale.defaultGerman; the row isdonewithbuiltin-ooxml/1,search_languagegermanandtruncatedfalse. Alex'ssearchwith typefilesandq=Lieferantenvertragreturns the file with a matching segment;q=Rechnungenfinds it through the stem (weight C); a word in neither name, transcript nor document gives no hit; Sam gets no hit for any of these queries. A variant with the fake clamd of M3-04 answeringstream: OKproves the trigger forclean.TestOfficeAndTextFormatsFound: XLSX, PPTX, ODT, ODS, ODP, a plain text file, a CSV file and an HTML file are each found by a word only in their content; the HTML file is not found by a word only inside itsscriptelement.TestZipBombFailedWithoutInflating(plan row, 14 12, SEC-T08 file text part): withsearch.extract.max_uncompressed_bytesandsearch.extract.max_archive_entriesset low, the bomb variants of note 14 endfailedwitharchive_size_exceeded,archive_invalidandarchive_entries_exceeded,text_contentNULL,extractorset; the counting seam of the adapter shows that no part was inflated beyond its limit; the file stays deliverable and is still found by its name.TestXXEFailedWithoutFetching(plan row, 14 12): a DOCX and an ODT whose XML part declares an external entity pointing at a loopback HTTP server started by the test, and at afileURL of a file in amktemp -ddirectory holding a canary value, both used in the text; each row isfailedwithxml_doctype; the HTTP server counted zero requests; the canary appears in no column offile_text_extracts, in no captured log line and in no span.TestNewExtractorVersionReextracts(plan row, 14 5.1 item 4; construction of note 14): a DOCX and a text file aredoneunder version 1 and an RTF file has no row; with the wrapper reportingbuiltin-ooxml/2and supportingtext/rtf, andsearch.extract.refresh_batch_sizeset to 1, the walk chains three runs; the DOCX row ispendingwith its old text and still found during the refresh, thendonewithbuiltin-ooxml/2and a laterextracted_at; the text row keeps itsextracted_at; the RTF file gets a row and is extracted; a second walk changes nothing.TestRefreshRecoversDiscardedJob: apendingrow without a queued job is extracted after one walk.TestExtractOnlyForDeliverableAttachments: with the fake clamd answering aFOUNDand anERRORline, and with a checksum mismatch, no row and nofiles.extract_textjob exist; an unsupported type (image/png) gets no row; a file of another purpose built with the files repository directly gets no row.TestExtractLimits: a text file oversearch.extract.max_input_bytesendsfailedinput_too_largewithout an object read; a text oversearch.extract.max_text_bytesendsdonewithtruncatedand exactly the limit or less; a stalling object store fake undersearch.extract.timeoutendsfailedtimeout.TestExtractVectorLimit: a text file ofsearch.extract.max_text_bytesbytes of distinct hex tokens endsdonewithtruncatedtrue and a non-empty vector, without an endless retry.TestExtractRowRemovedWithFile(DP-T01 file text part, 14 5.3): deleting the message that carries an extracted DOCX (deleteMessage, through the merged soft-delete path) removes the extract row in the same transaction andsearchfinds nothing; the path that setsfiles.deleted_atthere is the one of M3-07 (note 10). Deleting thefilesrow as the owner role removes the extract row by cascade; the walk removes a row whose file hasdeleted_atset.TestExtractNoContentInTelemetry(SEC-LOG-4, SEC-T10 for this path): a document whose text and file name carry canary values, one run endingdoneand one endingfailed; captured logs, spans and metric attributes contain neither canary; the failure log has onlybothy.file.idandbothy.reason; theriver_jobarguments contain only the file id.TestSearchFilesCombinesVectors(14 2, 4.5): of two files of the same age, the one matching by name ranks above the one matching only by text; a file matching by name and by text appears once; withsearch.max_candidatesset low, files found only by text count towardcandidates_cappedlike files found by name; the M3-14 tests ofsearchstay green.TestExtractJobsRegistered:files.extract_textin queuefilesandfiles.extract_text_refreshperiodic in queuemaintenancewithsearch.extract.refresh_intervalandRunOnStart, both only in rolesworkerandall; both kinds pass the argument and kind checks of M1-14.TestConfigSchemaBuildslists the seven keys with the chapter defaults; a configuration withsearch.extract.tika_urlis rejected as an unknown key.- The SQL visibility lint of M1-70 (part of
make check) passes with the newsystem_jobexceptions and no exception for the search query.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/files/... ./internal/search/... ./internal/wire/... ./internal/platform/jobs/... ./migrations/... ./test/...- a short local run of each fuzz target (
cd services/server && go test -run '^$' -fuzz FuzzExtractOOXML ./internal/files/adapters/extract/, likewise the other three, each stopped after a short-fuzztimeof your choice); report duration and result and commit any crashing input as a seed after the fix make migrations-lock,make migrations-lint,make arch,make codegen-check,make register-check,make config-reference(no diff afterwards),make config-doc-check,make licensesmake docs,make linkcheckmake check
Acceptance criteria¶
-
file_text_extractsexists exactly as 03 4.7 and 03 5 define it, with the grant forbothy_app;make migrations-lintis green. - A DOCX attached to a message is found by
searchwith typefilesthrough words that appear only inside the document, by its readers only (plan row). - XLSX, PPTX, ODT, ODS, ODP, text and HTML files are extracted as 14 5.2 says; script and style content is not.
- A zip bomb and an XML part with an external entity end
failedwithout inflating beyond the limits and without any fetch (plan row, 14 12, SEC-T08 file text part). - A new extractor version re-extracts the older rows, keeps them findable meanwhile and leaves current rows untouched (plan row).
-
search.extract.max_input_bytes,search.extract.max_text_byteswithtruncated,search.extract.max_uncompressed_bytes,search.extract.max_archive_entriesandsearch.extract.timeouthold; a text at the vector limit still endsdone. - Only deliverable attachments of supported types get a row and a job, in the transaction that makes them deliverable; the language is the instance's.
- File text appears in no log, span, metric, job argument or event, and it is deleted when the file is deleted.
- File text ranks below name and transcript and passes the same rights filter and candidate limit as M3-14.
- Chapters 14, 02, 12 and 19,
configuration.md,planned.yaml, thefilesREADME, the CHANGELOG and the rowsD-M3-15-<n>are updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- README of
filesupdated (jobsfiles.extract_textandfiles.extract_text_refresh, the portTextExtractor, the adapterextractwith its types and versions and the golden rule, the table and its retention class, the memory bound of note 6, the deletion rule of note 10, the test requirements); the README ofplatform/jobsstays as M3-04 left it (HandlerOptions.Timeout). - Rulings of note 15 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN), which also checks T-UP-01 for the parsers (zip bombs, external entities, memory); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.