diff --git a/go/internal/store/forge_authored.go b/go/internal/store/forge_authored.go new file mode 100644 index 00000000..e2a21076 --- /dev/null +++ b/go/internal/store/forge_authored.go @@ -0,0 +1,204 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// The DL-055 forge ownership index (design +// docs/designs/product/compass-forge-write-path/design.md §T7): the durable +// record of every forge artifact Compass AUTHORED on behalf of an agent. The +// write chokepoint (T4) records the row AND the F3 idempotency memo in one +// statement on a create success; a provider error records nothing. The dedup +// lookup (AuthoredArtifactByRequestID) reads the memo before a write to collapse +// a retry carrying the same client_request_id onto the already-authored artifact. + +// ForgeArtifactKind is the store-side artifact kind on the forge coordinate: +// an issue or a pull request. Mirrors the wire kind (issue=1, pull_request=2) +// and the migration's kind CHECK IN (1, 2); never 0 on a persisted row. +type ForgeArtifactKind int32 + +const ( + ForgeArtifactKindUnspecified ForgeArtifactKind = 0 // never persisted; the wire zero + ForgeArtifactKindIssue ForgeArtifactKind = 1 + ForgeArtifactKindPullRequest ForgeArtifactKind = 2 +) + +// AuthoredArtifact is one row of the ownership index: the forge coordinate an +// authored write minted, the agent it was authored for and that agent's owning +// user, the session that drove it, the F3 idempotency memo key, and the birth +// time. ClientRequestID "" means the caller supplied no key — stored as SQL +// NULL so null-key rows never collide under the partial unique memo index. +type AuthoredArtifact struct { + Provider ForgeProvider + Host string + Repo string + Kind ForgeArtifactKind + Number uint64 + + AgentAccountID AccountID + OwnerUserID AccountID + SessionID string + + ClientRequestID string // F3 idempotency memo key; "" = no key supplied + CreatedAtUnixMS int64 +} + +// validArtifact rejects the zero/empty fields RecordAuthoredArtifact guards on +// before any DB round trip: the coordinate (via validCoordinate), a zero kind +// (never UNSPECIFIED(0), the CHECK's job in Go space), and the required +// account ids. A caller bug is ErrInvalidArgument. +func (a AuthoredArtifact) valid() error { + if err := validCoordinate(a.Provider, a.Host, a.Repo); err != nil { + return err + } + if a.Kind == ForgeArtifactKindUnspecified { + return fmt.Errorf("%w: artifact kind is required", ErrInvalidArgument) + } + if a.AgentAccountID == "" { + return fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) + } + if a.OwnerUserID == "" { + return fmt.Errorf("%w: owner user id is required", ErrInvalidArgument) + } + return nil +} + +// RecordAuthoredArtifact idempotently upserts the ownership row at a's forge +// coordinate, writing the row AND the F3 memo in one statement. A retry of the +// same authored create (same coordinate) re-lands on the PK and updates the +// row in place. ClientRequestID "" is stored as SQL NULL so null-key rows never +// collide under the partial unique memo index. A duplicate (agent, +// client_request_id) non-null key is ErrConflict; an unknown agent/owner is +// ErrInvalidArgument (the FK RESTRICT). Zero/empty fields -> ErrInvalidArgument. +func (s *Store) RecordAuthoredArtifact(ctx context.Context, a AuthoredArtifact) error { + if err := a.valid(); err != nil { + return err + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO forge_authored_artifacts + (forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE + SET agent_account_id = EXCLUDED.agent_account_id, + owner_user_id = EXCLUDED.owner_user_id, + session_id = EXCLUDED.session_id, + client_request_id = EXCLUDED.client_request_id, + created_at_unix_ms = EXCLUDED.created_at_unix_ms`, + int32(a.Provider), a.Host, a.Repo, int32(a.Kind), int64(a.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain — never near the uint64 ceiling. + string(a.AgentAccountID), string(a.OwnerUserID), a.SessionID, + nullIfEmpty(a.ClientRequestID), a.CreatedAtUnixMS, + ); err != nil { + if pgErrIs(err, pgUniqueViolation) { + return fmt.Errorf("%w: client request id %q already authored for agent %q", ErrConflict, a.ClientRequestID, a.AgentAccountID) + } + if pgErrIs(err, pgForeignKeyViolation) { + return fmt.Errorf("%w: unknown agent %q or owner %q", ErrInvalidArgument, a.AgentAccountID, a.OwnerUserID) + } + return fmt.Errorf("store: record authored artifact: %w", err) + } + return nil +} + +// AuthoredArtifactByRequestID is the F3 dedup lookup: the artifact the agent +// authored under clientRequestID, or ok=false on a miss. An empty +// clientRequestID is always a miss (it is never stored — a null-key row carries +// no key to match), never returning a NULL-key row. Zero agent -> +// ErrInvalidArgument. +func (s *Store) AuthoredArtifactByRequestID(ctx context.Context, agent AccountID, clientRequestID string) (AuthoredArtifact, bool, error) { + if agent == "" { + return AuthoredArtifact{}, false, fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) + } + if clientRequestID == "" { + return AuthoredArtifact{}, false, nil + } + row := s.pool.QueryRow(ctx, + `SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms + FROM forge_authored_artifacts + WHERE agent_account_id = $1 AND client_request_id = $2`, + string(agent), clientRequestID, + ) + a, err := scanAuthoredArtifact(row) + if err != nil { + if noRows(err) { + return AuthoredArtifact{}, false, nil + } + return AuthoredArtifact{}, false, fmt.Errorf("store: read authored artifact by request id: %w", err) + } + return a, true, nil +} + +// ListAuthoredArtifactsByAgent reads every artifact the agent authored, ordered +// deterministically by created_at then coordinate. No rows is a nil slice, not +// an error. Zero agent -> ErrInvalidArgument. +func (s *Store) ListAuthoredArtifactsByAgent(ctx context.Context, agent AccountID) ([]AuthoredArtifact, error) { + if agent == "" { + return nil, fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) + } + rows, err := s.pool.Query(ctx, + `SELECT forge_provider, forge_host, repo, kind, number, + agent_account_id, owner_user_id, session_id, client_request_id, created_at_unix_ms + FROM forge_authored_artifacts + WHERE agent_account_id = $1 + ORDER BY created_at_unix_ms ASC, forge_provider ASC, forge_host ASC, repo ASC, kind ASC, number ASC`, + string(agent), + ) + if err != nil { + return nil, fmt.Errorf("store: list authored artifacts by agent: %w", err) + } + defer rows.Close() + + var out []AuthoredArtifact + for rows.Next() { + a, err := scanAuthoredArtifact(rows) + if err != nil { + return nil, fmt.Errorf("store: scan authored artifact: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate authored artifacts: %w", err) + } + return out, nil +} + +// scanAuthoredArtifact scans one row into an AuthoredArtifact, mapping the +// nullable client_request_id column to "" (no key) via a pgx-native scan. +func scanAuthoredArtifact(row pgx.Row) (AuthoredArtifact, error) { + var ( + a AuthoredArtifact + provider int32 + kind int32 + number int64 + agent string + owner string + reqID *string + ) + if err := row.Scan(&provider, &a.Host, &a.Repo, &kind, &number, + &agent, &owner, &a.SessionID, &reqID, &a.CreatedAtUnixMS); err != nil { + return AuthoredArtifact{}, err + } + a.Provider = ForgeProvider(provider) + a.Kind = ForgeArtifactKind(kind) + a.Number = uint64(number) //nolint:gosec // G115: number is a BIGINT written only from a canonical uint64 artifact number (RecordAuthoredArtifact narrows nothing), so the stored value is always within the uint64 domain. + a.AgentAccountID = AccountID(agent) + a.OwnerUserID = AccountID(owner) + if reqID != nil { + a.ClientRequestID = *reqID + } + return a, nil +} + +// nullIfEmpty maps the empty client_request_id (no key supplied) to a typed nil +// so it stores as SQL NULL — the partial unique memo index only constrains +// non-NULL keys, so null-key rows never collide. +func nullIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/go/internal/store/forge_authored_pgtest_test.go b/go/internal/store/forge_authored_pgtest_test.go new file mode 100644 index 00000000..74b8d580 --- /dev/null +++ b/go/internal/store/forge_authored_pgtest_test.go @@ -0,0 +1,368 @@ +//go:build pgtest + +package store + +// DL-055 forge ownership-index store contracts (design +// docs/designs/product/compass-forge-write-path/design.md §T7 test cycle, the +// DL-174 pair: this pgtest suite plus the in-memory reference in +// forge_authored_test.go): the migration 0002 table shape, the idempotent +// coordinate upsert, the FK RESTRICT on agent/owner, the by-agent scan order, +// the F3 memo lookup (hit/miss), and the UNIQUE violation on a duplicate +// (agent, client_request_id) non-null key with null-key rows never colliding. +// context.Background is the test root (the pgtest-suite convention, sibling +// forge_cursors_pgtest_test.go). + +import ( + "context" + "testing" +) + +// seedAgent creates an owner user + owned agent and returns their ids, so the +// ownership rows have real FK referents. +func seedAgent(t *testing.T, s *Store, handle string) (agent, owner AccountID) { + t.Helper() + u := mustUser(t, s, handle+"-owner") + a := mustAgent(t, s, u.ID, handle+"-agent") + return a.ID, u.ID +} + +// ── Test 1: insert + read-back through the by-agent scan ────────────────────── + +func TestRecordAuthoredArtifactInsertReadBack(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t1") + + want := AuthoredArtifact{ + Provider: ForgeProviderGitHub, + Host: "github.com", + Repo: "a/b", + Kind: ForgeArtifactKindIssue, + Number: 42, + AgentAccountID: agent, + OwnerUserID: owner, + SessionID: "sess-1", + ClientRequestID: "req-1", + CreatedAtUnixMS: 1000, + } + if err := s.RecordAuthoredArtifact(ctx, want); err != nil { + t.Fatalf("record: %v", err) + } + + got, err := s.ListAuthoredArtifactsByAgent(ctx, agent) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 { + t.Fatalf("row count = %d, want 1", len(got)) + } + if got[0] != want { + t.Fatalf("read-back = %+v, want %+v", got[0], want) + } +} + +// ── Test 2: idempotent re-insert on the coordinate PK — one row, updated ────── + +func TestRecordAuthoredArtifactIdempotentUpsert(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t2") + + base := AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindPullRequest, Number: 7, + AgentAccountID: agent, OwnerUserID: owner, + SessionID: "sess-1", ClientRequestID: "req-1", CreatedAtUnixMS: 1000, + } + if err := s.RecordAuthoredArtifact(ctx, base); err != nil { + t.Fatalf("record: %v", err) + } + // Re-record the SAME coordinate with mutated non-key fields: an upsert, not + // a duplicate. session_id/created_at update in place. + base.SessionID = "sess-2" + base.CreatedAtUnixMS = 2000 + if err := s.RecordAuthoredArtifact(ctx, base); err != nil { + t.Fatalf("re-record: %v", err) + } + + got, err := s.ListAuthoredArtifactsByAgent(ctx, agent) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 { + t.Fatalf("row count = %d, want 1 (upsert, not insert)", len(got)) + } + if got[0].SessionID != "sess-2" || got[0].CreatedAtUnixMS != 2000 { + t.Fatalf("row not updated in place: %+v", got[0]) + } +} + +// ── Test 3: FK RESTRICT — unknown agent / owner is rejected ─────────────────── + +func TestRecordAuthoredArtifactFKRestrict(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t3") + + // Unknown agent. + err := s.RecordAuthoredArtifact(ctx, AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Number: 1, + AgentAccountID: "no-such-agent", OwnerUserID: owner, CreatedAtUnixMS: 1, + }) + sentinelIs(t, err, ErrInvalidArgument, "unknown agent FK") + + // Unknown owner. + err = s.RecordAuthoredArtifact(ctx, AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Number: 2, + AgentAccountID: agent, OwnerUserID: "no-such-owner", CreatedAtUnixMS: 1, + }) + sentinelIs(t, err, ErrInvalidArgument, "unknown owner FK") +} + +// ── Test 4: by-agent scan ordering (created_at then coordinate) ─────────────── + +func TestListAuthoredArtifactsByAgentOrdering(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t4") + other, otherOwner := seedAgent(t, s, "t4-other") + + // Insert out of created-at order; expect ascending created_at back. + rows := []AuthoredArtifact{ + {Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", Kind: ForgeArtifactKindIssue, Number: 3, AgentAccountID: agent, OwnerUserID: owner, CreatedAtUnixMS: 300}, + {Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", Kind: ForgeArtifactKindIssue, Number: 1, AgentAccountID: agent, OwnerUserID: owner, CreatedAtUnixMS: 100}, + {Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", Kind: ForgeArtifactKindIssue, Number: 2, AgentAccountID: agent, OwnerUserID: owner, CreatedAtUnixMS: 200}, + } + for _, r := range rows { + if err := s.RecordAuthoredArtifact(ctx, r); err != nil { + t.Fatalf("record %d: %v", r.Number, err) + } + } + // A foreign agent's row must not leak into this agent's scan. + if err := s.RecordAuthoredArtifact(ctx, AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", Kind: ForgeArtifactKindIssue, Number: 9, + AgentAccountID: other, OwnerUserID: otherOwner, CreatedAtUnixMS: 50, + }); err != nil { + t.Fatalf("record foreign: %v", err) + } + + got, err := s.ListAuthoredArtifactsByAgent(ctx, agent) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 3 { + t.Fatalf("row count = %d, want 3 (foreign agent excluded)", len(got)) + } + if got[0].Number != 1 || got[1].Number != 2 || got[2].Number != 3 { + t.Fatalf("order = [%d %d %d], want ascending created_at [1 2 3]", got[0].Number, got[1].Number, got[2].Number) + } + + // No rows for an agent that authored nothing is a nil slice, not an error. + empty, otherErr := s.ListAuthoredArtifactsByAgent(ctx, otherOwner) + if otherErr != nil { + t.Fatalf("list empty: %v", otherErr) + } + if empty != nil { + t.Fatalf("no-rows = %v, want nil", empty) + } +} + +// ── Test 5: F3 memo lookup — hit and miss ───────────────────────────────────── + +func TestAuthoredArtifactByRequestID(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t5") + + want := AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Number: 5, + AgentAccountID: agent, OwnerUserID: owner, + SessionID: "sess", ClientRequestID: "req-hit", CreatedAtUnixMS: 500, + } + if err := s.RecordAuthoredArtifact(ctx, want); err != nil { + t.Fatalf("record: %v", err) + } + + // Hit. + got, ok, err := s.AuthoredArtifactByRequestID(ctx, agent, "req-hit") + if err != nil { + t.Fatalf("lookup hit: %v", err) + } + if !ok { + t.Fatal("lookup = miss, want hit") + } + if got != want { + t.Fatalf("lookup = %+v, want %+v", got, want) + } + + // Miss: unknown key. + _, ok, err = s.AuthoredArtifactByRequestID(ctx, agent, "req-none") + if err != nil { + t.Fatalf("lookup miss: %v", err) + } + if ok { + t.Fatal("unknown key = hit, want miss") + } + + // Miss: right key, wrong agent (the memo is per-agent scoped). + otherAgent, _ := seedAgent(t, s, "t5-other") + _, ok, err = s.AuthoredArtifactByRequestID(ctx, otherAgent, "req-hit") + if err != nil { + t.Fatalf("lookup wrong agent: %v", err) + } + if ok { + t.Fatal("key under wrong agent = hit, want miss") + } + + // Empty clientRequestID is ALWAYS a miss — it never matches a NULL-key row. + _, ok, err = s.AuthoredArtifactByRequestID(ctx, agent, "") + if err != nil { + t.Fatalf("lookup empty key: %v", err) + } + if ok { + t.Fatal("empty clientRequestID = hit, want always-miss") + } +} + +// ── Test 6: UNIQUE violation on a duplicate (agent, client_request_id) key ──── + +func TestRecordAuthoredArtifactDuplicateRequestIDConflict(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t6") + + first := AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Number: 1, + AgentAccountID: agent, OwnerUserID: owner, ClientRequestID: "dup", CreatedAtUnixMS: 1, + } + if err := s.RecordAuthoredArtifact(ctx, first); err != nil { + t.Fatalf("record first: %v", err) + } + // A DIFFERENT coordinate reusing the same (agent, client_request_id) key: + // not a PK conflict (distinct coordinate) but a memo UNIQUE violation. + second := first + second.Number = 2 + err := s.RecordAuthoredArtifact(ctx, second) + sentinelIs(t, err, ErrConflict, "duplicate (agent, client_request_id) memo key") + + // The SAME key under a DIFFERENT agent is fine (memo is per-agent). + otherAgent, otherOwner := seedAgent(t, s, "t6-other") + third := first + third.AgentAccountID = otherAgent + third.OwnerUserID = otherOwner + third.Number = 3 + if err := s.RecordAuthoredArtifact(ctx, third); err != nil { + t.Fatalf("record same key different agent: %v", err) + } +} + +// ── Test 7: null-key rows never collide and are never returned by lookup ────── + +func TestRecordAuthoredArtifactNullKeyRowsDoNotCollide(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t7") + + // Two DISTINCT coordinates, both with an empty clientRequestID (NULL key). + // The partial unique index must NOT treat two NULLs as a collision. + for _, n := range []uint64{1, 2} { + if err := s.RecordAuthoredArtifact(ctx, AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Number: n, + AgentAccountID: agent, OwnerUserID: owner, ClientRequestID: "", CreatedAtUnixMS: int64(n), + }); err != nil { + t.Fatalf("record null-key %d: %v", n, err) + } + } + got, err := s.ListAuthoredArtifactsByAgent(ctx, agent) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 2 { + t.Fatalf("row count = %d, want 2 (null-key rows do not collide)", len(got)) + } + for _, a := range got { + if a.ClientRequestID != "" { + t.Fatalf("null-key row read back with key %q, want empty", a.ClientRequestID) + } + } + // A null-key row is never returned by the memo lookup, even by empty key. + if _, ok, _ := s.AuthoredArtifactByRequestID(ctx, agent, ""); ok { + t.Fatal("empty-key lookup returned a null-key row, want always-miss") + } +} + +// ── Test 8: migration 0002 table shape — provider/kind CHECK domains ────────── + +func TestMigration0002AuthoredArtifactChecks(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, owner := seedAgent(t, s, "t8") + + insert := func(provider, kind int) error { + _, err := s.pool.Exec(ctx, + `INSERT INTO forge_authored_artifacts + (forge_provider, forge_host, repo, kind, number, agent_account_id, owner_user_id, created_at_unix_ms) + VALUES ($1, 'github.com', 'a/b', $2, 1, $3, $4, 1)`, + provider, kind, string(agent), string(owner)) + return err + } + // provider domain: 0 and 5 rejected. + for _, p := range []int{0, 5} { + if err := insert(p, 1); err == nil { + t.Fatalf("provider %d accepted, want CHECK rejection", p) + } + } + // kind domain: 0 and 3 rejected. + for _, k := range []int{0, 3} { + if err := insert(1, k); err == nil { + t.Fatalf("kind %d accepted, want CHECK rejection", k) + } + } + // A legal row inserts. + if err := insert(1, 1); err != nil { + t.Fatalf("legal row rejected: %v", err) + } +} + +// ── Test 9: invalid input → ErrInvalidArgument on every method ──────────────── + +func TestForgeAuthoredInvalidArgument(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + good := AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "h", Repo: "r", + Kind: ForgeArtifactKindIssue, Number: 1, AgentAccountID: "a", OwnerUserID: "o", + } + zeroProvider := good + zeroProvider.Provider = ForgeProviderUnspecified + sentinelIs(t, s.RecordAuthoredArtifact(ctx, zeroProvider), ErrInvalidArgument, "record zero provider") + + emptyHost := good + emptyHost.Host = "" + sentinelIs(t, s.RecordAuthoredArtifact(ctx, emptyHost), ErrInvalidArgument, "record empty host") + + emptyRepo := good + emptyRepo.Repo = "" + sentinelIs(t, s.RecordAuthoredArtifact(ctx, emptyRepo), ErrInvalidArgument, "record empty repo") + + zeroKind := good + zeroKind.Kind = ForgeArtifactKindUnspecified + sentinelIs(t, s.RecordAuthoredArtifact(ctx, zeroKind), ErrInvalidArgument, "record zero kind") + + noAgent := good + noAgent.AgentAccountID = "" + sentinelIs(t, s.RecordAuthoredArtifact(ctx, noAgent), ErrInvalidArgument, "record empty agent") + + noOwner := good + noOwner.OwnerUserID = "" + sentinelIs(t, s.RecordAuthoredArtifact(ctx, noOwner), ErrInvalidArgument, "record empty owner") + + sentinelIs(t, mustErr(func() error { _, _, e := s.AuthoredArtifactByRequestID(ctx, "", "req"); return e }), ErrInvalidArgument, "lookup empty agent") + sentinelIs(t, mustErr(func() error { _, e := s.ListAuthoredArtifactsByAgent(ctx, ""); return e }), ErrInvalidArgument, "list empty agent") +} diff --git a/go/internal/store/forge_authored_test.go b/go/internal/store/forge_authored_test.go new file mode 100644 index 00000000..e4979a07 --- /dev/null +++ b/go/internal/store/forge_authored_test.go @@ -0,0 +1,76 @@ +package store + +// The hermetic default-gate half of the DL-174 pair for the DL-055 ownership +// index (design docs/designs/product/compass-forge-write-path/design.md §T7): +// the pure-Go contract that needs no Postgres — the pre-DB argument guards, the +// empty-clientRequestID always-miss short-circuit, and the NULL client_request_id +// mapping. The real-Postgres row contracts live in the pgtest sibling +// (forge_authored_pgtest_test.go). context.Background is the test root. + +import ( + "context" + "errors" + "testing" +) + +// TestAuthoredArtifactValid pins the pre-DB argument guards (Store.valid), +// exercised without a database: each malformed field is ErrInvalidArgument +// before any pool call, and a fully-populated artifact passes. +func TestAuthoredArtifactValid(t *testing.T) { + good := AuthoredArtifact{ + Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Number: 1, AgentAccountID: "agent", OwnerUserID: "owner", + } + if err := good.valid(); err != nil { + t.Fatalf("valid artifact rejected: %v", err) + } + + tests := []struct { + name string + mut func(a *AuthoredArtifact) + }{ + {"zero provider", func(a *AuthoredArtifact) { a.Provider = ForgeProviderUnspecified }}, + {"empty host", func(a *AuthoredArtifact) { a.Host = "" }}, + {"empty repo", func(a *AuthoredArtifact) { a.Repo = "" }}, + {"zero kind", func(a *AuthoredArtifact) { a.Kind = ForgeArtifactKindUnspecified }}, + {"empty agent", func(a *AuthoredArtifact) { a.AgentAccountID = "" }}, + {"empty owner", func(a *AuthoredArtifact) { a.OwnerUserID = "" }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + bad := good + tc.mut(&bad) + if err := bad.valid(); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("%s: err = %v, want errors.Is(_, ErrInvalidArgument)", tc.name, err) + } + }) + } +} + +// TestAuthoredArtifactByRequestIDEmptyKeyMiss proves the empty-clientRequestID +// always-miss short-circuit returns (ok=false, nil) BEFORE any DB round trip — +// a null-key row carries no key to match. Runs against a poolless Store, so a +// pool call would panic; reaching a clean miss proves the short-circuit. +func TestAuthoredArtifactByRequestIDEmptyKeyMiss(t *testing.T) { + s := &Store{} // nil pool: the short-circuit must not touch it + _, ok, err := s.AuthoredArtifactByRequestID(context.Background(), "agent", "") + if err != nil { + t.Fatalf("empty-key lookup: %v", err) + } + if ok { + t.Fatal("empty clientRequestID = hit, want always-miss") + } +} + +// TestNullIfEmpty pins the NULL client_request_id mapping: "" becomes a typed +// nil (SQL NULL, so null-key rows never collide under the partial unique memo +// index), a non-empty key is passed through by value. +func TestNullIfEmpty(t *testing.T) { + if got := nullIfEmpty(""); got != nil { + t.Fatalf("nullIfEmpty(\"\") = %v, want nil (SQL NULL)", *got) + } + got := nullIfEmpty("req-1") + if got == nil || *got != "req-1" { + t.Fatalf("nullIfEmpty(%q) = %v, want a pointer to it", "req-1", got) + } +} diff --git a/go/internal/store/migrations/0002_forge_authored_artifacts.sql b/go/internal/store/migrations/0002_forge_authored_artifacts.sql new file mode 100644 index 00000000..f9cba41a --- /dev/null +++ b/go/internal/store/migrations/0002_forge_authored_artifacts.sql @@ -0,0 +1,44 @@ +-- 0002_forge_authored_artifacts: the DL-055 forge ownership index. One row per +-- forge artifact Compass AUTHORED on behalf of an agent — the coordinate the +-- write path minted, who authored it, and the F3 idempotency memo. The write +-- chokepoint (T4) writes the row AND the memo in a single statement on a create +-- success; a provider error writes NOTHING (no row, no memo). +-- +-- Convention (mirrors 0001_init): the SMALLINT provider enum + forge_host in +-- the key, FK ON DELETE RESTRICT so a referenced account cannot be orphaned out +-- from under an ownership row, and a provider CHECK IN (1,2,3,4) whose job is +-- "never UNSPECIFIED(0)", not rollout gating. + +-- PK is the forge coordinate (provider, host, repo, kind, number) — the same +-- coordinate shape forge_artifact_cursors keys on. A retry of the same authored +-- create idempotently re-lands on this key (ON CONFLICT upsert). kind CHECK +-- IN (1, 2): 1=issue, 2=pull_request, matching agent_forge_subscriptions.kind. +-- +-- client_request_id is NULLABLE: NULL when the caller supplied no idempotency +-- key. The UNIQUE PARTIAL index on (agent_account_id, client_request_id) WHERE +-- client_request_id IS NOT NULL is the F3 memo — it dedups a per-agent retry +-- carrying the same key, while NULL-key rows never collide (a NULL is distinct +-- from every other NULL under the partial index's WHERE filter). +CREATE TABLE forge_authored_artifacts ( + forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)), + forge_host TEXT NOT NULL, + repo TEXT NOT NULL, + kind SMALLINT NOT NULL CHECK (kind IN (1, 2)), + number BIGINT NOT NULL, -- canonical uint64 + agent_account_id TEXT NOT NULL REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, + owner_user_id TEXT NOT NULL REFERENCES user_accounts (account_id) ON DELETE RESTRICT, + session_id TEXT NOT NULL DEFAULT '', + client_request_id TEXT, -- NULL = caller supplied no idempotency key (F3) + created_at_unix_ms BIGINT NOT NULL, + PRIMARY KEY (forge_provider, forge_host, repo, kind, number) +); + +-- The F3 memo: a per-agent idempotency key is unique across the agent's +-- authored artifacts. Partial so NULL-key rows (no key supplied) never collide. +CREATE UNIQUE INDEX forge_authored_artifacts_request_memo_idx + ON forge_authored_artifacts (agent_account_id, client_request_id) + WHERE client_request_id IS NOT NULL; + +-- By-agent scan (ListAuthoredArtifactsByAgent): every artifact one agent authored. +CREATE INDEX forge_authored_artifacts_agent_idx + ON forge_authored_artifacts (agent_account_id);