Skip to content

Commit de641b2

Browse files
authored
Merge pull request #282 from morluto/agent/make-invalid-states-unrepresentable
refactor: make invalid states unrepresentable
2 parents b5397f8 + d786f45 commit de641b2

245 files changed

Lines changed: 5680 additions & 2413 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/architecture.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ remain write operations. Catalog changes require realistic multi-call agent
346346
evaluations, including held-out queries, tool-call count, errors, latency, and
347347
context size; scripted schema checks alone do not establish good tool choice.
348348

349+
Recovery-plan actions are sealed variants. The concrete input type derives the
350+
action discriminator, the output schema advertises the variants with `oneOf`,
351+
and decoding rejects unknown, mismatched, or multiple payloads. Application
352+
code therefore receives a typed action rather than validating a discriminator
353+
against a nullable argument bag.
354+
349355
The canonical source-audit workflow is a machine-readable contract exposed by
350356
`workflow.get_source_audit_contract`:
351357

@@ -430,6 +436,42 @@ rate capacity. Only replayable reads are retried. Backoff honors GitHub rate
430436
headers, is bounded, observes context cancellation, and redacts URL userinfo
431437
before retry metadata is persisted.
432438

439+
Repository identities are parsed at input, provider, and persistence
440+
boundaries into a private, comparable `domain.RepoRef`. Interior code cannot
441+
construct an owner without a repository name, carry whitespace, or bypass the
442+
owner and repository grammar; it receives a parsed identity and uses explicit
443+
accessors. The zero value is reserved for optional scope and must be tested
444+
with `IsValid`. JSON decoding reparses the identity, and larger domain records
445+
hold it in named fields so its codec cannot be promoted over the enclosing
446+
record.
447+
448+
Pull-request merge knowledge is likewise a parsed `domain.MergeStatus`, not
449+
independent `merged`, `merged_known`, and `merged_at` fields. Constructors make
450+
unknown, observed-unmerged, and observed-merged outcomes explicit. SQLite and
451+
GitHub adapter reads reject contradictions such as an unknown outcome marked
452+
merged or an unmerged outcome with a merge timestamp; interior code cannot
453+
create those combinations. The relational schema keeps scalar columns for
454+
querying, but rows are reparsed before they enter application models.
455+
456+
Durable run and job lifecycles are read through private state values that bind
457+
statuses to their timestamps. Running work cannot be completed, queued jobs
458+
cannot already be started, terminal work requires a completion time, and only
459+
cancelled or cancellation-requested jobs carry a cancellation time. Terminal
460+
run transitions are conditional on the stored running state, while job
461+
transitions update the status and required timestamps atomically. Corrupt or
462+
unknown persisted combinations fail at the corpus boundary.
463+
464+
JSON inputs that express alternatives remain wire-compatible discriminated
465+
objects, but they are parsed before any durable job is submitted. Thread sync
466+
becomes either repository discovery with repository-only filters or an exact
467+
thread set. Portfolio sync becomes either authored discovery or an explicit
468+
pull-request set. Actor identity becomes either a canonical login or a node ID,
469+
and coverage becomes either a repository target or an exact typed thread.
470+
Workers receive these private variants rather than the original field bags, so
471+
mode-specific fields cannot be silently ignored and identity strings are
472+
canonicalized before duplicate detection. The normalized wire form, not the
473+
caller's mutable slices or pointers, is what the durable job records.
474+
433475
## Acquisition and workspaces
434476

435477
Acquisition and workspace packages invoke `git` directly with prompts, hooks,
@@ -453,6 +495,30 @@ host paths. The application resolves each ID and verifies that it belongs to
453495
the selected investigation before persisting executable state. The explicit
454496
CLI remains a local-user interface and may accept a directly supplied path.
455497

498+
Observation definitions cross command and MCP boundaries as untrusted specs.
499+
The application parses a complete base-and-candidate contract before it enters
500+
the evidence service. Parsed observations have a private representation: their
501+
source and artifact-path relationship is established once, default occurrence
502+
is normalized, and regular expressions are compiled once for execution.
503+
Persistence decodes through the same parser, so malformed stored contracts do
504+
not re-enter the trusted model. Execution therefore consumes parsed values and
505+
does not repeat structural validation or regular-expression compilation.
506+
507+
Durable workflow JSON is parsed again on read. Concern, investigation,
508+
hypothesis, opportunity, validation, and evidence discriminators cannot enter
509+
application logic as unchecked strings; legacy empty states are canonicalized
510+
only where their historical meaning is unambiguous. Telemetry metrics decode as
511+
either an available value or an unavailable reason and reject payloads claiming
512+
both. External validation receipts atomically store their synthetic definition
513+
and run, while external evidence manifests atomically store the complete claim
514+
set. A failed import therefore leaves no orphan definition or partial manifest.
515+
516+
Bulk local-metadata and collection inputs are fully parsed before writable
517+
corpus access. Collection references are stored in canonical repository,
518+
thread, or UUID form, and malformed later members cannot follow earlier writes.
519+
Thread projections similarly parse kind, lifecycle state, repository key,
520+
and number before a transaction begins and again when SQLite rows are read.
521+
456522
## Search and analysis
457523

458524
Search uses the local SQLite corpus and FTS5 indexes; agents query bounded
@@ -468,6 +534,14 @@ Snapshots created before manifests were introduced report
468534
`indexed_coverage_unknown`; their zero skip counts are never presented as proof
469535
of complete coverage.
470536

537+
Repository coverage uses collection membership to represent presence: a
538+
returned `domain.FacetCoverage` is necessarily present, while a missing facet is
539+
absent from the collection. Its private constructor binds the facet name,
540+
observation time, completeness, and non-negative count. Immutable code-index
541+
artifacts similarly use their digest-bound manifest as the sole in-memory
542+
authority; duplicated query columns are checked against that manifest while
543+
decoding and discarded rather than exposed as a second source of truth.
544+
471545
Title, labels, body, and hydrated evidence are materialized into one search
472546
document per thread and ranked by one BM25 invocation. Ranks from the legacy
473547
thread and facet indexes are never compared; the facet index is used only to

internal/acquire/acquire.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ func NewManager(root string, runner runner) (*Manager, error) {
168168
// clean checkout at the resolved default branch. The returned Acquisition
169169
// records remote URL, default branch, commit SHA, and acquisition time.
170170
func (m *Manager) Acquire(ctx context.Context, owner, repo, remote string) (*Acquisition, error) {
171-
ref := domain.RepoRef{Owner: owner, Repo: repo}
172-
if err := ref.Validate(); err != nil {
171+
_, err := domain.NewRepoRef(owner, repo)
172+
if err != nil {
173173
return nil, fmt.Errorf("%w: %w", ErrInvalidRepo, err)
174174
}
175175
if err := validateRemote(remote); err != nil {

internal/app/acquisition.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ import (
1717
// resolved remote URL/default branch/commit SHA/acquired time, and indexes the
1818
// clean checkout into the corpus. It does not execute repository code.
1919
func (s *Service) Acquire(ctx context.Context, repo contracts.RepoRef, remote string) (result *contracts.AcquisitionResult, returnErr error) {
20-
ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}
21-
if err := ref.Validate(); err != nil {
20+
ref, err := domain.NewRepoRef(repo.Owner, repo.Repo)
21+
if err != nil {
2222
return nil, err
2323
}
2424
remote = strings.TrimSpace(remote)
2525
if remote == "" {
26-
remote = fmt.Sprintf("https://github.com/%s/%s.git", ref.Owner, ref.Repo)
26+
remote = fmt.Sprintf("https://github.com/%s/%s.git", ref.Owner(), ref.Repo())
2727
}
2828

2929
cacheRoot, err := s.paths.AcquisitionCacheDir()
@@ -36,7 +36,7 @@ func (s *Service) Acquire(ctx context.Context, repo contracts.RepoRef, remote st
3636
return nil, fmt.Errorf("create acquisition manager: %w", err)
3737
}
3838

39-
acq, err := mgr.Acquire(ctx, ref.Owner, ref.Repo, remote)
39+
acq, err := mgr.Acquire(ctx, ref.Owner(), ref.Repo(), remote)
4040
if err != nil {
4141
return nil, fmt.Errorf("acquire %s: %w", ref, err)
4242
}

internal/app/acquisition_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func TestAcquireSuccess(t *testing.T) {
8383
if err != nil {
8484
t.Fatalf("open corpus: %v", err)
8585
}
86-
snap, err := c.LatestCodeSnapshot(ctx, domain.RepoRef{Owner: "testowner", Repo: "testrepo"})
86+
snap, err := c.LatestCodeSnapshot(ctx, domain.MustRepoRef("testowner", "testrepo"))
8787
if err != nil {
8888
t.Fatalf("latest snapshot: %v", err)
8989
}
@@ -125,7 +125,7 @@ func TestAcquireRepeatFetch(t *testing.T) {
125125
if err != nil {
126126
t.Fatalf("open corpus: %v", err)
127127
}
128-
snap, err := c.LatestCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"})
128+
snap, err := c.LatestCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo"))
129129
if err != nil {
130130
t.Fatalf("latest snapshot: %v", err)
131131
}
@@ -163,7 +163,7 @@ func TestAcquireUnchangedCommitReusesCurrentSnapshot(t *testing.T) {
163163
},
164164
}
165165
if _, _, err := svc.corpus.StoreCodeSnapshot(
166-
ctx, domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, replacement,
166+
ctx, domain.MustRepoRef(ref.Owner, ref.Repo), replacement,
167167
); err != nil {
168168
t.Fatalf("replace snapshot: %v", err)
169169
}
@@ -176,7 +176,7 @@ func TestAcquireUnchangedCommitReusesCurrentSnapshot(t *testing.T) {
176176
t.Fatalf("second acquire = %+v", second)
177177
}
178178
matches, err := svc.corpus.SearchCode(
179-
ctx, "sentinel", domain.RepoRef{Owner: ref.Owner, Repo: ref.Repo}, 10,
179+
ctx, "sentinel", domain.MustRepoRef(ref.Owner, ref.Repo), 10,
180180
)
181181
if err != nil {
182182
t.Fatalf("search preserved snapshot: %v", err)

internal/app/actor_selector.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/morluto/gitcontribute/internal/corpus"
10+
"github.com/morluto/gitcontribute/internal/mcpcontract"
11+
)
12+
13+
// parsedActorSelector is the executable form of ActorSelector's JSON union.
14+
// Implementations contain exactly one identity, so downstream acquisition does
15+
// not need to keep re-checking the discriminator and mutually exclusive fields.
16+
type parsedActorSelector interface {
17+
key() string
18+
resolveLogin(context.Context, *corpus.Corpus) (string, error)
19+
}
20+
21+
type actorLogin string
22+
23+
func (login actorLogin) key() string { return strings.ToLower(string(login)) }
24+
func (login actorLogin) resolveLogin(context.Context, *corpus.Corpus) (string, error) {
25+
return string(login), nil
26+
}
27+
28+
type actorNodeID string
29+
30+
func (nodeID actorNodeID) key() string { return string(nodeID) }
31+
func (nodeID actorNodeID) resolveLogin(ctx context.Context, c *corpus.Corpus) (string, error) {
32+
actor, err := c.GetActor(ctx, string(nodeID))
33+
if err != nil {
34+
return "", err
35+
}
36+
if actor == nil || actor.Login == "" {
37+
return "", fmt.Errorf("node ID %q is not stored; search or sync by login first", nodeID)
38+
}
39+
return actor.Login, nil
40+
}
41+
42+
func parseActorSelectors(inputs []mcpcontract.ActorSelector) ([]parsedActorSelector, []mcpcontract.ActorSelector, error) {
43+
selectors := make([]parsedActorSelector, len(inputs))
44+
normalized := make([]mcpcontract.ActorSelector, len(inputs))
45+
seen := make(map[string]struct{}, len(inputs))
46+
for i, input := range inputs {
47+
switch input.Type {
48+
case "login":
49+
login := strings.TrimSpace(input.Login)
50+
if login == "" || input.NodeID != "" {
51+
return nil, nil, errors.New("login selectors require login and forbid node_id")
52+
}
53+
selectors[i] = actorLogin(login)
54+
normalized[i] = mcpcontract.ActorSelector{Type: "login", Login: login}
55+
case "node_id":
56+
nodeID := strings.TrimSpace(input.NodeID)
57+
if nodeID == "" || input.Login != "" {
58+
return nil, nil, errors.New("node_id selectors require node_id and forbid login")
59+
}
60+
selectors[i] = actorNodeID(nodeID)
61+
normalized[i] = mcpcontract.ActorSelector{Type: "node_id", NodeID: nodeID}
62+
default:
63+
return nil, nil, errors.New("actor selector type must be login or node_id")
64+
}
65+
key := selectors[i].key()
66+
if _, ok := seen[key]; ok {
67+
return nil, nil, fmt.Errorf("duplicate actor selector %q", key)
68+
}
69+
seen[key] = struct{}{}
70+
}
71+
return selectors, normalized, nil
72+
}
73+
74+
func storedActorForSelector(ctx context.Context, c *corpus.Corpus, selector parsedActorSelector) (*corpus.Actor, string, error) {
75+
login, err := selector.resolveLogin(ctx, c)
76+
if err != nil {
77+
return nil, "", err
78+
}
79+
actor, err := c.GetActor(ctx, login)
80+
if err != nil {
81+
return nil, "", err
82+
}
83+
if actor == nil {
84+
return nil, "", fmt.Errorf("actor %q has no stored identity; call github.sync_users first", login)
85+
}
86+
return actor, login, nil
87+
}

internal/app/app.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ func (s *Service) openCorpus(ctx context.Context) (*corpus.Corpus, error) {
219219
if err != nil {
220220
return nil, err
221221
}
222-
if inspection.Exists {
222+
if inspection.Exists() {
223223
switch inspection.State {
224224
case corpus.SchemaMigrationRequired:
225225
return nil, &corpus.MigrationRequiredError{Current: inspection.Current, Target: inspection.Target}
@@ -438,7 +438,7 @@ func (s *Service) Init(ctx context.Context) (*contracts.InitResult, error) {
438438
if err != nil {
439439
return nil, err
440440
}
441-
if inspection.Exists {
441+
if inspection.Exists() {
442442
switch inspection.State {
443443
case corpus.SchemaMigrationRequired:
444444
return nil, &corpus.MigrationRequiredError{Current: inspection.Current, Target: inspection.Target}
@@ -645,8 +645,8 @@ func corpusRepoFromGitHub(r github.Repository) corpus.Repository {
645645

646646
// Dossier builds a deterministic, local-corpus-backed repository dossier.
647647
func (s *Service) Dossier(ctx context.Context, repo contracts.RepoRef) (*contracts.DossierResult, error) {
648-
ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}
649-
if err := ref.Validate(); err != nil {
648+
ref, err := domain.NewRepoRef(repo.Owner, repo.Repo)
649+
if err != nil {
650650
return nil, err
651651
}
652652
if _, err := s.openReadOnlyCorpus(ctx); err != nil {

internal/app/app_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func TestContributionGuidanceDoesNotClaimUnfetchedSource(t *testing.T) {
285285
if _, err := svc.RepositoryContextSync(ctx, contracts.RepoRef{Owner: "octocat", Repo: "test"}, 0); err != nil {
286286
t.Fatal(err)
287287
}
288-
guidance, refs, err := (&corpusReader{s: svc}).ReadContributionGuidance(ctx, domain.RepoRef{Owner: "octocat", Repo: "test"})
288+
guidance, refs, err := (&corpusReader{s: svc}).ReadContributionGuidance(ctx, domain.MustRepoRef("octocat", "test"))
289289
if err != nil {
290290
t.Fatal(err)
291291
}
@@ -340,12 +340,12 @@ func TestMCPReaderLocalReads(t *testing.T) {
340340

341341
_, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "rocket"})
342342
var dossierErr *mcpcontract.ToolError
343-
if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Type != "get_repositories" {
343+
if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Type() != "get_repositories" {
344344
t.Fatalf("MCP dossier before build error = %+v", err)
345345
}
346346
_, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "missing"})
347347
var repositoryErr *mcpcontract.ToolError
348-
if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || repositoryErr.Recovery == nil || len(repositoryErr.Recovery.Then) != 1 || repositoryErr.Recovery.Then[0].Type != "sync_repository_context" {
348+
if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || repositoryErr.Recovery == nil || len(repositoryErr.Recovery.Then) != 1 || repositoryErr.Recovery.Then[0].Type() != "sync_repository_context" {
349349
t.Fatalf("MCP dossier for missing repository error = %+v", err)
350350
}
351351
if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil {
@@ -392,7 +392,7 @@ func TestSearchCodeUsesStoredSnapshotWithoutNetwork(t *testing.T) {
392392
if _, err := svc.Init(ctx); err != nil {
393393
t.Fatal(err)
394394
}
395-
_, _, err = svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "owner", Repo: "repo"}, codeindex.Snapshot{
395+
_, _, err = svc.corpus.StoreCodeSnapshot(ctx, domain.MustRepoRef("owner", "repo"), codeindex.Snapshot{
396396
RepoPath: "/repo", Commit: "abc", CreatedAt: time.Now(), TotalBytes: 20,
397397
Documents: []codeindex.Document{{Path: "parser.go", Content: "func searchableParser() {}", Bytes: 25, LanguageHint: "go"}},
398398
})

internal/app/clustering.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import (
1818
// ListClusters reads the current stored duplicate-candidate projection. It does
1919
// not compute or write cluster state.
2020
func (s *Service) ListClusters(ctx context.Context, repo contracts.RepoRef, limit int) (*contracts.ClusterListResult, error) {
21-
ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}
21+
ref, err := domain.NewRepoRef(repo.Owner, repo.Repo)
22+
if err != nil {
23+
return nil, err
24+
}
2225
if err := validateClusterList(ref, limit); err != nil {
2326
return nil, err
2427
}
@@ -36,8 +39,8 @@ func (s *Service) ListClusters(ctx context.Context, repo contracts.RepoRef, limi
3639
// RefreshClusters explicitly computes and persists the duplicate-candidate
3740
// projection for a repository.
3841
func (s *Service) RefreshClusters(ctx context.Context, repo contracts.RepoRef) (*contracts.ClusterRefreshResult, error) {
39-
ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}
40-
if err := ref.Validate(); err != nil {
42+
ref, err := domain.NewRepoRef(repo.Owner, repo.Repo)
43+
if err != nil {
4144
return nil, err
4245
}
4346
c, err := s.openCorpus(ctx)
@@ -97,8 +100,8 @@ func clusterRefreshToCLI(repo contracts.RepoRef, disposition string, identity cl
97100
}
98101

99102
func validateClusterList(ref domain.RepoRef, limit int) error {
100-
if err := ref.Validate(); err != nil {
101-
return err
103+
if !ref.IsValid() {
104+
return errors.New("repository reference is not parsed")
102105
}
103106
if limit < 1 || limit > 1000 {
104107
return errors.New("cluster limit must be between 1 and 1000")

0 commit comments

Comments
 (0)