You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
While reviewing rails-api spec coverage during #300, four areas surfaced where the rails specs cover real product behavior that the cross-stack tests/api/ suite doesn't. Some are universal-but-untested, some are real contract gaps where only 1–2 stacks enforce the rule, and one is a deliberate implementation choice worth documenting.
This issue tracks the four — each with a different action because the underlying situation differs.
Inventory
Cross-checked which stacks enforce each rule today:
#
Rule
Enforced by
Status
1
CORS preflight echoes the requesting origin
every API stack (CORS middleware everywhere)
universal but untested
3
Reject status transitions away from terminal states (accepted offer, declined offer)
rails-api, yoga-api
contract gap (8 stacks silently allow it)
4
Stage UUIDs preserved across snapshot restore
rails-api only (destroy_all + recreate-with-id in ApplicationRestoreService)
implementation choice; other stacks may legitimately mint new IDs
5
salary_max >= salary_min validation
rails-api, yoga-api (nest-api/hono-api have min(0) Zod but no min≤max check)
contract gap
(#2 default interview-stage seeding on transition to interviewing is rails-only by design and stays out of tests/api. #6 model-layer AR validation keys are too implementation-coupled to share.)
Universal behavior, zero coverage today. Easy win.
Add a per-stack expectedAllowedOrigin (or reuse the canonical UI port) field in tests/api/helpers.ts for each stack.
Add tests/api/cors.test.ts that does OPTIONS /applications with Origin: <expectedAllowedOrigin> and Access-Control-Request-Method: GET, asserts the echoed Access-Control-Allow-Origin header.
One test, one assertion per stack — runs against every stack via the existing harness.
Acceptance: CORS regression that breaks any stack's allowlist for the dev UI fails CI.
#3 — Standardize terminal-status transition rejection across stacks, then promote test
Almost certainly a contract gap rather than intentional divergence — accepted offer → applied doesn't make product sense. Two stacks already enforce it.
Decide and document the rule in the cross-stack contract (most likely: accepted offer and declined offer are terminal; reject any update that changes status away from these unless the source status equals the target).
Implement in the 8 stacks that don't have it: express (api/), koa-api, hono-api, nuxt-api, nest-api, nest-history-api, fastapi, go-api, spring-api, lambda-api.
Add a shared test in tests/api/application-status.test.ts that creates an application in accepted offer, attempts a PATCH to applied, asserts 400 with a validation_error-shaped response.
Acceptance: every stack rejects the transition with a 400; shared test passes for all stacks.
#4 — Document stage-ID preservation as deliberately stack-specific
Rails uses destroy_all + recreate-with-original-UUID; other stacks haven't made an explicit choice. Locking it as a contract would force every stack into the rails strategy without a clear product driver — and "restore mints new stage IDs" is a defensible model (treats restored stages as new entities).
Add a section to tests/CLAUDE.md or docs/DATABASE_ARCHITECTURE.md noting that stage UUID stability across history restore is not guaranteed by the cross-stack contract; clients depending on stable stage IDs should query the history endpoint instead.
No shared test added.
Optional: add a per-stack capability flag (preservesStageIdsOnRestore: boolean) in tests/api/helpers.ts for documentation/feature-gating purposes; only set true for rails-api initially.
Acceptance: behavior is documented; no implicit assumption baked into shared tests.
#5 — Standardize salary range ordering, then promote test
Same shape as #3. Two stacks enforce; the rest accept salaryMin: 200000, salaryMax: 100000 silently. The rule is unambiguous product behavior, not flavor.
Decide and document the rule (salary_max >= salary_min when both present).
Implement in the stacks that don't have it.
Add a shared test in tests/api/application-crud.test.ts (or a new tests/api/validation.test.ts) that POSTs an inverted salary range, asserts 400 with field-level error on salaryMax.
Acceptance: every stack rejects inverted salary range with a 400; shared test passes.
Originally surfaced during PR #300 (rails-api implementation) when comparing rails-api/spec coverage to tests/api/ coverage.
Companion to #301 (CI workflow restructure) — both came out of the same PR review cycle but are independent of each other.
Addendum: contract-vs-implementation drift on specialRequirements maxLength
While reviewing rails-api in #300, surfaced another cross-stack drift worth folding into the same standardization pass:
Source
maxLength enforced
specs/core/api/openapi.yaml (the contract)
1000
fastapi/src/schemas.py
5000
nest-api/src/types/api.ts
5000
hono-api/src/types/api.ts
5000
lambda-api/src/types/api.ts
5000
rails-api/app/models/job_application.rb (this PR)
5000
Every implementation diverges from the contract by the same amount. Two paths:
A — update specs/core/api/openapi.yaml to maxLength: 5000 (treat the implementations as the source of truth; the contract was wrong).
B — tighten all five implementations to maxLength: 1000 (treat the contract as binding; implementations drifted independently).
A is the lower-friction path: no behavior change for users, no breaking change for API consumers. The specialRequirements field is freeform notes; 5000 is the more useful limit. B requires updating every stack's validator, which is more code change for no real product benefit unless the contract value is being defended for a specific reason (and we should document why).
Recommend A: update the OpenAPI contract to 5000. Add a shared API test in tests/api/ that posts a 4000-char specialRequirements and asserts 200 — locks in the convention going forward.
Surfaced as a Copilot review comment on #300; declined there because aligning rails alone to 1000 would create a new cross-stack gap.
Background
While reviewing rails-api spec coverage during #300, four areas surfaced where the rails specs cover real product behavior that the cross-stack
tests/api/suite doesn't. Some are universal-but-untested, some are real contract gaps where only 1–2 stacks enforce the rule, and one is a deliberate implementation choice worth documenting.This issue tracks the four — each with a different action because the underlying situation differs.
Inventory
Cross-checked which stacks enforce each rule today:
accepted offer,declined offer)rails-api,yoga-apirails-apionly (destroy_all+ recreate-with-id inApplicationRestoreService)salary_max >= salary_minvalidationrails-api,yoga-api(nest-api/hono-apihavemin(0)Zod but no min≤max check)(#2 default interview-stage seeding on transition to
interviewingis rails-only by design and stays out oftests/api. #6 model-layer AR validation keys are too implementation-coupled to share.)Per-item plan
#1 — Promote CORS preflight test to
tests/api/Universal behavior, zero coverage today. Easy win.
expectedAllowedOrigin(or reuse the canonical UI port) field intests/api/helpers.tsfor each stack.tests/api/cors.test.tsthat doesOPTIONS /applicationswithOrigin: <expectedAllowedOrigin>andAccess-Control-Request-Method: GET, asserts the echoedAccess-Control-Allow-Originheader.Acceptance: CORS regression that breaks any stack's allowlist for the dev UI fails CI.
#3 — Standardize terminal-status transition rejection across stacks, then promote test
Almost certainly a contract gap rather than intentional divergence —
accepted offer → applieddoesn't make product sense. Two stacks already enforce it.accepted offeranddeclined offerare terminal; reject any update that changes status away from these unless the source status equals the target).express(api/),koa-api,hono-api,nuxt-api,nest-api,nest-history-api,fastapi,go-api,spring-api,lambda-api.tests/api/application-status.test.tsthat creates an application inaccepted offer, attempts a PATCH toapplied, asserts 400 with avalidation_error-shaped response.Acceptance: every stack rejects the transition with a 400; shared test passes for all stacks.
#4 — Document stage-ID preservation as deliberately stack-specific
Rails uses
destroy_all+ recreate-with-original-UUID; other stacks haven't made an explicit choice. Locking it as a contract would force every stack into the rails strategy without a clear product driver — and "restore mints new stage IDs" is a defensible model (treats restored stages as new entities).tests/CLAUDE.mdordocs/DATABASE_ARCHITECTURE.mdnoting that stage UUID stability across history restore is not guaranteed by the cross-stack contract; clients depending on stable stage IDs should query the history endpoint instead.preservesStageIdsOnRestore: boolean) intests/api/helpers.tsfor documentation/feature-gating purposes; only settrueforrails-apiinitially.Acceptance: behavior is documented; no implicit assumption baked into shared tests.
#5 — Standardize salary range ordering, then promote test
Same shape as #3. Two stacks enforce; the rest accept
salaryMin: 200000, salaryMax: 100000silently. The rule is unambiguous product behavior, not flavor.salary_max >= salary_minwhen both present).tests/api/application-crud.test.ts(or a newtests/api/validation.test.ts) that POSTs an inverted salary range, asserts 400 with field-level error onsalaryMax.Acceptance: every stack rejects inverted salary range with a 400; shared test passes.
Suggested PR ordering
Context
Originally surfaced during PR #300 (rails-api implementation) when comparing rails-api/spec coverage to
tests/api/coverage.Companion to #301 (CI workflow restructure) — both came out of the same PR review cycle but are independent of each other.
Addendum: contract-vs-implementation drift on
specialRequirementsmaxLengthWhile reviewing rails-api in #300, surfaced another cross-stack drift worth folding into the same standardization pass:
specs/core/api/openapi.yaml(the contract)fastapi/src/schemas.pynest-api/src/types/api.tshono-api/src/types/api.tslambda-api/src/types/api.tsrails-api/app/models/job_application.rb(this PR)Every implementation diverges from the contract by the same amount. Two paths:
specs/core/api/openapi.yamltomaxLength: 5000(treat the implementations as the source of truth; the contract was wrong).maxLength: 1000(treat the contract as binding; implementations drifted independently).A is the lower-friction path: no behavior change for users, no breaking change for API consumers. The
specialRequirementsfield is freeform notes; 5000 is the more useful limit. B requires updating every stack's validator, which is more code change for no real product benefit unless the contract value is being defended for a specific reason (and we should document why).Recommend A: update the OpenAPI contract to 5000. Add a shared API test in
tests/api/that posts a 4000-charspecialRequirementsand asserts 200 — locks in the convention going forward.Surfaced as a Copilot review comment on #300; declined there because aligning rails alone to 1000 would create a new cross-stack gap.