ADR 0025: Admin company enrichment: record-level manual-edit override and the admin endpoints¶
- Status: Accepted
- Date: 2026-07-25
- Deciders: jobhub-architect (David R H)
- Affects: job-service (new admin read/list/update path, write path sets provenance),
api-contracts (
job-service.yaml: newCompanyUpdateRequestschema + three planned admin operations,CompanyInforeused unchanged), JobHub-ui (new admin enrichment screen), db/init (no new migration, see D4), crawler-service (no change), application-service and notification-service (no change).
Context¶
Story #430 (sub-issue #452), parent #426, US 4 of 5: "admin can enrich company information".
An admin browses stored companies, opens one, and edits its enrichable fields (website,
industry, size, headquarters, description, tags, logoUrl). A field an admin has
edited must win over any later crawl.
Binding constraints from the earlier stories, not reopened here:
- ADR 0023 D1: job-service is the sole writer of
crawler.company. The table physically lives in the crawler schema but is owned, modelled and written by job-service. - ADR 0023 D4:
CompanyInfois frozen with every field this story needs already present (id,slug,website,industry,size,headquarters,description,tags,logoUrl,manuallyEdited,updatedAt), and it explicitly records that per-field provenance stays internal to job-service whilemanuallyEditedis the only provenance a client sees. This story must NOT reopenCompanyInfo. - ADR 0023 D3 + the code: the resolution reconciler (
CompanyResolutionServicetoCompanyPanacheRepository.upsertBySlug) is INSERT-ONLY:INSERT ... ON CONFLICT (slug) DO NOTHINGthen re-select. It performs noUPDATEof an existing company row. - ADR 0024 D3: the manual-edit guard is structural. The crawl/reconcile path cannot
overwrite because it never updates; the one-time logo backfill (
052) carriesWHERE logo_url IS NULL AND manually_edited = false; and there is a standing rule that any future field-refreshUPDATEon an existing company MUST carryWHERE manually_edited = false. - The schema already supports it.
db/init/051-job-company.sqlgivescrawler.companyall the enrichable columns plussource VARCHAR(16)(CHECK IN ('crawl','derived','manual')),manually_edited BOOLEAN NOT NULL DEFAULT FALSEandupdated_at.job_useralready holdsSELECT, INSERT, UPDATE ON crawler.company(051). NoDELETEanywhere by design. - job-service is Hexagonal and stays so: this is a mechanistic REST + persistence extension, not invariant-heavy domain behaviour.
The open questions this ADR closes: (1) override granularity, per-record vs per-field; (2) the shape of the admin endpoints and the update verb/semantics; (3) whether a migration is needed.
Decision¶
D1. Override granularity: per-record manually_edited. No per-field provenance.¶
We keep the existing single per-record manually_edited boolean as the override signal. We do
NOT add per-field provenance (no text[] of edited field names, no per-field boolean columns).
Rationale. Per-field provenance only earns its keep when a write path updates individual fields
of an existing company from a fresh source AND you still want admin-edited fields to win
field-by-field. No such path exists: the reconciler is insert-only (it never updates an existing
row), and ADR 0024 deferred the "reconciler UPDATEs existing companies to refresh" option. So
the ONLY writes that could ever clobber an admin edit are whole-row-guardable UPDATEs, for which
a record-level flag is exactly sufficient. Per-field storage would be speculative machinery for a
crawl path that does not exist, and it would contradict the already-frozen CompanyInfo
contract, which states per-field provenance stays internal and exposes only the record-level
manuallyEdited. This is also consistent with how ADR 0024 treated the logo: the logo backfill
guarded at the record level (manually_edited = false) plus a value guard (logo_url IS NULL);
the logo was never given its own provenance flag. Record-level is the same model, uniformly
applied.
When unsure, CLAUDE.md says prefer the simpler design and record why: this is that call.
D2. Resolution-path rule per editable field (how the crawl path honours the override).¶
The rule is identical for all seven editable fields (website, industry, size,
headquarters, description, tags, logoUrl) because the override is record-level:
- The reconciler (
CompanyResolutionService.resolvePendingtoupsertBySlug) never updates an existing company row. It only inserts on a brand-new slug. Therefore it cannot overwrite any admin-edited field of any existing company, for any field, by construction. This is the primary enforcement and it needs no per-field check. - Any future field-refresh
UPDATEon an existing company MUST carryWHERE manually_edited = false(the ADR 0024 D3 standing rule, restated). Becausemanually_edited = truepins the whole row, a single record-level predicate protects all seven fields at once. There is no field for which a different rule applies. - The admin update itself is the only path that sets the flag. On a successful
PUT /jobs/admin/companies/{id}, job-service setsmanually_edited = true,source = 'manual'andupdated_at = now()in the same statement that writes the edited fields. From that point the record is pinned against every crawl-side write above.
Net: "a field an admin edited wins over a later crawl" is satisfied because editing any field pins the record, and no crawl path updates a pinned (or in fact any existing) record.
D3. Admin endpoints: three operations, admin JWT group, reuse CompanyInfo.¶
Frozen in api-contracts/src/main/resources/openapi/job-service.yaml, all
x-implementation-status: planned, tag Admin, inheriting the global bearerAuth security and
gated in code by @RolesAllowed("admin") (the same pattern as the existing
/jobs/admin/triggers* operations). Naming mirrors /jobs/admin/triggers.
GET /jobs/admin/companies(listAdminCompanies): paginated browse. Query paramsq(case-insensitive substring overname),manuallyEdited(optional provenance filter, the enrichment backlog ismanuallyEdited=false),sort(name-ascdefault,name-desc,updated-desc,updated-asc),page(min 0, default 0),size(min 1, max 100, default 20). Response200is a JSON array ofCompanyInfo(FULL projection,descriptionpopulated, unlike the size-sensitive publicGET /jobssummary) plus anX-Total-Countresponse header with the total match count. Errors:400,401,403,500.GET /jobs/admin/companies/{id}(getAdminCompany): read one company byCompanyInfo.id, full projection.200CompanyInfo;401,403,404,500.PUT /jobs/admin/companies/{id}(updateAdminCompany): enrich the seven editable fields via the newCompanyUpdateRequestbody.200returns the updatedCompanyInfowithmanuallyEdited = true;400(validation),401,403,404(unknown id),500.
Pagination-with-X-Total-Count (array body + header) was chosen over a body page wrapper because
the task calls for the header-count convention (CLAUDE.md REST layer: "use Response when a
header must be set, e.g. X-Total-Count") and it lets CompanyInfo stay the sole response shape
with no new page-wrapper schema.
D4. Update verb: PUT (full editable-set replace), not PATCH.¶
PUT /jobs/admin/companies/{id} with CompanyUpdateRequest carrying all seven editable fields.
Semantics: every editable field is set from the body; a field sent as null (or omitted, which a
generated Jackson model reads as null) CLEARS the stored value to null. id, slug and name
are immutable through this endpoint and ignored if present.
Why PUT over PATCH. A generated Jackson POJO cannot distinguish "key absent" from "key present and
null" (the repo's existing updateSavedFilter PATCH already lives with this: a null field means
"leave unchanged", so it can never CLEAR a field). Admin enrichment specifically needs the ability
to clear a wrong value back to null, and its UX is a form that loads every editable field and
submits them together. Full-set replace on PUT is therefore both unambiguous and generator-safe:
null means clear, and there is no absent-vs-null ambiguity. The frozen client rule is: send the
FULL editable set (echo unchanged values back); an omitted field is a clear, not a no-op.
name is deliberately NOT editable here. slug is the identity key (unique, drives dedup and
logo derivation) and name is the display value the crawl captured or a future story curates;
renaming and merging companies is out of scope for #430 (051 already notes "merges are story #430
territory", but this story is scoped by the orchestrator to the seven enrichment fields, so
rename/merge is explicitly deferred). Keeping name/slug immutable avoids slug divergence.
D5. CompanyUpdateRequest validation (frozen).¶
website: string,format: uri, nullable,maxLength: 2048.industry: string, nullable,maxLength: 80. Free text, not an enum (CompanyInfo reason).size: string, nullable,maxLength: 40. Free text; producers should stay in the CompanyInfo headcount vocabulary so the UI can group, but it is not enum-constrained.headquarters: string, nullable,maxLength: 120. Never back-filled from a posting location.description: string, nullable,maxLength: 2000.tags: array, nullable,maxItems: 20, itemsminLength: 1,maxLength: 40,pattern: ^[a-z0-9]+(-[a-z0-9]+)*$. Duplicates rejected with400, enforced server-side (NOT viauniqueItems: that made the generator emit aSetwith a@JsonDeserializewhose jackson-databind import is absent from the interface-only api-contracts classpath, breaking the build; verified). Null or empty array clears all tags (stored as null, never[]).logoUrl: string,format: uri, nullable,maxLength: 2048. Setting it makes the logo a manual value the crawl/derivation must never overwrite (ADR 0024 guard, now record-level).
D6. Migration: none. CompanyInfo: reused unchanged.¶
No db/init migration is needed for #430. Every column the update writes exists from 051
(website, industry, size, headquarters, description, tags, logo_url, source,
manually_edited, updated_at); source = 'manual' is already permitted by
chk_company_source; job_user already holds SELECT, INSERT, UPDATE ON crawler.company; and
the browse endpoint is a SELECT with LIMIT/OFFSET + COUNT(*) over a ~150-row table needing
no new index. No new grant, role, schema, password or .env key.
Migration-number bookkeeping: the highest committed db/init file is 051. ADR 0024 D5 reserved
052 (logo backfill, #447) and 053 (job-service follow-up). Should #430 ever need a migration
(it does not under this design), it would claim 054; nothing here does.
The contract change is purely additive: one new request schema (CompanyUpdateRequest) and three
new operations. No existing shared schema is touched, so there is no cross-service blast radius of
the #330 kind; CompanyInfo is reused verbatim.
Consequences¶
- Positive. Zero migration, zero new grant, zero schema/contract-shape change to anything
existing. The whole story is a job-service REST + persistence extension plus one additive
request schema. It stays inside the ADR-0023 sole owner of
crawler.company. - Positive. The override stays structural and cheap: one boolean pins the record; the
insert-only reconciler physically cannot overwrite; the one standing
WHERE manually_edited = falserule protects every future refresh. No per-field bookkeeping to keep in sync. - Positive. Consistent with ADR 0024: same record-level guard model the logo work used, now the general rule for all enrichable fields.
- Cost / trade-off. PUT full-set replace means a client that forgets to echo a field will
clear it. Mitigated by the frozen "load-all, submit-all" form contract and the
400/typed responses; documented onCompanyUpdateRequest. This is the deliberate price of being able to clear a field on a generated model. - Cost / trade-off.
name/slugare not editable in this story, so fixing a wrong company name or merging duplicates is deferred. Acceptable: it is out of #430's scope and needs its own identity/merge design. - Follow-on rule for developers. If a later story adds a reconciler or refresh
UPDATEon existing companies, it MUST includeAND manually_edited = false. This ADR and ADR 0024 both record it; it is the single point the record-level model depends on.
Alternatives considered¶
- Per-field provenance (a
text[]of edited field names or per-field boolean columns). Rejected: no write path updates individual fields of an existing company (the reconciler is insert-only, refresh-UPDATE was deferred by ADR 0024), so there is nothing for field-level granularity to arbitrate. It would also contradict the frozenCompanyInfo(per-field provenance is explicitly internal, onlymanuallyEditedis exposed) and add a migration for no behavioural gain. Revisit only if and when a field-level crawl refresh path is actually built. - PATCH (partial merge) for the update. Rejected as the primary verb: on a generated Jackson
model, absent and null collapse, so PATCH could never CLEAR a field (the repo's
updateSavedFilteralready has this limitation), yet clearing a wrong value is a core enrichment need. PUT full-set replace removes the ambiguity. - Body page wrapper (a
CompanyPageschema) instead ofX-Total-Count. Rejected: the task andCLAUDE.mdfavour the header-count convention here, and reusingCompanyInfoas the array item avoids introducing another wrapper schema. - Making
name/slugeditable now. Deferred: slug is the identity/dedup/logo-derivation key and renaming or merging companies is a separate concern with its own invariants; out of scope for #430.