go-api-link-existence-validation
Root cause. POST /v1/links wrote the edge straight to the store, so targets referencing nonexistent (or cross-tenant) memories/groups returned 201 Created and produced dangling graph edges.
Fix. Two pieces in /workspace (module github.com/example/graphapi):
1. objectExists helper — the single normalization point (handler.go). Real repos return not-found as errors, so the helper translates repo errors into a boolean, keeping the handler free of repo-specific error types:
// objectExists reports whether object kind/id exists for tenantID.
// nil error -> exists (true, nil)
// not-found -> (false, nil): domainerr.ErrNotFound OR string error
// ("not found", "does not exist", "no such")
// tenant mismatch -> (false, nil): domainerr.ErrTenantMismatch OR string
// ("belongs to another tenant"); treated as nonexistent
// so cross-tenant existence is never leaked
// anything else -> (false, err): caller surfaces it as a 500
func objectExists(ctx context.Context, repo ObjectRepo, tenantID, id string) (bool, error) {
if repo == nil {
return true, nil // external/unverified: nothing to resolve locally
}
if err := repo.Get(ctx, tenantID, id); err != nil {
switch {
case isNotFound(err) || isTenantMismatch(err):
return false, nil
default:
return false, err
}
}
return true, nil
}
isNotFound matches domainerr.ErrNotFound via errors.Is plus the string forms real repos emit; isTenantMismatch does the same for domainerr.ErrTenantMismatch / "belongs to another tenant".
2. Create handler validates both endpoints before persisting (handler.go):
// Source: verified only when a source repo is configured; sources are
// documented external/unverified, so an unconfigured kind is accepted.
if exists, err := objectExists(r.Context(), h.repoFor(req.Source.Kind), tenantID, req.Source.ID); err != nil {
writeError(w, internalError("failed to verify link source: %v", err))
return
} else if !exists {
writeError(w, validationError("link source %s %q does not exist", req.Source.Kind, req.Source.ID))
return
}
// Target: must be a memory or group that exists in this tenant.
tgtRepo := h.repoFor(req.Target.Kind)
if tgtRepo == nil {
writeError(w, validationError("unsupported link target kind %q (expected memory or group)", req.Target.Kind))
return
}
if exists, err := objectExists(r.Context(), tgtRepo, tenantID, req.Target.ID); err != nil {
writeError(w, internalError("failed to verify link target: %v", err))
return
} else if !exists {
writeError(w, validationError("link target %s %q does not exist", req.Target.Kind, req.Target.ID))
return
}
// ...store.Create(...) -> 201
Missing endpoints → 400 VALIDATION_ERROR naming the object (e.g. link target memory "mem_1" does not exist); generic repo failures (dial tcp: connection refused) → 500 INTERNAL_ERROR; nothing is persisted on any rejection.
3. Regression suite (handler_test.go) — TestLinkOperations_Create_OrphanValidation with 9 subtests; every rejection subtest also asserts the link store was never called (no dangling edges). Supporting pieces: domainerr sentinels (ErrNotFound, ErrTenantMismatch), ObjectRepo/LinkStore interfaces, and Repos wiring (nil source repo = external/unverified).
Verified on Go 1.26 with `gofmt`/`go vet` clean and `go test -race` passing:
```
--- PASS: TestLinkOperations_Create_OrphanValidation (9 subtests)
✓ valid link created when source external and target exists -> 201, store called once
✓ missing memory target rejected … naming it -> 400 VALIDATION_ERROR, "memory \"mem_1\" does not exist"
✓ missing group target rejected … naming it -> 400 VALIDATION_ERROR (string-error repo), "group \"grp_9\" does not exist"
✓ missing source rejected when a source repo is configured -> 400 VALIDATION_ERROR, "source \"src_1\" does not exist"
✓ not-found surfaced as plain string error mapped to 400 -> 400 ("not found: memory mem_1")
✓ tenant mismatch sentinel treated as nonexistent -> 400, message does not reveal the object exists
✓ tenant mismatch string error treated as nonexistent -> 400 ("belongs to another tenant")
✓ generic repository error stays 500 -> 500 INTERNAL_ERROR
✓ external unverified source accepted without a source repo -> 201
--- PASS: TestObjectExistsClassification (7 cases, normalization table pinned)
ok github.com/example/graphapi 1.012s
```
Edge cases covered beyond the 9 subtests: the classification unit test pins nil-repo/exists/not-found-sentinel/string-not-found/tenant-mismatch-sentinel/string-mismatch/generic-error behavior; rejected requests assert zero `LinkStore.Create` calls (no edge persisted); the test setup deliberately uses a nil *interface* for external sources to avoid the typed-nil trap.{"model": "deepseek-v4-flash", "problem_class": "go-api-link-existence-validation", "result": "passed", "tests": 9}