{"record":{"id":"61dbee54c6ec0d6c","repo":"gastownhall/beads","slug":"provenance-ref-kind-git-sha-requires-a-40-charact","errorCode":null,"errorMessage":"provenance: ref-kind git-sha requires a 40-character lowercase hex ref","messagePattern":"provenance: ref-kind git-sha requires a 40-character lowercase hex ref","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/provenance.go","lineNumber":72,"sourceCode":"\tif _, ok := knownProvKinds[ev.Kind]; !ok {\n\t\treturn fmt.Errorf(\"provenance: unknown kind %q\", ev.Kind)\n\t}\n\tif strings.TrimSpace(ev.Source) == \"\" {\n\t\treturn fmt.Errorf(\"provenance: source is required\")\n\t}\n\tif strings.EqualFold(strings.TrimSpace(ev.Source), ReservedProvSource) {\n\t\treturn fmt.Errorf(\"provenance: source %q is reserved for ingest backfill and cannot be recorded directly\", ReservedProvSource)\n\t}\n\tif ev.RefKind != nil {\n\t\tif _, ok := knownProvRefKinds[*ev.RefKind]; !ok {\n\t\t\treturn fmt.Errorf(\"provenance: unknown ref-kind %q\", *ev.RefKind)\n\t\t}\n\t\tif ev.Ref == nil || *ev.Ref == \"\" {\n\t\t\treturn fmt.Errorf(\"provenance: ref-kind %q requires a ref\", *ev.RefKind)\n\t\t}\n\t\tif *ev.RefKind == \"git-sha\" {\n\t\t\tif !gitSHARE.MatchString(*ev.Ref) {\n\t\t\t\treturn fmt.Errorf(\"provenance: ref-kind git-sha requires a 40-character lowercase hex ref\")\n\t\t\t}\n\t\t}\n\t}\n\t// A ref-less event is keyed by occurred_at for its stable id; without either,\n\t// two distinct events would collapse to the same content-addressed id. Guard\n\t// at the store boundary so every caller (CLI or library) is covered.\n\tif (ev.Ref == nil || *ev.Ref == \"\") && ev.OccurredAt == nil {\n\t\treturn fmt.Errorf(\"provenance: event with no ref requires occurred_at (--at) for a stable id\")\n\t}\n\treturn nil\n}\n\n// ProvenanceEventID computes the deterministic, idempotent id for a provenance\n// event from source:issue:kind:(ref or occurred_at). A producer firing twice\n// with the same facts yields the same id, so the INSERT IGNORE in\n// RecordProvenanceEventInTx is a harmless no-op the second time. The\n// discriminator is the ref when present, otherwise the fixed-width occurred_at,\n// which is why a ref-less event requires --at (so the id is caller-owned, never","sourceCodeStart":54,"sourceCodeEnd":90,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/provenance.go#L54-L90","documentation":"When a provenance event's RefKind is \"git-sha\", ValidateProvenanceEvent additionally requires the Ref to match gitSHARE, a 40-character lowercase hex regex (a full git commit SHA). Anything shorter, uppercase, or non-hex is rejected. This guarantees provenance records reference a canonical commit identity.","triggerScenarios":"Calling RecordProvenanceEventInTx with RefKind \"git-sha\" and a Ref that is a short SHA (e.g. 7 chars from `git rev-parse --short`), an uppercase SHA, a branch name, or a tag instead of a full commit hash.","commonSituations":"Piping `git rev-parse --short HEAD` output into the event; copying SHAs from UI tools that truncate; normalizing SHAs with strings.ToUpper; passing a ref name where a SHA is expected.","solutions":["Use the full commit SHA: `git rev-parse HEAD` (no --short) and lowercase it before assigning to ev.Ref","Validate the ref against ^[0-9a-f]{40}$ in the caller before building the event","If you only have a branch/tag, use the appropriate RefKind instead of \"git-sha\""],"exampleFix":"// before\nev := ProvenanceEvent{RefKind: strPtr(\"git-sha\"), Ref: &shortSHA}\n// after\nfullSHA := exec.Command(\"git\", \"rev-parse\", \"HEAD\").Out()\nfullSHA = strings.ToLower(strings.TrimSpace(fullSHA))\nev := ProvenanceEvent{RefKind: strPtr(\"git-sha\"), Ref: &fullSHA}","handlingStrategy":"validation","validationCode":"var gitSHARE = regexp.MustCompile(`^[0-9a-f]{40}$`)\nfunc isFullSHA(s string) bool { return gitSHARE.MatchString(s) }","typeGuard":"func asGitSHA(ref string) (*string, bool) {\n  if len(ref) == 40 && regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(ref) {\n    return &ref, true\n  }\n  return nil, false\n}","tryCatchPattern":null,"preventionTips":["Always use `git rev-parse HEAD` without --short","Lowercase SHAs before storing","Reserve RefKind \"git-sha\" strictly for full commit hashes; use other ref kinds for branches/tags"],"tags":["provenance","git","validation"],"backgroundTag":"invalid-git-sha","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}