GH#37 Link Visit with Vet

5/10not yet mergeable
branch test-prbase main!files +4 / ±38lines +958 / −44tests +10 / ±4Opus 5 review 9 open · 2 autofixed

🤖 Review

9 open, worst first · 2 auto-applied · 4 coder assumptions to check

Requires human review

  1. must look/code-review The new foreign key makes a vet with visits undeletable — DELETE /api/vets/{id} returns 500

    vet_id gets no ON DELETE clause and nothing on the JPA side nulls it out, so delete(vet) raises a foreign-key violation the advice turns into a 500. Verified live this run: DELETE /api/vets/4HTTP 500, violates foreign key constraint "visits_vet_id_fkey". The backfill gives every seeded vet a visit, so on a fresh database none of the six can be deleted.

    Three defensible answers: ON DELETE SET NULL, a guard in deleteVet answering 409, or “a vet who has attended is not deletable”. Returning 500 is not one of them.

    No ON DELETE clause on the new constraint.
    3ALTER TABLE visits ADD COLUMN vet_id INT REFERENCES vets (id);
    4CREATE INDEX ON visits (vet_id);
    And nothing between the request and the violation.
     96@DeleteMapping("{vetId}")
     97public void deleteVet(@PathVariable int vetId) {
     98    Vet vet = vetRepository.findById(vetId).orElseThrow();
     99    vetRepository.delete(vet);
     100}
  2. worth a look/code-review The V9 backfill hardcodes vet ids 1–6, and the comment above it is not true of every database

    The UPDATE assumes the six V3-seeded vets still occupy ids 1–6. DELETE /api/vets/{id} is exposed, so in any long-lived database where a seeded vet was removed this violates visits_vet_id_fkey: Flyway aborts and the app does not start.

    Also false wherever a visit was booked through the UI. Selecting from the vets that actually exist costs one subquery and removes both problems.

    Both the claim and the arithmetic that depends on it.
    6-- Backfill: every visit in the DB at this point comes from the V3 sample data,
    7-- spread round-robin over the 6 seeded vets so the UI has something to show.
    8UPDATE visits SET vet_id = 1 + (id % 6);
  3. worth a look/code-review The vet dropdown calls a VET_ADMIN endpoint from two OWNER_ADMIN screens, and the 403 is swallowed

    The 403 is swallowed into an empty array, so the select renders with nothing but -- not assigned -- and no error.

    Masked today: petclinic.security.enable is false by default and the one seeded user holds all three roles, so no test catches it. PetTypeRestController sets the precedent — hasAnyRole(@roles.OWNER_ADMIN, @roles.VET_ADMIN) — but widening an authorization rule is a contract decision.

    The role the whole controller requires.
     27@RestController
     28@RequestMapping("/api/vets")
     29@PreAuthorize("hasRole(@roles.VET_ADMIN)")
     30public class VetRestController {
     31
     32    private final VetMapper vetMapper;
     33    private final SpecialtyMapper specialtyMapper;
     34    private final VetRepository vetRepository;
     35    private final SpecialtyRepository specialtyRepository;
     36
     37    public VetRestController(
     38            VetMapper vetMapper,
     39            SpecialtyMapper specialtyMapper,
     40            VetRepository vetRepository,
     41            SpecialtyRepository specialtyRepository) {
     42        this.vetMapper = vetMapper;
     43        this.specialtyMapper = specialtyMapper;
     44        this.vetRepository = vetRepository;
     45        this.specialtyRepository = specialtyRepository;
     46    }
     47
     48    @GetMapping
     49    @ApiResponse(responseCode = "200", description = "OK",
     50            content = @Content(mediaType = "application/json",
     51                    array = @ArraySchema(schema = @Schema(implementation = VetDto.class)),
     52                    examples = @ExampleObject(name = "sample", value = ApiExamples.VETS)))
     53    public List<VetDto> listVets() {
     54        List<Vet> allVets = vetRepository.findAll();
     55        return vetMapper.toVetDtos(allVets);
     56    }
     57
     58    @GetMapping("{vetId}")
     59    public VetDto getVet(@PathVariable int vetId) {
     60        Vet vet = vetRepository.findById(vetId).orElseThrow();
     61        return vetMapper.toVetDto(vet);
     62    }
     63
     64    @PostMapping
     65    public ResponseEntity<Void> addVet(@RequestBody @Validated VetDto vetDto) {
     66        Vet vet = vetMapper.toVet(vetDto);
     67        updateSpecialties(vet);
     68        URI createdVetUri = UriComponentsBuilder.fromPath("/api/vets/{id}")
     69                .buildAndExpand(vet.getId()).toUri();
     70        return ResponseEntity.created(createdVetUri).build();
    …and where the 403 becomes an empty list.
     21getVets(): Observable<Vet[]> {
     22  return this.http.get<Vet[]>(this.entityUrl)
     23    .pipe(
     24      catchError(this.handlerError('getVets', []))
     25    );
     26}
  4. worth a look/code-review @ManyToOne Vet is EAGER, and only one of the four read paths join-fetches it

    The other three — GET /api/owners/{ownerId} (which the new e2e exercises), GET /api/visits/{visitId} and PetClinicMcp.listVisits — issue one select … from vets where id=? per visit. On the MCP tool that is N+1 across the whole clinic.

    LAZY trades a query count for a LazyInitializationException on any path that maps outside the transaction. That trade is yours.

    EAGER by omission — @ManyToOne defaults to it.
    +31@ManyToOne
    +32@JoinColumn(name = "vet_id")
    +33private Vet vet;
    The one query that was taught about the vet.
    +18@Query("SELECT v FROM Visit v JOIN FETCH v.pet p JOIN FETCH p.owner LEFT JOIN FETCH v.vet")
     19List<Visit> findAllWithPetAndOwner();
  5. nit/code-review An unknown vetId answers 404, for a resource the client is trying to create

    orElseThrow() throws NoSuchElementException, which the advice maps to 404 with a bare "Not found!" string body rather than the ProblemDetail every other handler returns. POST /api/visits {"vetId": 9999} answers 404 and the client cannot tell a bad field from a bad URL.

    Its Javadoc says “an unknown one is rejected”; a 400 naming the field would match that intent. Left alone because a status code is a contract.

    The rule, and the throw that decides the status code.
    +28@Nullable
    +29default Vet getByIdOrNull(@Nullable Integer vetId) {
    +30    if (vetId == null) {
    +31        return null;
    +32    }
    +33    return findByIdWithoutSpecialties(vetId).orElseThrow();
    +34}
  6. nit/simplify The map now says a visit has exactly one vet, and the code says it may have none

    DomainModelExtractor reads the model by plain reflection and ignores JPA annotations on purpose, so it can only mark a * end — an unmarked end reads as exactly one.

    Optionality is the central design decision of this change and the one thing the living diagrams cannot show; no test will complain either — the extractor that feeds them cannot see it.

    The Javadoc says 0..1; the diagram says 1.
    +31@ManyToOne
    +32@JoinColumn(name = "vet_id")
    +33private Vet vet;
  7. nit/code-review The deny that protects the generated openapi.yaml covers only one tool

    Edit(./openapi.yaml) blocks Edit and not Write, MultiEdit or NotebookEdit; the shell-write denies added earlier were dropped again. CLAUDE.md says hand-editing the file is denied, so the rule and the documentation disagree.

    Raised, not fixed, on purpose: an agent should not widen its own permission configuration because a review suggested it — and the ./ prefix in that glob is worth confirming actually matches first.

    The deny, and the three verbs it does not name.
     10"deny": [
     11  "Bash(git commit --no-verify*)",
     12  "Bash(git push --no-verify*)",
     13  "Edit(./openapi.yaml)"
     14],
     15"ask": [
     16  "Edit(.claude/settings.json)"
     17]
  8. context/simplify The sequence deltas carry rendering noise

    This branch moved the diagram generator itself — trace-to-puml.ts alone changed 102 lines — so the committed base diagrams were rendered by the old generator and arrows that never changed still read as removed-and-re-added: 45 of 54 arrows red in add-visit, 34 of 64 in owner-search, a scenario this feature does not touch at all.

    Red means “rendered differently”, not “changed”. In add-visit, the listVets / GET /api/vets arrows are genuinely new. Re-recording the base side with the base generator needs the stack running on the base commit, and its ports are fixed.

    trace-to-puml.ts:1-20

  9. context/simplify Which line should a diagram header point at — the comment, or the declaration?

    The existing test pins the opposite rule on purpose, at line 3 of its fixture: a comment naming the test is still where a reviewer wants to land. The applied fix was reverted; the file is untouched by this branch.

    Which rule you actually want is a call for whoever reads these diagrams.

    The preference the revert put back.
     31test('takes the first line that names the test, comment or declaration', () => {
     32  expect(lineOfTest(SPEC, 'Add a visit to an existing pet from the owner detail page')).toBe(3);
     33});

Decided by the coder, not by you

  1. your callassumption The attending vet is optional at every layer — a visit nobody attended is a legal, permanent state

    Issue #37 asks that a visit “be linked to the vet that attended that consultation” and never says whether that link is mandatory. Six minutes in, the coder settled it as nullable everywhere — column, entity, both DTOs, no validation — and made -- not assigned -- the picker's first option. Nothing in the stack can now refuse a vet-less visit. “one design decision matters: vet should be optional/nullable since legacy rows and MCP-booked visits won't have one yet” — its own thinking, 13 Aug 23:00.

    Read the other way: Required on every new visit — @NotNull on VisitFieldsDto, NOT NULL after the backfill — with vet-less tolerated only for rows that predate the column.

    Whether a receptionist may save a visit naming no vet, and whether that is a legitimate resting state or a to-do something should chase.

    description carries @NotNull; vetId, right beneath it, carries only @Min(0).
     17@NotNull
     18@Size(min = 1, max = 255)
     19@Schema(example = "rabies shot", description = "The description for the visit.")
     20private String description;
     21
    +22@Min(0)
    +23@Schema(example = "1", description = "The ID of the vet that attended the visit.")
    +24private @Nullable Integer vetId;
    Booking with nobody attending is the dropdown's first option.
    +54<div class="form-group">
    +55  <label for="vetId" class="col-sm-2 control-label">Vet</label>
    +56  <div class="col-sm-10">
    +57    <select id="vetId" name="vetId" class="form-control" [(ngModel)]="visit.vetId">
    +58      <option [ngValue]="null">-- not assigned --</option>
    +59      <option *ngFor="let vet of vets" [ngValue]="vet.id">{{ vet.firstName }} {{ vet.lastName }}</option>
    +60    </select>
    +61  </div>
    +62</div>
  2. your callassumption A PUT that omits vetId silently unassigns the attending vet

    updateVisit assigns resolveVet(visitDto.getVetId()) unconditionally, and vetId is a plain Integer — so “field absent” and “explicitly null” are the same wire value. Any client still sending the pre-feature body wipes the vet on every save. “I'm adding a helper method that resolves a vet by id, returning null if the id is null and throwing otherwise, then using it in both bookVisit and updateVisit” — its own thinking, 13 Aug 23:02. The two paths were never separated after that.

    Read the other way: Leave the recorded vet untouched when vetId is absent, and require an explicit null to unassign.

    Whether an older integration, or a partial form post, is allowed to erase attribution as a side effect. No test clears a vet, so nothing pins today's answer either.

    setVet runs on every update, with whatever the body did or did not carry.
    +89@Transactional
     90@PutMapping("{visitId}")
     91public void updateVisit(@PathVariable int visitId, @RequestBody @Validated VisitFieldsDto visitDto) {
     92    Visit currentVisit = visitRepository.findById(visitId).orElseThrow();
     93    currentVisit.setDate(visitDto.getDate());
     94    currentVisit.setDescription(visitDto.getDescription());
    +95    currentVisit.setVet(resolveVet(visitDto.getVetId()));
     96    visitRepository.save(currentVisit);
     97}
  3. your callassumption The picker offers every vet — no specialty, no availability, no double-booking rule

    The form loads getVets() and lists the whole clinic, unfiltered and unordered. The domain already models vet_specialties, and a visit already carries a date and an exact time, so narrowing by specialty or by who is free at that slot was available and was never weighed. Nothing prevents booking one vet for two visits at the same minute. “adding a vets array to visit-add, loading it in ngOnInit, and binding a select dropdown to vetId using ngValue” — its own thinking, 13 Aug 23:03, straight from “add a dropdown” to binding it.

    Read the other way: Offer only vets whose specialty fits, or only those with no clashing visit at that date and time — and reject a clashing vetId server-side.

    Whether picking a vet is scheduling, with the constraints that implies, or only record-keeping about who was there.

    The whole vets feed: getVets(), no filter, no awareness of the date being booked.
     43ngOnInit() {
    +44  this.vetService.getVets().subscribe(vets => this.vets = vets);
     45  console.log(this.route.parent);
     46  const petId = this.route.snapshot.params.id;
     47  this.petService.getPetById(petId).subscribe(
     48    pet => {
     49      this.currentPet = pet;
     50      this.visit.pet = this.currentPet;
     51      this.currentPetType = this.currentPet.type;
     52      this.ownerService.getOwnerById(pet.ownerId).subscribe(
     53        owner => {
     54          this.currentOwner = owner;
     55        }
     56      )
     57    },
     58    error => this.errorMessage = error as any);
     59}
  4. your callassumption The chatbot can read the attending vet but can never set one

    “Display its vet everywhere throughout the app” was read as display-only for the MCP surface: list_visits gained a vet name, create_visit gained no vet parameter. Every visit an owner books through the assistant is therefore vet-less until somebody opens the web form. “This keeps things least disruptive and avoids breaking the MCP create_visit path, so I'll move on to checking the remaining files rather than asking for clarification” — its own thinking, 13 Aug 23:00. That constraint is also what forced the column to stay nullable, so this call and the first one are the same call.

    Read the other way: Give create_visit an optional vetId, so the chat booking path carries what the web form does.

    Whether owners booking by chat are meant to choose a vet at all, and who is expected to fill the gap on the visits they create.

    create_visit's four required parameters — pet, date, time, description; no vet.
     119@McpTool(
     120        name = "create_visit",
     121        description = "Create a new vet visit for one of the authenticated owner's pets "
     122                + "(date/time, pet, description). Books the visit directly — no confirmation prompt.",
     123        annotations = @McpAnnotations(destructiveHint = true))
     124@Transactional
     125public String createVisit(
     126        @McpToolParam(description = "Pet ID (must belong to the authenticated owner)", required = true) int petId,
     127        @McpToolParam(description = "Visit date (yyyy-MM-dd); must be today or in the future",
     128                required = true) LocalDate visitDate,
     129        @McpToolParam(description = "Exact local time of the appointment (HH:mm), e.g. 08:00",
     130                required = true) LocalTime visitTime,
     131        @McpToolParam(description = "Visit description (reason, diagnosis, notes...)",
     132                required = true) String description) {
     133    int ownerId = McpSecurity.currentOwnerId();
     134    Pet pet = petRepository.findById(petId)
     135            .orElseThrow(() -> new IllegalArgumentException("Pet not found: " + petId));
     136    if (pet.getOwner() == null || pet.getOwner().getId() != ownerId) {
     137        throw new IllegalArgumentException("Pet " + petId + " does not belong to owner " + ownerId);
     138    }
     139    requireFutureDate(visitDate);
     140    if (LocalDateTime.of(visitDate, visitTime).isBefore(LocalDateTime.now())) {
     141        throw new IllegalArgumentException("Visit time must be in the future: " + visitDate + " " + visitTime);
     142    }
     143    requireUnderUpcomingVisitCap(pet);
     144
     145    Visit v = new Visit();
     146    v.setDate(visitDate);
     147    v.setTime(visitTime);
     148    v.setDescription(description);
     149    pet.addVisit(v); // maintain both sides of the Pet<->Visit association
     150    Visit saved = visitRepository.save(v);
     151    return "Created visit id=" + saved.getId() + " for pet '" + pet.getName() + "' on " + visitDate
     152            + " at " + visitTime;
     153}

Already fixed for you

Diffs cut from bf26a0de alone. A third was applied and then reverted — it inverted a rule the tests pin deliberately; that is item 9 above.

  1. auto-fixed/code-review Made the two remaining visit write paths one unit of work — on the public methods, not the private one

    @Transactional on the private, self-invoked method is a no-op — Spring AOP proxies the public entry point, which is where it now sits.

    VisitRestController.java+20 −2 vs bf26a0de
    import io.swagger.v3.oas.annotations.media.Schema;
    88 import io.swagger.v3.oas.annotations.responses.ApiResponse;
    99 import jakarta.transaction.Transactional;
    1010 import lombok.RequiredArgsConstructor;
    11+import lombok.extern.slf4j.Slf4j;
    1112 import org.springframework.http.ResponseEntity;
    13+import victor.training.petclinic.domain.Vet;
    1214 import victor.training.petclinic.mapper.VisitMapper;
    1315 import victor.training.petclinic.domain.Visit;
    1416 import victor.training.petclinic.repository.VetRepository;
    import org.springframework.security.access.prepost.PreAuthorize;
    2123 import org.springframework.web.util.UriComponentsBuilder;
    2224
    2325 import java.util.List;
    26+import java.util.NoSuchElementException;
    2427
    28+@Slf4j
    2529 @RestController
    2630 @RequestMapping("/api/visits")
    2731 @RequiredArgsConstructor
    public class VisitRestController {
    4751 return visitMapper.toVisitDto(visit);
    4852 }
    4953
    54+ // On the public entry point, not on bookVisit: that one is private and self-invoked,
    55+ // so a @Transactional there would be silently ignored by Spring AOP — the same reason
    56+ // the span below has to come from the bytecode agent.
    57+ @Transactional
    5058 @PostMapping
    5159 public ResponseEntity<Void> addVisit(@RequestBody @Validated VisitDto visitDto) {
    5260 int id = bookVisit(visitDto);
    public class VisitRestController {
    6371 @WithSpan("book-visit")
    6472 private int bookVisit(VisitDto visitDto) {
    6573 Visit visit = visitMapper.toVisit(visitDto);
    66- visit.setVet(vetRepository.getByIdOrNull(visitDto.getVetId()));
    74+ visit.setVet(resolveVet(visitDto.getVetId()));
    6775 visitRepository.save(visit);
    6876 return visit.getId();
    6977 }
    7078
    79+ @Transactional
    7180 @PutMapping("{visitId}")
    7281 public void updateVisit(@PathVariable int visitId, @RequestBody @Validated VisitFieldsDto visitDto) {
    7382 Visit currentVisit = visitRepository.findById(visitId).orElseThrow();
    7483 currentVisit.setDate(visitDto.getDate());
    7584 currentVisit.setDescription(visitDto.getDescription());
    76- currentVisit.setVet(vetRepository.getByIdOrNull(visitDto.getVetId()));
    85+ currentVisit.setVet(resolveVet(visitDto.getVetId()));
    7786 visitRepository.save(currentVisit);
    7887 }
    7988
    89+ private Vet resolveVet(Integer vetId) {
    90+ try {
    91+ return vetRepository.getByIdOrNull(vetId);
    92+ } catch (NoSuchElementException e) {
    93+ log.warn("Rejecting visit: attending vet id {} does not exist", vetId);
    94+ throw e;
    95+ }
    96+ }
    97+
    8098 @Transactional
    8199 @DeleteMapping("{visitId}")
    82100 public void deleteVisit(@PathVariable int visitId) {
  2. auto-fixed/simplify Attaching a vet to a visit no longer drags its specialties along

    The vet-detail query was fetching specialties that three write endpoints throw away; the existing one is untouched for the paths that need them.

    VetRepository.java+5 −1 vs bf26a0de
    public interface VetRepository extends Repository<Vet, Integer> {
    1515 @Query("SELECT v FROM Vet v LEFT JOIN FETCH v.specialties WHERE v.id = :id")
    1616 Optional<Vet> findById(int id);
    1717
    18+ /** The row alone — attaching a vet to a visit needs its identity, not its specialties. */
    19+ @Query("SELECT v FROM Vet v WHERE v.id = :id")
    20+ Optional<Vet> findByIdWithoutSpecialties(int id);
    21+
    1822 /**
    1923 * The attending-vet rule, in one place: a null vetId means "no vet attended (yet)",
    2024 * an unknown one is rejected. Both write paths that accept a vetId from a DTO
    public interface VetRepository extends Repository<Vet, Integer> {
    2630 if (vetId == null) {
    2731 return null;
    2832 }
    29- return findById(vetId).orElseThrow();
    33+ return findByIdWithoutSpecialties(vetId).orElseThrow();
    3034 }
    3135
    3236 void save(Vet vet);

Demo

Deployed appOpen ↗checking…

Run this in a terminal to start it:cd ~/workspace/petclinic-pr && ./start-docker.sh up --ref 2a03c604

  1. 0:05Every pet’s visit list now carries a Vet column.
  2. 0:10The booking form asks who will attend — and lets you say nobody yet.
  3. 0:16We book this one with Rafael Ortega.
  4. 0:19Back on the owner, the new visit names the vet that will attend it.
  5. 0:25The all-visits page carries the same column.
  6. 0:30And on the edit form the vet picker is the design-system combo.
  7. --:--Not filmed. Touched by this change: all visits.

API

Backwards compatible · 25 changes, none breaking · checked by oasdiff (report ↗), double-checked by our openapi-diff.py (report ↗)

Data

The concepts, as the team drew them

Hand-drawn in draw.io, but checked to match the code by ConceptualModelDiagramTest.

Edit this diagram in draw.io App ↗ or draw.io Web ↗, then (or ) to update the report.To start over, (or ).

Domain ModelDomainModel.puml
Diff + extra neighbours:
Domain Model - DiffDomain Model - DiffVetid : IntegerfirstName : StringlastName : StringVisitid : Integerdate : LocalDatetime : LocalTimedescription : String*vetaddedorremoved— the impacted elements only (2 of 8 shown)domain/*.java -> petclinic-backend/docs/generated/DomainModel.puml
Diff + extra neighbours:
Database Schema (ERD) - DiffDatabase Schema (ERD) - Diffvetsid : int «PK»first_name : textlast_name : textvisitsid : int «PK»pet_id : int «FK»visit_date : datedescription : textvisit_time : timevet_id : int «FK»vet_idaddedorremoved— the impacted elements only (2 of 9 shown)db/migration/*.sql -> DB -> dump to DB.sql -> converted to DB.puml

Tests

Legend:fully coveredpartiallyexecutedmissingN/A
victorrentea opened on Jun 13, 2026

The Visit should be linked to the vet that attended that consultation. Visit should display its vet everywhere throughout the app.


What it has to do

  1. Booking a visit lets you choose the vet, and lets you not choose one. The vet is optional: half the time the appointment is booked before anyone knows who is taking it, and forcing a choice there produces bad data rather than information.
  2. Editing a visit can change the vet, and can remove it. Clearing the field must persist as empty. We had this with pet types: the old value kept coming back.
  3. Wherever a visit is shown with its details, the vet is shown too. Today that means the owner's page and the all-visits screen.
  4. A visit with no vet reads as having none. Not "Unknown", not blank-because-broken, and never an error. Visits created before this change have no vet and will not get one.

Out of scope

Searching or filtering visits by vet.

UIclicks the screenAPIREST/MCPunitone isolated component

Sequence

AddVisitSequenceTest.java
 60@GenerateSequence
 61class AddVisitSequenceTest {
 8 lines not shown
 70    @Test
+71    @Order(1)
 72    void addsAVisitToAnExistingPet() throws Exception {
 73        given("an owner with at least one pet exists");
 74        JsonNode owner = anOwnerWithAPet();
 75        int ownerId = owner.path("id").asInt();
 76        int petId = owner.path("pets").get(0).path("id").asInt();
 77
 78        when("the owner detail page is opened");
 79        call(mockMvc, get("/api/owners/{ownerId}", ownerId)).andExpect(status().isOk());
 80
 81        and("a visit is added for the first pet");
 82        String description = "Annual check-up " + System.currentTimeMillis();
 83        call(mockMvc, post("/api/owners/{ownerId}/pets/{petId}/visits", ownerId, petId)
 84                .contentType(MediaType.APPLICATION_JSON)
 85                .content(mapper.writeValueAsString(Map.of("date", VISIT_DATE, "description", description))))
 86                .andExpect(status().isCreated());
 87
 88        then("the visit is listed under the pet");
 89        JsonNode reloaded = json(call(mockMvc, get("/api/owners/{ownerId}", ownerId))
 90                .andExpect(status().isOk()));
 91        assertThat(reloaded.path("pets").get(0).path("visits").toString())
 92                .contains(description)
 93                .contains(VISIT_DATE);
 94    }
 29 lines not shown
+124    @Test
+125    @Order(2)
+126    @WithMockUser(roles = {"OWNER_ADMIN", "VET_ADMIN"})
+127    void remembersTheVetWhoAttendedIt() throws Exception {
+128        given("an owner with at least one pet exists");
+129        JsonNode owner = anOwnerWithAPet();
+130        int ownerId = owner.path("id").asInt();
+131        int petId = owner.path("pets").get(0).path("id").asInt();
+132
+133        and("the clinic has a vet who can attend it");
+134        JsonNode vet = theFirstVet();
+135        int vetId = vet.path("id").asInt();
+136        String vetName = vet.path("firstName").asText() + " " + vet.path("lastName").asText();
+137
+138        when("a visit is booked for that pet with that vet attending");
+139        String description = "Annual check-up " + System.currentTimeMillis();
+140        call(mockMvc, post("/api/owners/{ownerId}/pets/{petId}/visits", ownerId, petId)
+141                .contentType(MediaType.APPLICATION_JSON)
+142                .content(mapper.writeValueAsString(
+143                        Map.of("date", VISIT_DATE, "description", description, "vetId", vetId))))
+144                .andExpect(status().isCreated());
+145
+146        then("that pet's history shows the visit was attended by that vet");
+147        JsonNode reloaded = json(call(mockMvc, get("/api/owners/{ownerId}", ownerId))
+148                .andExpect(status().isOk()));
+149        JsonNode visit = visitDescribed(reloaded, description);
+150        // The name, not only the id: the id proves the column was written, the two names
+151        // prove the read path joins the vet back in — which is the half the UI depends on.
+152        assertThat(visit.path("vetId").asInt()).isEqualTo(vetId);
+153        assertThat(visit.path("vetFirstName").asText() + " " + visit.path("vetLastName").asText())
+154                .isEqualTo(vetName);
+155    }
petclinic-backend/src/test/java/victor/training/petclinic/rest/AddVisitSequenceTest.javapetclinic-backend/src/test/java/victor/training/petclinic/rest/AddVisitSequenceTest.javaTestTestTestTestTestTestTestTestBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendTestBackendDBTestBackendDBTestTestTestTestTestTestTestTestBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendadds a visit to an existing pet given an owner with at least one pet exists ↗List ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select pets ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕when the owner detail page is opened ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕and a visit is added for the first pet ↗Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕txbook-visit ↗VetRepository.getByIdOrNull ↗VisitRepository.save ↗insert for victor.training.petclinic.domain.Visit ⊕201then the visit is listed under the pet ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕remembers the vet who attended it given an owner with at least one pet exists ↗List ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select pets ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕and the clinic has a vet who can attend it ↗listVetsGET /api/vetsVetRepository.findAll ↗txSELECT DISTINCT v FROM Vet v LEFT JOIN FETCH v.specialties ⊕200 ⊕when a visit is booked for that pet with that vet attending ↗Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕txbook-visit ↗VetRepository.getByIdOrNull ↗VetRepository.findByIdWithoutSpecialties ↗SELECT v FROM Vet v WHERE v.id = :id ⊕VisitRepository.save ↗insert for victor.training.petclinic.domain.Visit ⊕201then that pet's history shows the visit was attended by that vet ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕@GenerateSequence — generated from real traces of end-to-end test runs, do not edit ❗
add-visit.feature
15@generate_sequence
16Scenario: A visit remembers the vet who attended it
17  When I book a visit for that pet with "Helen Leary" attending
18  Then that pet's history shows the visit was attended by "Helen Leary"
src/add-visit.featuresrc/add-visit.featureBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBrowserBackendDBBrowserBackendDBBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendA visit remembers the vet who attended it Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕listVetsGET /api/vetsVetRepository.findAll ↗txSELECT DISTINCT v FROM Vet v LEFT JOIN FETCH v.specialties ⊕200 ⊕getPetGET /api/pets/{petId}PetRepository.findById ↗txselect pets ⊕select visits ⊕200 ⊕Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕txbook-visit ↗VetRepository.getByIdOrNull ↗VetRepository.findByIdWithoutSpecialties ↗SELECT v FROM Vet v WHERE v.id = :id ⊕VisitRepository.save ↗insert for victor.training.petclinic.domain.Visit ⊕201Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence — generated from real traces of end-to-end test runs, do not edit ❗
add-visit.spec.ts
 35test('Add a visit to an existing pet from the owner detail page',
 36  {tag: [GENERATE_SEQUENCE_TAG]},
 37  async ({page}) => {
 38    const {ownerId} = await an_owner_with_at_least_one_pet_exists();
 39
 40    await open_owner_detail_page(page, ownerId);
 41    await click_add_visit_for_first_pet(page, 'Add Visit');
 42    const description = await fill_visit_date_and_unique_description(page, VISIT_DATE);
 43    await submit_visit_form(page);
 44
 45    await expect_back_on_owner_detail_page(page, ownerId);
 46    await expect_pet_visit_list_contains(page, VISIT_DATE, description);
+47    await expect_pet_visit_list_shows_no_vet(page, VISIT_DATE, description);
+48  });
 3 lines not shown
+52test('Add a visit attended by a vet',
+53  {tag: [GENERATE_SEQUENCE_TAG]},
+54  async ({page}) => {
+55    const {ownerId} = await an_owner_with_at_least_one_pet_exists();
+56
+57    await open_owner_detail_page(page, ownerId);
+58    await click_add_visit_for_first_pet(page, 'Add Visit');
+59    const description = await fill_visit_date_and_unique_description(page, VISIT_DATE);
+60    const vetName = await select_first_vet_in_visit_form(page);
+61    await submit_visit_form(page);
+62
+63    await expect_back_on_owner_detail_page(page, ownerId);
+64    await expect_pet_visit_list_shows_vet(page, VISIT_DATE, description, vetName);
 65  });
src/add-visit.spec.tssrc/add-visit.spec.tsBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBrowserBackendDBBrowserBackendDBBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendBackendAdd a visit to an existing pet from the owner detail page Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕listVetsGET /api/vetsVetRepository.findAll ↗txpetclinicSELECT DISTINCT v FROM Vet v LEFT JOIN FETCH v.specialties ⊕200 ⊕getPetGET /api/pets/{petId}PetRepository.findById ↗txpetclinicselect pets ⊕select visits ⊕200 ⊕Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕txbook-visit ↗VetRepository.getByIdOrNull ↗VisitRepository.save ↗insert for victor.training.petclinic.domain.Visit ⊕201Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕Add a visit attended by a vet Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕listVetsGET /api/vetsVetRepository.findAll ↗txSELECT DISTINCT v FROM Vet v LEFT JOIN FETCH v.specialties ⊕200 ⊕getPetGET /api/pets/{petId}PetRepository.findById ↗txselect pets ⊕select visits ⊕200 ⊕Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕txbook-visit ↗VetRepository.getByIdOrNull ↗VetRepository.findByIdWithoutSpecialties ↗SELECT v FROM Vet v WHERE v.id = :id ⊕VisitRepository.save ↗insert for victor.training.petclinic.domain.Visit ⊕201Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗txselect owners ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence — generated from real traces, do not edit⚠️ GENERATED FILE — DO NOT EDIT. Every edit is lost on the next run.
owner-search.feature
 25@generate_sequence
 26Scenario: Searching with an empty last name lists every owner
 27  When I open the owners page
 28  And I search owners for ""
 29  Then every owner in the clinic is listed
src/owner-search.featuresrc/owner-search.featureBackendBackendBackendBackendBrowserBackendDBBrowserBackendDBBackendBackendBackendBackendSearching with an empty last name lists every owner List ownersGET /api/ownersOwnerRepository.findByLastNameStartingWithOwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select pets ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕List ownersGET /api/ownersOwnerRepository.findByLastNameStartingWithpetclinicOwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select pets ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence — generated from real traces, do not edit⚠️ GENERATED FILE — DO NOT EDIT. Every edit is lost on the next run.

Structure

Neither picture moved on this branch

Packages — the dependencies allowed between Java packages, written by hand as the rule and enforced on every build by PackagesArchTest (ArchUnit): the test fails when the code disagrees with it. This branch did briefly break it — the [Mapper] → [DTO] arrow had been commented out during a demo. Restoring it is in the fixed list on the Review tab.

Maven modules — from mvn dependency:tree per project, filtered to this repo's own groupId:artifactId pairs: four standalone builds with nothing between them. There is no aggregator pom.xml at the root, and petclinic-frontend and petclinic-test have no pom.xml at all.

Packagesunchangedpetclinic-backend/docs/packages.puml
Backend Logical Architecture (java packages)Backend Logical Architecture (java packages)«..rest»REST«..mcp»MCP«..rest.error»REST Error«..security»Security«..mapper»Mapper«..repository»Repository«..rest.dto»DTO«..domain»DomainDiagram ArchUnit-tested vs codepetclinic-backend/docs/packages.puml
Maven modulesunchangedpetclinic-backend/docs/generated/MavenModules.puml
Maven Module GraphMaven Module Graphpetclinic-backend (victor.training.agentic)petclinic-chatbot (victor.training.petclinic)petclinic-database (victor.training.agentic)refactoring-legacy (victor.training.agentic)Diagram generated from `mvn dependency:tree -Dincludes=<this repo's own groupId:artifactId>`/pom.xml -> petclinic-backend/docs/scripts/mavenmodules/gen-maven-modules.sh -> petclinic-backend/docs/generated/MavenModules.puml

Code City

11 classes lit — a clean vertical slice: domain, mapper, repository, controller, and the tests beside them.

Code City with the branch change set highlighted

UX

Where the design system was not used

main introduced app-combo — one standardised single-select that marks its host data-ds="combo" — and moved every existing single-select onto it. This branch adds a vet picker to two screens and puts only one of them on the component.

Flagged: a native control filling a role the design system covers, with no [data-ds] above it. The roles are derived from the components themselves — the attribute is the registry — so controls the design system has no component for, like the multi-select on Edit a vet, are listed as considered and deliberately not judged.

Nothing else in the repository catches this. Not ESLint, not the guardrails, not Cucumber: app-combo renders an inner <select> carrying the same id, so page.locator('select#vetId') in add-visit.spec.ts matches either implementation identically.

1 gap across 7 screens, 4 design-system components in place · 1 regression

Roles the design system covers, derived — not listed by hand, so a second component needs no change here:
  • data-ds="combo" covers select — runtime:Edit a visit:new: <select> inside app-combo[name="vetId"]

Book a visit

1 gap · 0 design-system components in place

screenshots and findings
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked
✗ plain <select>, not the combo component · added
sideelementrolewhydeltachurn
gaptest-prVet
#vetId
selectnative <select> in a role the design system covers (combo), and it is not inside any [data-ds] host
new on this branch: it shipped bare, it was never migrated
added
2 controls considered and deliberately not judged
  • input[name="date"] Date — role input[type=text], not covered
  • #description Description — role input[type=text], not covered

Edit a visit

0 gaps · 1 design-system component in place

screenshots and findings
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked
✓ combo · added
sideelementrolewhydeltachurn
oktest-prVet
app-combo[name="vetId"]
design-system component comboadded
2 controls considered and deliberately not judged
  • input[name="date"] Date — role input[type=text], not covered
  • #description Description — role input[type=text], not covered

Add a pet

0 gaps · 1 design-system component in place

screenshots and findings — this branch did not touch this screen
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked
✓ combo
sideelementrolewhydeltachurn
oktest-prType
app-combo[name="type"]
design-system component combosame0%
okmainType
app-combo[name="type"]
design-system component combosame0%
3 controls considered and deliberately not judged
  • #owner_name Owner — role input[type=text], not covered
  • #name Name — role input[type=text], not covered
  • input[name="birthDate"] Birth Date — role input[type=text], not covered

Edit a pet

0 gaps · 1 design-system component in place

screenshots and findings — this branch did not touch this screen
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked
✓ combo
sideelementrolewhydeltachurn
oktest-prType
app-combo[name="pettype"]
design-system component combosame0%
okmainType
app-combo[name="pettype"]
design-system component combosame0%
4 controls considered and deliberately not judged
  • #owner_name Owner — role input[type=text], not covered
  • #name Name — role input[type=text], not covered
  • input[name="birthDate"] Birth Date — role input[type=text], not covered
  • #type1 Type — role input[type=text], not covered

Add a vet

0 gaps · 1 design-system component in place

screenshots and findings — this branch did not touch this screen
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked
✓ combo
sideelementrolewhydeltachurn
oktest-prType
app-combo[name="specialties"]
design-system component combosame0%
okmainType
app-combo[name="specialties"]
design-system component combosame0%
2 controls considered and deliberately not judged
  • #firstName First Name — role input[type=text], not covered
  • #lastName Last Name — role input[type=text], not covered

Edit a vet

0 gaps · 0 design-system components in place

screenshots and findings — this branch did not touch this screen
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked

Nothing on this screen is a design-system component or a gap where one belongs.

3 controls considered and deliberately not judged
  • #firstName First Name — role input[type=text], not covered
  • #lastName Last Name — role input[type=text], not covered
  • #spec Specialties — role role=combobox, not covered

Owners

0 gaps · 0 design-system components in place

screenshots and findings — this branch did not touch this screen
is a design-system componentnot the design-system component — a native control where one belongs, outside any [data-ds]new or changed on this brancheverything else is deliberately unmarked

Nothing on this screen is a design-system component or a gap where one belongs.

1 control considered and deliberately not judged
  • #lastName Last name — role input[type=text], not covered

Complexity

Cognitive complexity of the whole flow behind each entry point.

Logging

Found structurally searching for common logging libraries.

 81private int bookVisit(VisitDto visitDto) {
+82    log.info("Booking visit for pet {}: {}", visitDto.getPetId(), visitDto.getDescription());
  • visitDto.getPetId() — numeric pet database identifier
  • 🤔 visitDto.getDescription() — free-text visit description whose content is unconstrained and could include personal details
+99private Vet resolveVet(Integer vetId) {
 3 lines not shown
+103        log.warn("Rejecting visit: attending vet id {} does not exist", vetId);
  • vetId — just a numeric vet database identifier, no name or contact info
+203private Vet resolveVet(Integer vetId) {
 3 lines not shown
+207        log.warn("Rejecting visit: attending vet id {} does not exist", vetId);
  • vetId — numeric vet database id parameter, not personal data itself

🤖 AI Evaluation:

The code block is the evidence: alongside each statement it quotes the lines its logged values came from, walked back structurally by ast-grep and cut from the working tree with their real line numbers.

  • SAFE — nothing traced reads as personal data
  • 🤔 DOUBT — could not trace it with confidence, and an unresolved case is read as DOUBT on purpose rather than guessed SAFE
  • PRIVACY — a value traced back to personal data, on its way to a log aggregator kept for months
  • ⚠️ NOT EVALUATED — the model could not be reached; never silently read as SAFE

CODEOWNERS