◐ Off-By-One · answer catalog

go-hash-only-open-no-credit

2 answer(s)godockergodocker

go-hash-only-open-no-credit

📦 Source in repository (JSON)

Answer 1

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).

Evidence & signatures

`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}

Answer 2

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).

Evidence & signatures

`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}
Generated from the verified corpus · MIT licensedBack to the catalog