Appearance
Search Service
search-service owns rebuildable search indexes, source-service sync checkpoints, and replay receipts/version tombstones for copied projection events. It stores copied index data only; canonical question, exam, course, and document state stays in the owner services.
Public search-like routes remain legacy-proxied until backfill/event pipelines, gateway adapters, and browser parity evidence exist. Native /v1/* routes are for service tests, migration tooling, service-to-service indexing, and future gateway adapters only.
Current legacy catalog routes are not search-service routes. The legacy snapshot contains /api/public/catalog* under PublicCatalogController, and docs/architecture/legacy-api-map.md maps those public catalog paths to course-service. Search-service may index copied COURSE documents for discovery, but it must not claim /api/public/catalog* or add synthetic /api/search gateway compatibility without an explicit legacy route and owner mapping.
Boundary
Owns:
- Indexed copies for
QUESTION,EXAM,COURSE, andDOCUMENT. - Full-text search over title, summary, content, tags, and taxonomy. Gateway adapters may send legacy-style
q; native callers may sendquery. - Filters for organization, owner, entity type, status, visibility, tags, and taxonomy.
- Native search scope enforcement:
organizationIdorX-Organization-Idis required unless the caller explicitly sendsallOrganizations=truewith an ADMIN actor role. - Sync checkpoints per source service and entity type.
Does not own:
- Canonical question, exam, course, document, profile, wallet, payment, or audit data.
- Business writes or source-of-truth status transitions.
- Gateway response compatibility for public routes until a route-table cutover task explicitly adds it.
Native Routes
| Method | Path | Purpose |
|---|---|---|
POST | /v1/index/documents | Upsert one rebuildable search document. |
POST | /v1/index/documents/bulk | Upsert up to 500 search documents. |
DELETE | /v1/index/documents/{entityType}/{entityId} | Delete one indexed document by optional organizationId. |
POST | /v1/search | Query indexed copies with filters and pagination. |
POST | /v1/rebuild/question-bank/questions | Pull one QUESTION projection page from question-bank-service and index copied documents. |
POST | /v1/rebuild/course-service/courses | Pull one COURSE projection page from course-service and index copied documents. |
POST | /v1/rebuild/exam-service/exams | Pull one EXAM projection page from exam-service and index copied documents. |
POST | /v1/rebuild/document-service/documents | Pull one DOCUMENT projection page from document-service and index copied documents. |
PUT | /v1/sync-state | Upsert source-service sync checkpoint state. |
GET | /v1/sync-state/{sourceService}/{entityType} | Read one sync checkpoint. |
Search Scope
POST /v1/search rejects unscoped requests before storage lookup. Callers must send organizationId in the JSON body or X-Organization-Id in headers. Global search requires allOrganizations=true plus userRole=ADMIN or X-User-Role: ADMIN; otherwise the service returns SEARCH_FORBIDDEN.
This is only a search-service scope guard. Owner services and gateway/BFF contracts still own canonical visibility and authorization decisions before a public search/catalog route can be promoted.
Route Promotion Gate
Before adding a gateway route-table rehearsal for search-service, the task must cite the exact legacy route, controller, and owner mapping. A valid rehearsal is non-default first, proves gateway scope/auth guards, preserves rollback to legacy, and includes browser/runtime parity evidence before any default route promotion.
Current route proof is the pending guard in docs/qa/search-route-rehearsals.md:
bash
make test-search-routesQuestion Bank Rebuild
POST /v1/rebuild/question-bank/questions is an internal job/migration trigger. It requires QUESTION_BANK_SERVICE_BASE_URL on search-service and calls question-bank-service GET /v1/questions/search-projections. The trigger indexes one page, writes sync-state for question-bank-service/QUESTION, and returns hasMore=true with sync-state status PARTIAL while additional pages remain.
Requests may pass page, limit, organizationId, status, includeArchived, and allOrganizations. X-Organization-Id, X-User-Id, and X-User-Role are forwarded when body fields are omitted. Search-service rejects unscoped rebuilds unless allOrganizations=true is paired with ADMIN actor role; when organizationId is present, the rebuild remains org-scoped. When status is omitted, archived questions are included so rebuilds preserve archive/delete state.
For an initial rebuild, start search-service with the matching owner-service base URL configured, then use the migration helper to repeat the one-page trigger until the owner route returns no more pages. The helper accepts --source question-bank, --source course, --source exam, or --source document and validates the returned owner sourceService plus entityType.
bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source question-bankThis remains an internal native workflow. Public search/catalog routes still need gateway adapters, event/subscriber freshness evidence, and browser parity before any cutover.
Course Rebuild
POST /v1/rebuild/course-service/courses is the matching one-page trigger for course-service GET /v1/courses/search-projections. It requires COURSE_SERVICE_BASE_URL on search-service and writes sync-state for course-service/COURSE.
Requests use the same rebuild body fields and scope rules as the question trigger. The body or headers may pass organization and actor scope; status=PUBLISHED can narrow a targeted rebuild, while omitting status preserves archived courses.
bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source courseExam Rebuild
POST /v1/rebuild/exam-service/exams pulls one projection page from exam-service GET /v1/exams/search-projections. It requires EXAM_SERVICE_BASE_URL on search-service and writes sync-state for exam-service/EXAM.
Requests use the same rebuild body fields and scope rules as the question and course triggers. organizationId, userId, and userRole can come from the body or forwarded headers. allOrganizations=true is forwarded as allOrgs=1 only after search-service verifies an ADMIN actor; exam-service only honors that bypass when no explicit organizationId is sent.
Exam projection documents are copied search data only. Runtime secrets remain owned by exam-service: the projection includes requiresAccessPassword and link counts, but does not include access password hashes or access-link codes.
bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source examDocument Rebuild
POST /v1/rebuild/document-service/documents pulls one projection page from document-service GET /v1/documents/search-projections. It requires DOCUMENT_SERVICE_BASE_URL on search-service and writes sync-state for document-service/DOCUMENT.
Requests use the same rebuild body fields and scope rules as the other owner triggers. status=APPROVED can narrow public/catalog-style backfills, while omitting status lets document-service include review/archive states according to includeArchived.
bash
SEARCH_SERVICE_BASE_URL=http://localhost:8095 \
SEARCH_REBUILD_USER_ROLE=ADMIN \
SEARCH_REBUILD_ALL_ORGANIZATIONS=1 \
python3 scripts/migration/rebuild-question-search-index.py --source documentProjection Freshness Worker
For continuous copied-index freshness after the initial rebuild, start search-service with SEARCH_REBUILD_WORKER_ENABLED=1. The worker is internal only: it does not expose new gateway routes and it uses the same RebuildFromProjection path as the manual /v1/rebuild/* triggers.
Worker defaults:
SEARCH_REBUILD_WORKER_SOURCES=question-bank,course,exam,documentSEARCH_REBUILD_WORKER_INTERVAL=5mSEARCH_REBUILD_WORKER_MAX_PAGES=1SEARCH_REBUILD_WORKER_MAX_CONCURRENT_SOURCES=2SEARCH_REBUILD_WORKER_LIMIT=100SEARCH_REBUILD_WORKER_ALL_ORGANIZATIONS=1SEARCH_REBUILD_WORKER_USER_ID=search-service-workerSEARCH_REBUILD_WORKER_USER_ROLE=ADMINSEARCH_REBUILD_WORKER_INCLUDE_ARCHIVED=1
Each tick reads the current sync-state cursor for each configured source, pulls up to the configured page cap, and lets the normal rebuild path write PARTIAL or SYNCED. Owner-source failures are logged and recorded as sync-state ERROR, then the worker continues to the next source so search-service availability is not tied to an owner-service outage. Up to two independent owner sources run concurrently by default; set SEARCH_REBUILD_WORKER_MAX_CONCURRENT_SOURCES=1 when an environment needs a strictly serial rebuild load profile.
Each owner result includes latencyMs covering sync-state lookup, owner fetch, copied-index mutation, and sync-state write. The worker emits that field with the source/entity/page/indexed/cursor fields in structured logs. Use those observations before lowering the interval or raising concurrency; they are not a production end-to-end search latency SLO.
SEARCH_PROJECTION_PULL_WORKER_POSTGRES_CONFIRM=disposable-postgres make test-search-projection-pull-worker-postgres is the local restart/resume proof: it starts the real search-service process, writes one page per owner to a fresh Postgres store, restarts the process, then verifies each persisted cursor/nextPage=2 checkpoint resumes at page two and returns to SYNCED/nextPage=1 through native search and sync-state reads. It does not add a public search or catalog route.
TestProjectionFreshnessWorkerFromEnvRebuildsOwnerFixturesConcurrently holds four disposable HTTP owner-fixture responses until all configured worker calls are in flight. It verifies the internal ADMIN scope and request options, copied index rows, and SYNCED checkpoints without exposing a Gateway route.
Projection Change Event Contract
contracts/events/search.projection.changed.v1.schema.json defines an internal owner-to-search contract for one copied QUESTION, COURSE, EXAM, or DOCUMENT change. Search-service accepts it through the internal use case only; there is no HTTP endpoint or Gateway route.
The event envelope id is stored as a (source,id) receipt. A positive, strictly increasing sourceVersion is required for each (source,organizationId,entityType,entityId) tuple. Search-service writes the receipt, version/tombstone, copied-index UPSERT or DELETE, and freshness state in one transaction. Duplicate events do nothing; same-or-lower versions are recorded stale so an old UPSERT cannot restore a newer delete. Event processing does not overwrite the cursor used by pull rebuilds.
Only these owner/entity pairs are valid: question-bank-service/QUESTION, course-service/COURSE, exam-service/EXAM, and document-service/DOCUMENT. An opt-in subscriber starts only when SEARCH_PROJECTION_EVENT_TRANSPORT=nats is configured. It consumes JetStream stream SEARCH_PROJECTIONS on subject search.projection.changed.v1 with durable ACK/NAK handling. The default is disabled and Question Bank is the only wired owner publisher; course, exam, and document remain pull-rebuild sources. The opt-in pull worker remains the production reconciliation and rollback fallback until all owner outboxes and transport replay proof are complete.
Legacy Evidence
The first generic contract is based on stable legacy and native surfaces:
- Question search uses
q, taxonomy/type/source/status, org/owner scope, pagination, and newest-first defaults. - Course/catalog search uses
q, status, subject/grade, visibility, and public catalog filters. - Admin exam/document search uses
q, status/workflow/creator/owner, visibility, subject/grade, page, and limit filters.
Those are represented as copied index fields only. Owner services still decide what is visible and canonical.
Verification
bash
GOTOOLCHAIN=go1.25.11 go test ./services/search-service/... -count=1bash
GOTOOLCHAIN=go1.25.11 go test -race ./services/search-service/internal/http \
-run TestProjectionFreshnessWorkerFromEnvRebuildsOwnerFixturesConcurrently -count=1 -vOptional Postgres repository integration:
bash
SEARCH_SERVICE_POSTGRES_TEST_DATABASE_URL='postgres://hoctapaz:hoctapaz@localhost:5433/hoctapaz_platform?sslmode=disable' \
GOTOOLCHAIN=go1.25.11 go test ./services/search-service/internal/repository \
-run TestPostgresSearchIndexAndSyncIntegration -count=1 -vProjection-event Postgres proof uses a disposable database only:
bash
SEARCH_PROJECTION_EVENTS_POSTGRES_CONFIRM=disposable-postgres \
make test-search-projection-events-postgresDisposable broker proof for the Question Bank owner path:
bash
SEARCH_PROJECTION_BROKER_SMOKE_CONFIRM=disposable-runtime \
make test-search-projection-broker-liveThis is not a default-route or Gateway promotion.