Production's SQLite-shaped Jobs table lacks AUTO_INCREMENT, causing the legacy opportunity backfill to abort startup. Reuse the existing idempotent MariaDB primary-key repair before backfill runs.
Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
- consolidate API ownership and remove dead vendor code
- add Stripe billing, learning paths, and public CV hardening
- add migration, recovery, security, audit, and browser gates
The structured model and StructuredCvProfileJson.FromSections already map
Projects/Certifications/Languages headings, but the AI normalize prompt
never emitted them, so on the benchmark CV the entire Projects section and
the in-summary languages (English Native, Norwegian B1) were silently
dropped. This closes that gap upstream — no backend schema or data change.
ai-service (tools/summarizer/app.py):
- /cv/normalize: added # Projects and # Certifications headings; a
languages-from-prose rule (pull "native English", "Norwegian at B1" out
of the summary even with no Languages section; ignore programming
languages); and skill-group prefix stripping ("Development:",
"DevOps & Infrastructure:", "Practices:" dropped, only the skills kept).
- /cv/classify-block: Projects and Certifications added to the section
enum + rules (fallback path).
Backend:
- LooksLikeNormalizedMarkdownCv now recognises # Projects / # Certifications
so those CVs still take the markdown assembly path.
Tests:
- CvExtractionCoverageTests (4) lock the C# mapping of Projects,
Certifications and Languages sections into the structured profile.
- ai-service test_classify_block_supports_projects_section (1).
426 backend tests, 17 ai-service tests pass; app.py compiles.
The LLM behaviour (prompt -> headings) needs Ollama to observe and was not
run here; the C# side that consumes the headings is proven and the prompt
change is additive. Merge-not-replace + the review screen are the next
increment (2.1-a, approved: always-review, conservative merge).
Deployment: these prompts live in the ai-service container, which
deploy.sh does not rebuild by default -- deploy with
DEPLOY_BUILD_AI_SERVICE=true or the change won't take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-only. No change to APIs, save payloads, extraction behaviour, or data
models. The parent CareerProfilePage still owns loading, state, saving,
and all extraction/import actions; the new sections are presentational
(value + onChange, plus a getMetadata callback for review chips).
Extracted into src/views/career/CareerProfileSections.tsx:
PersonalInformation, ProfessionalSummary, Skills, Interests, Languages,
WorkExperience, Education, OtherSections. FieldReviewNote + confidenceTone
moved there verbatim and shared with the parent. CareerProfilePage went
from 1376 to ~1200 lines.
No Projects/Certifications sections were created -- the editor never had
them (they are not editable structured fields here). Inventing them would
add functionality, which this refactor avoids; noted for a product
decision later.
Hid the duplicate CV concepts behind an "Advanced CV tools" toggle,
collapsed by default: the CV Structure Overview parse block and the
Template-driven CV builder. Both stay mounted and functional (gated with
display:none), so no tested functionality is removed -- the real CV
Builder at /career/builder is the single generation surface. Future
removal plan documented.
Tests: added "editing a field in an extracted section updates parent
state and flows into save" (render -> edit -> PUT /career/profile
{profile,cvText}); existing parse/rewrite tests reveal the advanced tools
first. The increment-1 save-invariant test still pins the payload.
Verified: tsc clean, production build clean, 137 frontend tests pass.
Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.
Terminology -> user-facing (i18n strings):
- "Structured CV editor" -> "Career information"
- "CV structure overview" -> "Profile sections"
- "Summary bullets" -> "Professional summary"
- "Core skills" -> "Skills"
- "Analyze sections" -> "Read sections"
- "Original extraction" -> "Original import"
- hardcoded "Master career profile" -> "Career profile"
Help text de-jargoned; the Career information help now frames it as the
source the CV Builder consumes.
Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.
Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.
Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.
The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On /career/builder/{id} both "Career Workspace" (/career) and "CV Builder"
(/career/builder) highlighted, because AppShell tested each item with
`pathname === to || pathname.startsWith(to + "/")` — so /career matched
every /career/... child. No "most specific wins" rule.
Add AppShell.activeNavTo(pathname, tos): the longest `to` that the path is
at or under wins, across both nav lists; every other item is inactive. A
child route never lights up a parent nav item. `selected` now compares
against that single computed activeTo. Exported as a pure function so the
ownership rule is unit-tested directly (sidebar-active-nav.test.ts):
exactly one active item for /career, /career/builder and
/career/builder/{id}, and no double-highlight.
Also give the breadcrumb/title in App.tsx explicit /career/builder ->
"CV Builder" ownership (it previously showed "Career Workspace"), and
reframe the Career Workspace header to the "Career Profile" product
framing: "This information powers your CVs, applications, cover letters
and AI assistance."
Frontend only — no change to CareerProfiles, CvVariants, CV generation,
extraction APIs, AI, permissions or tenant isolation. Plan for the deeper
information-architecture work is in docs/career-workspace-ux-refactor.md,
staged so the 1376-line CareerProfilePage and the live CV/extraction
pipeline are refactored incrementally with verification, not in one risky
rewrite.
Verified: tsc clean, frontend build clean, 135 frontend tests pass
(128 + 7 new nav tests). Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production POST /api/cv/variants returned 200 but GET /api/cv/variants/{id}
returned 404, with the query logged as `... FROM CvVariants WHERE FALSE`
(no parameters). The created row had a correct OwnerUserId; the read was
excluded by the global query filter because CurrentUserId was null at
query time. Reproduced locally: it affected EVERY tenant-filtered read
(CV list returned 0 after creating 5, JobApplications returned total 0),
not just CV -- writes worked, reads came back empty.
Root cause: the "local" JwtBearer OnTokenValidated resolves the
request-scoped JobTrackerContext (to run LocalSessionValidator) BEFORE the
authentication middleware assigns HttpContext.User. JobTrackerContext
captured CurrentUserId in its constructor from ICurrentUserService.UserId,
which reads HttpContext.User -- still unauthenticated at that point -- so
CurrentUserId froze to null. That same scoped instance is reused by the
controller, so `CurrentUserId != null && OwnerUserId == CurrentUserId`
compiled to WHERE FALSE for the whole request. POST worked because
CreateAsync sets OwnerUserId from the controller-resolved user, and
inserts are not filtered.
Fix: make CurrentUserId a computed property that reads
ICurrentUserService.UserId live, so the query filters see the
authenticated user at query-execution time. Deny-on-null is preserved
(still null for an unauthenticated principal). LocalSessionValidator is
unaffected -- it already uses IgnoreQueryFilters and queries by explicit
sid.
Verified on a real MariaDB 11 container end to end: create then read a
variant returns 200, the variant list returns all rows, and
GET /api/jobapplications reads normally. Added
CurrentUserIdLiveEvaluationTests pinning the live-evaluation behaviour
(both fail against a constructor snapshot). 422 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production GET /api/cv/outline returned 500 "Unknown column
'c.LongTailJson'". CareerProfiles is reconciler-owned, but the
reconciler's CREATE TABLE (both the SQLite and MySQL branches) only
listed Id, OwnerUserId, ProfileJson, Version, CreatedAtUtc, UpdatedAtUtc.
LongTailJson was added to the CareerProfile model in Phase 3 but neither
CREATE was updated and no column-repair existed, so:
- existing databases (prod): the MySQL CREATE is guarded on
!HasMySqlTable, so it never runs once the table exists, and nothing
adds the column -> LoadStructuredAsync selects a column that isn't
there.
- fresh databases: the CREATE itself omitted the column, so even a brand
new MariaDB/SQLite was missing it. The 420 tests never caught this
because they build tables from the EF model, not the reconciler DDL.
The release audit missed it because it never exercised /api/cv/outline.
Add LongTailJson to both CREATE statements and add an additive repair
(EnsureColumn / EnsureMySqlColumn) for existing tables. DEFAULT ''
backfills existing rows and matches the non-nullable model property.
This is the sanctioned reconciler repair path, not a manual ALTER, and
preserves existing data (ADD COLUMN is non-destructive).
Verified on a real MariaDB 11 container: an existing 6-column
CareerProfiles gains LongTailJson on startup (repair path), a fresh DB
gets it from the CREATE (longtext), and GET /api/cv/outline returns 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidates release-candidate verification: backend 420 (incl. the CI
runner's exact Ubuntu 20.04/libicu66 environment), frontend 128, all
four DB scenarios, backup/restore with a byte-exact æøå round trip,
health and auth checks.
Status: READY WITH DOCUMENTED RISKS. No open code blocker. Remaining
risks separated into code (none), infrastructure (runner instability
A/B, still unconfirmed-fixed; old runner ICU), and manual owner
verification (sign-in, production backup, production scale).
Recommendation: deploy with documented risks -- re-run CI with the ICU
fix, owner runs a real backup + scratch restore, deploy, then run the
manual smoke test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docs/deployment/backup-restore.md: production backup checklist; documents
that deploy.sh loads the env, validates before backup, and validates the
dump. Adds VERIFIED UTF-8/Norwegian-character round trip (æ ø å survive a
real deploy.sh backup -> restore byte-exact; HEX compared). States plainly
that no production database was reached and the owner must run one real
backup + scratch restore.
- docs/deployment/manual-smoke-test.md: owner-run post-deploy checklist
(auth, applications, career profile, CV builder, AI, files). Each item
names what "wrong" looks like. Documents that login requires the owner.
- runner-investigation.md: Finding C -- the latest CI red was a real ICU
code bug the runner caught correctly, not instability. Amends the blanket
"outside the repository" conclusion. A and B stand as separate env issues.
- release-candidate-review.md: corrected drifted line refs after the index
fix; noted the CI ICU finding so the "purely external" verdict is honest.
All claims reflect behaviour verified this session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy.sh and the other scripts run on the Linux deploy host and in
Docker. A CRLF checkout breaks them with "bad interpreter: bash\r".
The committed blobs are already LF, but nothing guaranteed it against a
host with core.autocrlf=true. `*.sh text eol=lf` makes it explicit.
Verified: git ls-files --eol shows attr/text eol=lf on all .sh files;
renormalize produced no index churn (already LF).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CI backend test job failed on HumanLanguageCatalogTests:
nynorsk -> expected "Norwegian", actual "Norwegian Nynorsk"
This was a real bug, correctly caught by the runner -- not runner
instability. Reproduced on Ubuntu 20.04 / libicu66 (the CI runner's ICU)
with .NET 9 installed via dotnet-install.sh exactly as CI does.
Root cause: BuildLanguageLookup's explicit normalization aliases
(nynorsk/bokmål/norsk -> Norwegian) were added with map.TryAdd, which
loses to any key the culture enumeration already inserted. On libicu66
the "nn" culture's NativeName is the bare word "nynorsk", so enumeration
claimed key "nynorsk" -> "Norwegian Nynorsk" first and the explicit alias
silently lost. On libicu70+ (Debian/Ubuntu 22.04+, my earlier local
runs) the native name is "norsk nynorsk", so the key was free and the
alias won -- which is why it passed locally and only failed on the
runner's older ICU. Same host-ICU dependence class as 9681618.
Fix: add an Override helper (map[key] = value) and apply it to the
alias block so these mappings win regardless of insertion order. Also
collapse the full "Norwegian Nynorsk"/"Norwegian Bokmål" phrases to
"Norwegian" for consistency. Correct by construction for any ICU version.
Verified:
- reproduced the exact failure on libicu66 with the old code (probe)
- fix logic yields nynorsk/bokmål -> Norwegian on that same libicu66
- real net9 test DLL: 420/420 on focal libicu66 (CI mirror, dotnet
9.0.316 via dotnet-install.sh), and 420/420 on libicu72 (Debian) and
libicu74 (Ubuntu 24.04), plus locally
No test weakened, skipped, or relaxed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the two issues found and fixed during the release-candidate audit to
release-candidate-review.md: the follow-up reminder index that never
created on MariaDB (fix in the preceding commit), and the
nondeterministic timeline day-grouping test.
Corrects database-ownership.md drift: the MariaDB startup scenarios now
report 42 tables (measured in every scenario this audit), not the stale
40 from before the last Phase 5 tables were added, and adds the
partially-migrated heal scenario (35 -> 42) that was verified.
All claims reflect behaviour verified in this audit: 420 tests on Windows
and Linux in both ICU modes, all three Docker images built, four database
startup scenarios against live MariaDB 11 and SQLite, and a full
backup -> restore -> app-start cycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reconciler's IX_JobApplications_OwnerUserId_FollowUpAt was declared as
(OwnerUserId(191), FollowUpAt) with no prefix length on FollowUpAt. But
FollowUpAt is `text` on MariaDB -- JobApplications is migration-owned and
the migration was scaffolded against SQLite, which stores DateTimeOffset
as TEXT. A text column cannot be indexed without a prefix length, so this
index failed the 3072-byte key check on EVERY MariaDB boot, was caught by
TryCreateIndex, and was silently skipped -- leaving the follow-up reminder
query (OwnerUserId + FollowUpAt) unindexed.
Two consequences, both real:
- the index the code intends to create never existed on MariaDB
- every healthy boot logged "Specified key was too long", which
deploy/first-production-deployment.md lists as a STOP-AND-ROLL-BACK
signal -- so an operator following the runbook could abort a good deploy
The author already handled the identical problem for the longtext Status
column one line below with Status(50). Apply the same fix: FollowUpAt(20).
ISO-8601 date strings sort lexicographically, so a 20-char prefix
("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
Verified on a fresh empty MariaDB 11 container: the index is now created
(both key parts present), zero "Specified key was too long" lines, zero
skipped indexes, zero unhandled exceptions, 42 tables, app healthy. This
was the only unprefixed text column in any reconciler composite index --
the datetime columns on reconciler-owned tables are datetime(6). SQLite is
unaffected (its CREATE INDEX has no key-length limit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Timeline_groups_by_day_newest_first seeded two "same day" events as
DateTime.Now.AddDays(-3) and DateTime.Now.AddDays(-3).AddHours(2). When
the wall clock was within two hours of midnight the second timestamp
crossed into the next calendar day, so the service grouped them into two
days instead of one and the test failed (expected 2 day-groups, got 3).
The service is correct -- it groups by e.At.Date, which is the intended
behaviour and what the test name asserts. The test was nondeterministic,
failing roughly two hours out of every twenty-four, including in CI
whenever CI ran late in the day.
Anchor the two older events to DateTime.Today plus fixed hours (9 and 11)
so they always land on the same calendar day regardless of wall-clock
time. The "today" event stays DateTime.Now so the "Today" label
assertion still exercises the real path.
Verified: 420 tests pass at 22:35 local (the failing window) and on Linux
with full ICU and under DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Validation only. No application behaviour changed.
Verified against the code rather than the prose, by line number:
Before deployment -- validate_deploy_config (deploy.sh:343) and
backup_database (:347) both precede the build (:375) and the container
replacement (:382), so nothing is built, stopped or replaced without a
verified restore point.
During deployment -- startup runs ReconcileSchema (:1965),
Database.Migrate (:1973), ReconcileSchema (:1984). All seven Phase 4/5
migrations confirmed to have a literally empty Up body, which is what
makes a code rollback safe.
Health checks and rollback -- backend and frontend healthchecks present,
frontend gated on backend health, rollback documented in two places with
the code-vs-database distinction.
After deployment -- added an eight-point owner checklist covering login,
existing applications, workspace, career profile, CV builder, public CV,
AI features and attachments. Each item names what wrong looks like,
because "it loaded" is not a check. Merged the previous overlapping
"After deploying" list into it rather than leaving two competing
checklists.
Recorded the CV language ICU defect as closed, with the note that it was
invisible to a normal local test run -- the clearest evidence in this
review that passing locally and correct in the deployed container are
different claims.
Sections are now READY / BLOCKED / MANUAL VERIFICATION, with accepted
limitations kept separate. Test count updated to 420.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full backup -> verify -> restore -> start-the-app rehearsal of the
deploy.sh backup path against MariaDB 11.
Verified: the real backup_database function selected the MariaDB path
from DATABASE_PROVIDER=mariadb, produced a valid .sql.gz with 42
CREATE TABLE statements and an intact "Dump completed" trailer, restored
into a separate empty MariaDB container, and the application then
started healthy against the restored database with the reconciler
finding nothing to do. All 42 tables matched on row count, and content
survived including foreign key relationships and career profile JSON.
This is a rehearsal, NOT a verification of production data. No
production host was contacted and no production data was read. This
machine has no route to production: no /opt/job-tracker, no
DATABASE_PROVIDER or connection string in its .env, and the local stack
runs SQLite. Production host, user and key are CI secrets not available
here.
The document leads with that scope limit, records the commands to run
against production with values substituted, and ends with the checklist
that actually closes the gap -- including checking that non-ASCII CV
text survives the round trip, which the ASCII-heavy seed data did not
prove.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HumanLanguageCatalog built its lookup table solely from
CultureInfo.GetCultures, so which languages counted as human languages
depended on the host's ICU data rather than on the CV. Measured: 806
cultures on a normal Windows or Linux machine, exactly 1 under
globalization-invariant mode, and an English-only subset on a container
with trimmed ICU data.
Consequences by environment, all silent:
- full ICU: correct
- trimmed ICU: canonical names present in the reduced data survive and
the rest are dropped, so a CV keeps English and loses Norwegian
- invariant: every language is dropped and a CV import loses its
Languages section entirely, with no error
The tests were right and are unchanged. Seed the catalog explicitly with
the languages a CV realistically lists, before the culture enumeration,
which still runs and still adds breadth. Nothing in the seed collides
with a technical skill -- Go, Java, Swift, Rust and Basic are
deliberately absent, and Basic is also a proficiency level.
Verified 420 tests pass in four environments: Windows and Linux, each
with full ICU and with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. Before
this change the invariant runs failed 5 tests. No test was modified,
skipped or relaxed.
Added HumanLanguageCatalogTests to pin the seeded catalog, confirmed
non-vacuous by removing the seed and watching 15 tests fail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructured into READY / BLOCKED / MANUAL, with accepted limitations
kept separate.
B1 (backup selected the wrong provider and reported success) and N2
(/health always reported version: unknown) are both closed and verified;
their original findings are kept because the failure modes are worth
understanding. N1 closed with the .env.example additions.
One blocker remains and it is external: the CI runner. Stated with what
it needs from the owner, and with the decision it forces -- fix the
runner, or deploy deliberately from a locally verified commit knowing CI
is red.
MANUAL now leads with backup and restore readiness: the mechanism is
verified against containers, but only the owner can prove it works on
production data. Added a note that authenticated smoke testing is not an
automation gap that more work would close -- sign-in needs a password,
and no automated step here should handle one.
Validation only. No application behaviour changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/health read the APP_VERSION environment variable directly, but
docker-compose passes App__Version, which binds to the App:Version
configuration key. The variable under that name never existed in the
container, so the endpoint always reported "unknown".
Read App:Version through IConfiguration, the approach AdminSystemController
already used for the same value. The resolution rule (configured version,
else assembly version) moves to a shared BuildMetadata helper rather than
being written twice; AdminSystemController now calls it, so the admin page
and /health cannot drift apart.
Local development is unaffected: nothing sets App:Version there, and the
assembly-version fallback still applies.
Tests pin the configuration KEY, not just the behaviour, including that an
App__Version environment variable binds to App:Version. The original bug
failed silently, so a behavioural test alone would not have caught it.
Verified against a running backend: App__Version=9.9.9-test reports
9.9.9-test; unset reports the assembly version rather than "unknown".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>