go-hash-only-open-no-credit
Root cause. /v1/open only called RecordCredit when both publisher_id AND domain appeared in the request body, contradicting the hash-only spec. A hash-only request therefore opened but never accrued credit.
The fix (~ module opencredits): the handler now resolves everything from the hash and credits with resolved values; unresolvable cases skip with an INFO log while the open still succeeds.
// credits.go — the fixed handler logic
func Open(ctx context.Context, worker Worker, crediter CreditRecorder, req OpenRequest, log *slog.Logger) OpenResult {
if log == nil { log = slog.Default() }
// 1. Resolve URL from the persistent hash store.
url, found, err := worker.LookupHash(ctx, req.Hash)
if err != nil { // hard store failure → fail open
log.Error("hash lookup failed", "hash", req.Hash, "err", err)
return OpenResult{Opened: false, CreditErr: err.Error()}
}
if !found { // unresolvable → INFO skip, still open
log.Info("hash not found; skipping credit", "hash", req.Hash)
return OpenResult{Opened: true, Credited: false, SkipReason: "hash_not_found"}
}
// 2. Extract the domain from the resolved URL.
domain := ExtractDomain(url) // hostname, scheme-less tolerant
if domain == "" {
log.Info("no domain extractable; skipping credit", "hash", req.Hash, "url", url)
return OpenResult{Opened: true, Credited: false, SkipReason: "no_domain_in_url"}
}
// 3. Optional capability: resolve owner from domain_verifications.
resolver, ok := worker.(DomainOwnerResolver) // type-assert; never assumed
if !ok { // in-memory fakes degrade gracefully
log.Info("worker lacks DomainOwnerResolver capability; skipping credit",
"hash", req.Hash, "domain", domain)
return OpenResult{Opened: true, Credited: false, Domain: domain, SkipReason: "no_resolver_capability"}
}
ownerID, resolved, err := resolver.LookupDomainOwner(ctx, domain)
if err != nil || !resolved { // unresolvable → INFO skip, still open
log.Info("no verified owner for domain; skipping credit", "hash", req.Hash, "domain", domain)
return OpenResult{Opened: true, Credited: false, Domain: domain,
SkipReason: "owner_lookup_error / no_verified_owner"}
}
// 4. Credit with RESOLVED values — never the raw body fields.
if err := crediter.RecordCredit(ctx, ownerID, domain); err != nil {
log.Error("credit failed; open still succeeds", "owner_id", ownerID, "domain", domain, "err", err)
return OpenResult{Opened: true, Credited: false, OwnerID: ownerID, Domain: domain, CreditErr: err.Error()}
}
return OpenResult{Opened: true, Credited: true, OwnerID: ownerID, Domain: domain}
}
Supporting pieces:
// hashstore.go — persistent hash store (SQLite `hashes` table)
type SQLHashStore struct{ db *sql.DB } // LookupHash: SELECT url FROM hashes WHERE hash=?
func (s *SQLHashStore) LookupHash(ctx context.Context, hash string) (string, bool, error) // sql.ErrNoRows → found=false
// domainresolver.go — the optional capability
type DomainOwnerResolver interface {
LookupDomainOwner(ctx context.Context, domain string) (ownerID string, found bool, err error)
}
// SQLDomainOwnerResolver: newest VERIFIED row wins
// SELECT owner_id FROM domain_verifications
// WHERE domain = ? AND verified = 1
// ORDER BY created_at DESC, id DESC LIMIT 1
// server.go — HTTP surface
func OpenHTTPHandler(worker Worker, crediter CreditRecorder, log *slog.Logger) http.HandlerFunc
Worker pattern. Worker is interface { HashStore }. DomainOwnerResolver is an optional capability on the concrete worker: production SQLWorker embeds both; in-memory test fakes may embed only HashStore. Open probes with worker.(DomainOwnerResolver) — no capability, no panic: INFO skip, open still succeeds. Request body fields publisher_id/domain are still parsed for client compatibility but no longer gate or influence crediting (test TestOpenHashOnlyIgnoresLegacyBodyFieldsForCrediting proves body can't override resolution).
`go test -race ./...` — **16/16 pass**, `go vet` clean, `gofmt` clean. Verified cases:
| Case | Result |
|---|---|
| Hash-only `{"hash":...}` with resolvable hash + verified owner | credited with **resolved** owner/domain |
| Legacy body fields lie (`evil.com`) vs hash URL (`good.com`) | credit goes to resolved `good.com` owner — body ignored |
| Unresolvable hash / empty hash | `opened:true, credited:false` (INFO skip), HTTP 200 |
| Hash URL has no domain | skip `no_domain_in_url`, open succeeds |
| Domain has only `verified=false` rows | skip `no_verified_owner`, open succeeds |
| Multiple verification rows (old/new verified + newer unverified) | **newest verified row wins**; unverified ignored |
| Worker without `DomainOwnerResolver` (in-memory fake) | graceful: no panic, skip `no_resolver_capability`, open succeeds |
| Resolver/credit error | skip/fail credit, open still succeeds |
| `LookupHash` hard error | open fails (`opened:false`) |
| SQLite store reopen | hash persists across process reopen |
| SQL resolver | newest verified via `created_at DESC, id DESC` |
| HTTP handler hash-only | 200 + credit; unresolvable → 200 + skip |
| `ExtractDomain` | scheme-less, ports, subdomains, uppercase, IPs, garbage |
**Live smoke test** (built binary, real SQLite file, curl):
```
$ curl -X POST localhost:39123/v1/open -d '{"hash":"deadbeef"}'
{"opened":true,"credited":true,"owner_id":"acme-123","domain":"acme.example.com"} HTTP 200
$ curl -X POST localhost:39123/v1/open -d '{"hash":"nope"}'
{"opened":true,"credited":false,"skip_reason":"hash_not_found"} HTTP 200
$ curl -X POST localhost:39123/v1/open -d '{}'
{"opened":true,"credited":false,"skip_reason":"hash_not_found"} HTTP 200
$ sqlite3 open.db "SELECT owner_id, domain FROM credits;"
acme-123|acme.example.com ← exactly 1 credit, from a hash-only request
```{"model": "deepseek-v4-flash", "problem_class": "go-hash-only-open-no-credit", "result": "passed", "tests": 16}Root cause. /v1/open only called RecordCredit when both publisher_id AND domain appeared in the request body, contradicting the hash-only spec. A hash-only request therefore opened but never accrued credit.
The fix (~ module opencredits): the handler now resolves everything from the hash and credits with resolved values; unresolvable cases skip with an INFO log while the open still succeeds.
// credits.go — the fixed handler logic
func Open(ctx context.Context, worker Worker, crediter CreditRecorder, req OpenRequest, log *slog.Logger) OpenResult {
if log == nil { log = slog.Default() }
// 1. Resolve URL from the persistent hash store.
url, found, err := worker.LookupHash(ctx, req.Hash)
if err != nil { // hard store failure → fail open
log.Error("hash lookup failed", "hash", req.Hash, "err", err)
return OpenResult{Opened: false, CreditErr: err.Error()}
}
if !found { // unresolvable → INFO skip, still open
log.Info("hash not found; skipping credit", "hash", req.Hash)
return OpenResult{Opened: true, Credited: false, SkipReason: "hash_not_found"}
}
// 2. Extract the domain from the resolved URL.
domain := ExtractDomain(url) // hostname, scheme-less tolerant
if domain == "" {
log.Info("no domain extractable; skipping credit", "hash", req.Hash, "url", url)
return OpenResult{Opened: true, Credited: false, SkipReason: "no_domain_in_url"}
}
// 3. Optional capability: resolve owner from domain_verifications.
resolver, ok := worker.(DomainOwnerResolver) // type-assert; never assumed
if !ok { // in-memory fakes degrade gracefully
log.Info("worker lacks DomainOwnerResolver capability; skipping credit",
"hash", req.Hash, "domain", domain)
return OpenResult{Opened: true, Credited: false, Domain: domain, SkipReason: "no_resolver_capability"}
}
ownerID, resolved, err := resolver.LookupDomainOwner(ctx, domain)
if err != nil || !resolved { // unresolvable → INFO skip, still open
log.Info("no verified owner for domain; skipping credit", "hash", req.Hash, "domain", domain)
return OpenResult{Opened: true, Credited: false, Domain: domain,
SkipReason: "owner_lookup_error / no_verified_owner"}
}
// 4. Credit with RESOLVED values — never the raw body fields.
if err := crediter.RecordCredit(ctx, ownerID, domain); err != nil {
log.Error("credit failed; open still succeeds", "owner_id", ownerID, "domain", domain, "err", err)
return OpenResult{Opened: true, Credited: false, OwnerID: ownerID, Domain: domain, CreditErr: err.Error()}
}
return OpenResult{Opened: true, Credited: true, OwnerID: ownerID, Domain: domain}
}
Supporting pieces:
// hashstore.go — persistent hash store (SQLite `hashes` table)
type SQLHashStore struct{ db *sql.DB } // LookupHash: SELECT url FROM hashes WHERE hash=?
func (s *SQLHashStore) LookupHash(ctx context.Context, hash string) (string, bool, error) // sql.ErrNoRows → found=false
// domainresolver.go — the optional capability
type DomainOwnerResolver interface {
LookupDomainOwner(ctx context.Context, domain string) (ownerID string, found bool, err error)
}
// SQLDomainOwnerResolver: newest VERIFIED row wins
// SELECT owner_id FROM domain_verifications
// WHERE domain = ? AND verified = 1
// ORDER BY created_at DESC, id DESC LIMIT 1
// server.go — HTTP surface
func OpenHTTPHandler(worker Worker, crediter CreditRecorder, log *slog.Logger) http.HandlerFunc
Worker pattern. Worker is interface { HashStore }. DomainOwnerResolver is an optional capability on the concrete worker: production SQLWorker embeds both; in-memory test fakes may embed only HashStore. Open probes with worker.(DomainOwnerResolver) — no capability, no panic: INFO skip, open still succeeds. Request body fields publisher_id/domain are still parsed for client compatibility but no longer gate or influence crediting (test TestOpenHashOnlyIgnoresLegacyBodyFieldsForCrediting proves body can't override resolution).
`go test -race ./...` — **16/16 pass**, `go vet` clean, `gofmt` clean. Verified cases:
| Case | Result |
|---|---|
| Hash-only `{"hash":...}` with resolvable hash + verified owner | credited with **resolved** owner/domain |
| Legacy body fields lie (`evil.com`) vs hash URL (`good.com`) | credit goes to resolved `good.com` owner — body ignored |
| Unresolvable hash / empty hash | `opened:true, credited:false` (INFO skip), HTTP 200 |
| Hash URL has no domain | skip `no_domain_in_url`, open succeeds |
| Domain has only `verified=false` rows | skip `no_verified_owner`, open succeeds |
| Multiple verification rows (old/new verified + newer unverified) | **newest verified row wins**; unverified ignored |
| Worker without `DomainOwnerResolver` (in-memory fake) | graceful: no panic, skip `no_resolver_capability`, open succeeds |
| Resolver/credit error | skip/fail credit, open still succeeds |
| `LookupHash` hard error | open fails (`opened:false`) |
| SQLite store reopen | hash persists across process reopen |
| SQL resolver | newest verified via `created_at DESC, id DESC` |
| HTTP handler hash-only | 200 + credit; unresolvable → 200 + skip |
| `ExtractDomain` | scheme-less, ports, subdomains, uppercase, IPs, garbage |
**Live smoke test** (built binary, real SQLite file, curl):
```
$ curl -X POST localhost:39123/v1/open -d '{"hash":"deadbeef"}'
{"opened":true,"credited":true,"owner_id":"acme-123","domain":"acme.example.com"} HTTP 200
$ curl -X POST localhost:39123/v1/open -d '{"hash":"nope"}'
{"opened":true,"credited":false,"skip_reason":"hash_not_found"} HTTP 200
$ curl -X POST localhost:39123/v1/open -d '{}'
{"opened":true,"credited":false,"skip_reason":"hash_not_found"} HTTP 200
$ sqlite3 open.db "SELECT owner_id, domain FROM credits;"
acme-123|acme.example.com ← exactly 1 credit, from a hash-only request
```{"model": "deepseek-v4-flash", "problem_class": "go-hash-only-open-no-credit", "result": "passed", "tests": 16}