Two agents, two tracks. The architect scopes both tasks using the per-resource
research docs in docs/design/related-resources/{shortname}.md. Coder and QA
can run in parallel (pattern is rigid).
Prerequisites
Infrastructure must be in place first. These must exist before any
per-resource related views can be added:
internal/resource/related.go -- types, registries (RelatedDef, NavigableField), helper constructors
RelatedCheckResultMsg and RelatedNavigateMsg in internal/tui/messages/messages.go
ToggleRelated binding in internal/tui/keys/keys.go
- Two-column detail view in
internal/tui/views/detail.go (field-list model with embedded rightColumnModel)
- Handler code in
internal/tui/app.go (main Update switch) and internal/tui/app_related.go for RelatedCheckResultMsg and RelatedNavigateMsg
If these don't exist, STOP. The infrastructure must land first.
See docs/design/related-views-architecture.md Phases 1-8.
Architect Must Provide
You MUST have a scoped task from the architect with:
- Source type ShortName (e.g., "ec2")
- Left column (navigable fields):
- For each navigable field: FieldPath, TargetType
- Right column (related types):
- For each related type:
- Target ShortName (e.g., "sg", "vpc")
- DisplayName override (if any)
- For cache-based checks: which cache key to look up, how to match
- For field-based checks: which Fields key to read
- Exact files to create/modify with append points
If you don't have this, STOP. Reply with REJECTED and ask for architect scope.
Agent Ownership
| Steps | Owner | Writes to |
|---|
| 1-7 (implementation) | a9s-coder | internal/, cmd/ |
| 8-12 (tests) | a9s-qa | tests/unit/ |
Coder MUST NOT write test files. QA MUST NOT write production code.
Relationship Patterns
Pattern F: Forward / Field-Based (cheap, count shown)
IDs are already in the source resource's Fields or RawStruct. No external API
call needed. The checker reads from Fields or RawStruct directly.
go
1// Example: EC2 -> EBS volumes (read from RawStruct block device mappings)
2func checkEC2EBS(_ context.Context, _ interface{}, res resource.Resource, _ resource.ResourceCache) resource.RelatedCheckResult {
3 ids := ec2VolumeIDs(res) // reads from res.RawStruct
4 if len(ids) == 0 {
5 return resource.RelatedCheckResult{TargetType: "ebs", Count: 0}
6 }
7 ordered := make([]string, 0, len(ids))
8 for id := range ids {
9 ordered = append(ordered, id)
10 }
11 sort.Strings(ordered)
12 return relatedResult("ebs", ordered)
13}
Pattern C: Cache-Based (reads from already-loaded resource lists)
Looks up related resources in the ResourceCache. Falls back to a live API
call only when the cache doesn't contain the target type. All cache-based
checkers follow this helper pattern:
go
1func check{Source}{Target}(ctx context.Context, clients interface{}, res resource.Resource, cache resource.ResourceCache) resource.RelatedCheckResult {
2 // 1. Extract identity from res (ID, relevant fields, or RawStruct)
3 sourceID, _ := extractSourceIdentity(res)
4 if sourceID == "" {
5 return resource.RelatedCheckResult{TargetType: "{target}", Count: 0}
6 }
7
8 // 2. Load target list from cache or live fetch
9 targetList, truncated, err := {source}RelatedResources(ctx, clients, cache, "{target}")
10 if err != nil {
11 return resource.RelatedCheckResult{TargetType: "{target}", Count: -1, Err: err}
12 }
13 if targetList == nil {
14 // Target fetcher not registered / nothing loaded yet — honest lower
15 // bound is zero, NOT an error. See "Count: -1 vs ApproximateZero" below.
16 return resource.ApproximateZero("{target}")
17 }
18
19 // 3. Match against source
20 var ids []string
21 for _, targetRes := range targetList {
22 // match by RawStruct fields first, fall back to resource.Fields
23 if matchesSource(targetRes, sourceID) {
24 ids = append(ids, targetRes.ID)
25 }
26 }
27 // Truncation guard: partial page with 0 matches → honest lower bound, not an error.
28 if len(ids) == 0 && truncated {
29 return resource.ApproximateZero("{target}")
30 }
31 return relatedResult("{target}", ids)
32}
The relatedResult helper (defined in ec2_related.go, copy-paste for other
source types) deduplicates and sorts IDs:
go
1func relatedResult(target string, ids []string) resource.RelatedCheckResult {
2 if len(ids) == 0 {
3 return resource.RelatedCheckResult{TargetType: target, Count: 0}
4 }
5 // deduplicate and sort ids ...
6 return resource.RelatedCheckResult{
7 TargetType: target,
8 Count: len(uniq),
9 ResourceIDs: uniq,
10 }
11}
Count semantics:
Count: 0 -- confirmed none (exhaustive scan produced no matches)
Count: N (N > 0) -- confirmed N found, ResourceIDs populated
Count: -1 -- error state. Something went wrong: AWS API returned an
error, required client is nil, assertStruct failed, or we lack the identity
needed to run the query. The UI renders this as "?" and it contributes to
the per-resource truncation bucket.
resource.ApproximateZero("{target}") -- honest lower bound of zero. Not
an error. Returned when the target list comes back nil (fetcher not
registered, nothing loaded yet) or when a truncated target page produced zero
matches so far. The UI renders this as "0+".
Return-value classifier for a checker -- load-bearing
A related checker must choose one of four return values per code path. Getting this wrong misleads the UI: Count: -1 renders as "?" and feeds the truncation bucket; ApproximateZero renders as "0+"; Count: 0 renders as plain "0". The operator reads those three differently. Classify every branch:
-
Error path -- AWS call returned an error, required client is nil, an assertStruct failed, the source resource lacks the identity needed to form the query, or an intermediate helper returned a sentinel meaning "unknown, could not determine". Return resource.RelatedCheckResult{TargetType: "{target}", Count: -1, Err: err} (attach Err when a real error value is in hand). If an intermediate helper's documented contract is "nil return = error distinct from empty", propagate its nil as Count: -1; do NOT collapse it into ApproximateZero.
-
Complete-answer API -- the checker makes a single bounded AWS call (not cache-backed, not paginated) that returns an exhaustive answer. out == nil && err == nil (or an empty output slice with no error) means the AWS service reported zero authoritatively. Return Count: 0. This is NOT ApproximateZero -- no lower bound is involved because no pagination cursor remains.
-
Nil target list from the cache-backed helper -- the checker uses the standard {source}RelatedResources(ctx, clients, cache, "{target}") (or equivalent FetchRelatedTarget wrapper) and the returned list is nil because the target fetcher is not registered or nothing has been loaded yet. Return resource.ApproximateZero("{target}"). Zero registered targets is an honest lower bound of zero, not an error.
-
Truncated target page with zero matches so far -- the cache-backed helper returned a partial page (truncated == true) and this checker's match loop produced no IDs. Return resource.ApproximateZero("{target}"). Later pages may produce matches; the current view is a lower bound.
Decision order when reviewing a new or rewritten checker: for each return statement, ask (in order) -- Is an AWS call or helper erroring? Is this a one-shot exhaustive API with out == nil && err == nil? Is this the if targetList == nil branch from a cache-backed helper? Is this the truncated-and-no-matches branch? The first "yes" determines the return. Returning Count: -1 on a nil-list-from-cache or truncated-empty branch is the regression this section exists to prevent.
Pattern N: Naming-Convention (reverse lookup by name pattern)
The target resource's ID or name follows a predictable naming convention
that embeds the source resource's name. Search the target cache for matches.
go
1// Example: SFN → logs (log group name is /aws/vendedlogs/states/{sfn-name})
2func checkSFNLogs(ctx context.Context, clients any, res resource.Resource, cache resource.ResourceCache) resource.RelatedCheckResult {
3 sfnName := res.ID
4 if sfnName == "" {
5 return resource.RelatedCheckResult{TargetType: "logs", Count: 0}
6 }
7 expectedPrefix := "/aws/vendedlogs/states/" + sfnName
8
9 logsList, truncated, err := sfnRelatedResources(ctx, clients, cache, "logs")
10 // ... standard cache lookup, match by strings.HasPrefix(logRes.ID, expectedPrefix)
11}
Known naming conventions:
- Lambda → logs:
/aws/lambda/{function-name}
- CodeBuild → logs:
/aws/codebuild/{project-name}
- SFN → logs:
/aws/vendedlogs/states/{state-machine-name}
- EKS → logs:
/aws/eks/{cluster-name}/...
Pattern D: Dimension-Based (reverse lookup by alarm dimensions)
Search the alarm cache for alarms whose Dimensions[] contain a matching
dimension name/value. The source resource's ARN or name is the dimension value.
go
1// Example: SFN → alarm (alarm dimension StateMachineArn matches SFN ARN)
2func checkSFNAlarm(ctx context.Context, clients any, res resource.Resource, cache resource.ResourceCache) resource.RelatedCheckResult {
3 sfnARN := res.Fields["arn"]
4 if sfnARN == "" {
5 return resource.RelatedCheckResult{TargetType: "alarm", Count: -1}
6 }
7 alarmList, truncated, err := sfnRelatedResources(ctx, clients, cache, "alarm")
8 // ... iterate alarms, assert MetricAlarm, check Dimensions for StateMachineArn == sfnARN
9}
When Checker: nil Is Acceptable
A nil checker (unknown count, shows "?" in UI) is ONLY acceptable when ALL
of the following are true:
- No forward fields: The source RawStruct has no fields referencing the target
- No reverse fields: No cached resource type has fields referencing this source
- No naming convention: The target doesn't follow a name pattern embedding the source name
- No dimension match: No alarm dimensions reference this source's ARN or name
- The relationship requires a separate API call not available from any cached data
Before marking a checker as nil, the architect MUST verify all five conditions.
Checking the research doc (docs/design/related-resources/{shortname}.md) is
mandatory — it lists viable lookup strategies for each relationship.
Common mistakes:
- Marking alarm as nil when alarms have
Dimensions[] referencing the source ARN
- Marking logs as nil when a
/aws/{service}/{name} log group convention exists
- Marking a relationship as nil when OTHER cached resources have fields pointing back
(e.g., SNS→alarm: alarm cache has
AlarmActions[] containing topic ARNs)
CODER STEPS (1-7) -- a9s-coder agent only
IMPORTANT: Module path is github.com/k2m30/a9s/v3/... (the /v3 suffix is required).
Both RegisterRelated and RegisterNavigableFields calls belong in the
same init() as the resource type registration. For large resources like EC2
the related checker functions live in a separate {source}_related.go file
for readability, but the RegisterRelated calls stay in the main init().
go
1func init() {
2 // ... existing RegisterType / RegisterFetcher calls ...
3
4 // --- Right column: related resource definitions ---
5 resource.RegisterRelated("{source}", []resource.RelatedDef{
6 // NeedsTargetCache: true for Pattern C (cache-based) checkers — triggers pre-fetch before checker runs.
7 // Omit (false) for Pattern A/B checkers that call AWS directly.
8 {TargetType: "{target1}", DisplayName: "{Display Name 1}", Checker: check{Source}{Target1}, NeedsTargetCache: true},
9 {TargetType: "{target2}", DisplayName: "{Display Name 2}", Checker: check{Source}{Target2}, NeedsTargetCache: true},
10 // Checker may be nil for stubs (shows as unknown count):
11 {TargetType: "{target3}", DisplayName: "{Display Name 3}", Checker: nil},
12 })
13
14 // --- Left column: navigable fields ---
15 resource.RegisterNavigableFields("{source}", []resource.NavigableField{
16 {FieldPath: "{FieldName}", TargetType: "{target}"},
17 {FieldPath: "{Section.FieldName}", TargetType: "{target}"},
18 // ... one entry per navigable field in the detail view
19 })
20}
RelatedDef fields:
TargetType string -- target resource short name (e.g., "tg", "alarm")
DisplayName string -- right-column row label (e.g., "Target Groups")
Checker RelatedChecker -- async checker function (nil for stubs)
NeedsTargetCache bool -- set true for Pattern C (cache-based) checkers; triggers coordinated pre-fetch before the checker runs. Omitting this on a cache-based checker causes cold-cache misses to return Count: -1 instead of the real count.
NavigableField fields:
FieldPath string -- matches a label rendered in the detail view (e.g., "VpcId")
TargetType string -- resource short name to navigate to
The RelatedChecker type signature is:
go
1type RelatedChecker func(ctx context.Context, clients interface{}, res resource.Resource, cache resource.ResourceCache) resource.RelatedCheckResult
Note: no error return -- errors are embedded in RelatedCheckResult.Err.
go
1package aws
2
3import (
4 "context"
5 "sort"
6
7 // ... SDK type imports as needed ...
8
9 "github.com/k2m30/a9s/v3/internal/resource"
10)
11
12// check{Source}{Target1} checks the cache for {target1} resources related to this {source}.
13func check{Source}{Target1}(ctx context.Context, clients interface{}, res resource.Resource, cache resource.ResourceCache) resource.RelatedCheckResult {
14 sourceID := res.ID
15 if sourceID == "" {
16 return resource.RelatedCheckResult{TargetType: "{target1}", Count: 0}
17 }
18
19 targetList, truncated, err := {source}RelatedResources(ctx, clients, cache, "{target1}")
20 if err != nil {
21 return resource.RelatedCheckResult{TargetType: "{target1}", Count: -1, Err: err}
22 }
23 if targetList == nil {
24 // Honest zero — fetcher not registered or nothing loaded yet.
25 return resource.ApproximateZero("{target1}")
26 }
27
28 var ids []string
29 for _, r := range targetList {
30 // prefer RawStruct for accuracy, fall back to Fields
31 if r.Fields["{source_id_field}"] == sourceID {
32 ids = append(ids, r.ID)
33 }
34 }
35 // Truncation guard: partial page with 0 matches → honest lower bound.
36 if len(ids) == 0 && truncated {
37 return resource.ApproximateZero("{target1}")
38 }
39 return relatedResult("{target1}", ids)
40}
41
42// check{Source}{Target2} checks the cache for {target2} resources related to this {source}.
43func check{Source}{Target2}(ctx context.Context, clients interface{}, res resource.Resource, cache resource.ResourceCache) resource.RelatedCheckResult {
44 // ... similar pattern ...
45}
46
47// {source}RelatedResources returns the resource list for target from cache or
48// fetches the first page via the registered paginated fetcher.
49// Returns (resources, isTruncated, error).
50// isTruncated=true means the list is partial; callers MUST return Count=-1
51// when 0 matches are found in a truncated list.
52func {source}RelatedResources(ctx context.Context, clients interface{}, cache resource.ResourceCache, target string) ([]resource.Resource, bool, error) {
53 resources, isTruncated, err := FetchRelatedTarget(ctx, clients, cache, target)
54 // When AWS clients are not initialized (nil or wrong type), registered fetchers
55 // return "AWS clients not initialized". Treat as graceful no-op (Count=-1, no error).
56 if err != nil {
57 if _, ok := clients.(*ServiceClients); !ok {
58 return nil, false, nil
59 }
60 }
61 return resources, isTruncated, err
62}
63
64// Do NOT define a {source}RelatedResult function. Call the shared package-level
65// relatedResult(target, ids) from ec2_related.go — it lives in the same package
66// and handles deduplication and sorting for all resource types.
RelatedCheckResult fields:
TargetType string -- echoed from RelatedDef.TargetType
Count int -- -1 = unknown; 0 = confirmed none; N > 0 = confirmed N
ResourceIDs []string -- IDs of found related resources (empty when Count <= 0)
Err error -- non-nil on error
There is no Available bool field.
3. Interfaces: internal/aws/<service>_interfaces.go (APPEND if needed)
Only needed if the checker's live-fetch fallback calls an API not already
covered by existing interfaces. Cache-only checkers that never call live APIs
do not need new interfaces.
go
1// Only add if not already present:
2type {TypeName}{APICall}API interface {
3 {APICall}(ctx context.Context, params *{service}.{APICall}Input, optFns ...func(*{service}.Options)) (*{service}.{APICall}Output, error)
4}
4. Demo overrides
Register a demo checker so the related panel shows realistic data in demo mode.
Hybrid fixture pattern (014-demo-transport-mock). Demo mode has two layers: the legacy HTTP transport (internal/demo/transport.go + handlers.go) is the base for all services, and per-service typed fakes (internal/demo/fakes/<service>.go) override individual services. Currently only EC2 uses a typed fake.
- Preferred (migrated services): add fixture data to
internal/demo/fixtures/<service>.go and extend the matching fake in internal/demo/fakes/<service>.go.
- Legacy (non-migrated services): add fixture data to the matching
internal/demo/fixtures_*.go category file and (if needed) extend handlers in internal/demo/handlers.go.
When adding a new resource type, match the service's current layer. Do not mix layers for the same service.
No separate demo registry is needed. Related checkers run against the typed fakes automatically. Ensure the target resource type's fixtures contain IDs that match what the source resource's fields reference. For example, if EC2 instances reference vpc-prod-main in their VpcId field, the VPC fake's fixtures must include a VPC with that ID.
Add fixture data to internal/demo/fixtures/<service>.go for the target resource type. The related checker will find it via the standard prefetch + cache path.
Never amend tests if fixtures do not have related IDs/fields. Fix fixtures, not tests.
Use the resource short name in PascalCase as {SourceCamel} and {TargetCamel}:
ec2 → EC2, rds → RDS, s3 → S3, tg → TG, ebs → EBS, etc.
Add a numeric suffix only when a source has multiple related IDs of the same target type.
5. Verify parent resource Fields
Critical for left-column navigable fields. Verify that the source
resource's regular fetcher populates the Fields keys that the
NavigableField entries reference.
For example, if a NavigableField has FieldPath: "VpcId", verify
that internal/aws/{source}.go populates a field with key "VpcId" or that
the field appears in the detail view from RawStruct reflection.
If a required field is missing from Fields, add it to the regular fetcher.
6. Verify navigable field paths match detail view output
The FieldPath in NavigableField must match the actual key labels
rendered in the detail view. A mismatch silently leaves the field non-highlighted
across the entire resource type.
Automated check (required): Run the test added in QA step 9
(TestNavigableFields_{Source}_FieldPathsResolve) which verifies each registered
FieldPath resolves to a non-empty value against the resource's demo fixture:
bash
1go test ./tests/unit/ -run "TestNavigableFields_{Source}_FieldPathsResolve" -v -count=1
Manual fallback: If no demo fixture exists yet, check .a9s/views_reference.yaml
for the source resource — all field paths that appear in the detail view are
listed there. Verify each FieldPath matches a key in that file (case-sensitive).
For nested fields like SecurityGroups.GroupId, the detail view renders
these as indented sub-fields. The FieldPath must match the leaf label
that appears in the rendered output.
7. Post-implementation verification
bash
1make test
2make lint
3make gofix
4make build
QA STEPS (8-12) -- a9s-qa agent only
8. Mocks: tests/unit/mocks_test.go (APPEND if needed)
Only needed if the checker's live-fetch fallback introduces NEW interfaces
not already present. Cache-only checkers that never call live APIs do not
need new mocks.
go
1// mock{TypeName}By{Filter}Client implements awsclient.{InterfaceName} for testing.
2type mock{TypeName}By{Filter}Client struct {
3 output *{service}.{APICall}Output
4 err error
5}
6
7func (m *mock{TypeName}By{Filter}Client) {APICall}(
8 ctx context.Context,
9 params *{service}.{APICall}Input,
10 optFns ...func(*{service}.Options),
11) (*{service}.{APICall}Output, error) {
12 return m.output, m.err
13}
Write tests covering each checker and navigable field registration.
Checkers receive a resource.ResourceCache -- populate it with test data
to simulate the cache-hit path. Test the cache-miss path by passing an
empty or nil cache.
go
1package unit_test
2
3import (
4 "context"
5 "testing"
6
7 // ... SDK type imports as needed ...
8
9 "github.com/k2m30/a9s/v3/internal/demo"
10 "github.com/k2m30/a9s/v3/internal/fieldpath"
11 "github.com/k2m30/a9s/v3/internal/resource"
12)
13
14func {source}CheckerByTarget(t *testing.T, target string) resource.RelatedChecker {
15 t.Helper()
16 for _, def := range resource.GetRelated("{source}") {
17 if def.TargetType == target {
18 if def.Checker == nil {
19 t.Fatalf("{source} related checker for %s is nil", target)
20 }
21 return def.Checker
22 }
23 }
24 t.Fatalf("{source} related checker for %s not found", target)
25 return nil
26}
27
28// --- Navigable Field Registration Tests ---
29
30func TestNavigableFields_{Source}_Registered(t *testing.T) {
31 fields := resource.GetNavigableFields("{source}")
32 if len(fields) == 0 {
33 t.Fatal("no navigable fields registered for {source}")
34 }
35
36 expected := map[string]string{
37 "{FieldPath1}": "{target1}",
38 "{FieldPath2}": "{target2}",
39 }
40 for path, targetType := range expected {
41 nav := resource.IsFieldNavigable("{source}", path)
42 if nav == nil {
43 t.Errorf("expected navigable field %q not found", path)
44 continue
45 }
46 if nav.TargetType != targetType {
47 t.Errorf("field %q: TargetType = %q, want %q", path, nav.TargetType, targetType)
48 }
49 }
50}
51
52// TestNavigableFields_{Source}_FieldPathsResolve verifies that each registered
53// NavigableField.FieldPath resolves to a non-empty value against the demo fixture.
54// A mismatch here means the field will silently never be highlighted in the detail view.
55func TestNavigableFields_{Source}_FieldPathsResolve(t *testing.T) {
56 // Get demo resource for this source type (must be populated by the demo fixture)
57 resources, ok := demo.GetResources("{source}")
58 if !ok {
59 t.Skip("no demo fixture registered for {source}")
60 }
61 if len(resources) == 0 {
62 t.Skip("demo fixture returned no resources for {source}")
63 }
64 r := resources[0]
65
66 fields := resource.GetNavigableFields("{source}")
67 if len(fields) == 0 {
68 t.Fatal("no navigable fields registered for {source}")
69 }
70
71 for _, nav := range fields {
72 items := fieldpath.ExtractFieldList(r.RawStruct, []string{nav.FieldPath}, r.Fields, nil)
73 found := false
74 for _, item := range items {
75 if item.Value != "" && item.Value != "-" {
76 found = true
77 break
78 }
79 }
80 if !found {
81 t.Errorf("NavigableField.FieldPath %q resolved to empty/missing value in demo fixture — check FieldPath spelling or add field to fetcher", nav.FieldPath)
82 }
83 }
84}
85
86// --- Checker Tests ---
87
88func TestRelated_{Source}_{Target}_Found(t *testing.T) {
89 // Build a fake target resource that should match the source
90 fakeTarget := resource.Resource{
91 ID: "target-id-1",
92 Fields: map[string]string{
93 "{source_id_field}": "source-id-1",
94 },
95 }
96 cache := resource.ResourceCache{
97 "{target}": resource.ResourceCacheEntry{Resources: []resource.Resource{fakeTarget}},
98 }
99 source := resource.Resource{ID: "source-id-1"}
100
101 checker := {source}CheckerByTarget(t, "{target}")
102 result := checker(context.Background(), nil, source, cache)
103
104 if result.Count != 1 {
105 t.Errorf("Count = %d, want 1", result.Count)
106 }
107 if len(result.ResourceIDs) != 1 || result.ResourceIDs[0] != "target-id-1" {
108 t.Errorf("ResourceIDs = %v, want [target-id-1]", result.ResourceIDs)
109 }
110 if result.Err != nil {
111 t.Errorf("unexpected error: %v", result.Err)
112 }
113}
114
115func TestRelated_{Source}_{Target}_NotFound(t *testing.T) {
116 fakeTarget := resource.Resource{
117 ID: "target-id-2",
118 Fields: map[string]string{
119 "{source_id_field}": "other-source-id",
120 },
121 }
122 cache := resource.ResourceCache{
123 "{target}": resource.ResourceCacheEntry{Resources: []resource.Resource{fakeTarget}},
124 }
125 source := resource.Resource{ID: "source-id-1"}
126
127 checker := {source}CheckerByTarget(t, "{target}")
128 result := checker(context.Background(), nil, source, cache)
129
130 if result.Count != 0 {
131 t.Errorf("Count = %d, want 0", result.Count)
132 }
133 if len(result.ResourceIDs) != 0 {
134 t.Errorf("ResourceIDs = %v, want []", result.ResourceIDs)
135 }
136}
137
138func TestRelated_{Source}_{Target}_CacheMissNoClients(t *testing.T) {
139 // Empty cache + nil clients -> unknown (-1), no error
140 source := resource.Resource{ID: "source-id-1"}
141 checker := {source}CheckerByTarget(t, "{target}")
142 result := checker(context.Background(), nil, source, resource.ResourceCache{})
143
144 if result.Count != -1 {
145 t.Errorf("Count = %d, want -1 (unknown)", result.Count)
146 }
147}
148
149func TestRelated_{Source}_{Target}_EmptySourceID(t *testing.T) {
150 source := resource.Resource{ID: ""}
151 checker := {source}CheckerByTarget(t, "{target}")
152 result := checker(context.Background(), nil, source, resource.ResourceCache{})
153
154 if result.Count != 0 {
155 t.Errorf("Count = %d, want 0 for empty source ID", result.Count)
156 }
157}
10. Cold-cache checker tests (MANDATORY for every registered source type)
Add to the same tests/unit/aws_{source}_related_test.go file. Tests drive the real checker through the typed fakes via the cold-cache harness — no demo registry needed:
go
1func TestRelated_{Source}_ColdCacheChecker(t *testing.T) {
2 m := newDemoColdCacheApp(t)
3 // Size, inject clients, navigate to source, fetch, open detail
4 // Assert related check produces non-empty results via the real checker path
5}
Verify the registration was successful:
go
1func TestRelated_{Source}_Registered(t *testing.T) {
2 defs := resource.GetRelated("{source}")
3 if len(defs) == 0 {
4 t.Fatal("no related defs registered for {source}")
5 }
6
7 // Verify expected target types are present
8 expected := []string{"{target1}", "{target2}", "{target3}"}
9 for _, exp := range expected {
10 found := false
11 for _, def := range defs {
12 if def.TargetType == exp {
13 found = true
14 break
15 }
16 }
17 if !found {
18 t.Errorf("expected related def for target %q not found", exp)
19 }
20 }
21
22 // Verify non-stub checkers exist
23 for _, def := range defs {
24 if def.Checker == nil {
25 continue // stub entry, intentional
26 }
27 // Verify checker is callable (non-nil is sufficient for registration check)
28 }
29}
12. Post-test verification
bash
1go test ./tests/unit/ -count=1 -timeout 120s -run "Related_{Source}"
2go test ./tests/unit/ -count=1 -timeout 120s -run "NavigableFields_{Source}"
3make test
4make lint
5make gofix
What You Do NOT Need to Change (per resource)
detail.go -- the two-column detail view renders from registries generically
app.go -- generic handlers dispatch to registry
messages.go -- generic message types carry strings
keys.go -- r toggle and Tab switching are generic
app_related.go -- handleRelatedCheckStarted() and handleRelatedNavigate() are generic
Research Reference
Each resource's related relationships are documented in:
docs/design/related-resources/{shortname}.md
These docs contain:
- Real-world use cases (why engineers need this relationship)
- Which other resource types reference or are referenced by this resource
- Which Fields/RawStruct paths to use for matching
Forward relationships and navigable field paths come from the resource's
own API response fields, documented in .a9s/views_reference.yaml.
When the architect scopes related views for resource X, the handoff uses:
## RELATED VIEWS: {Source Display Name} ({source_shortname})
### Left Column -- Navigable Fields:
| Field Path | Target Type | Notes |
|------------|-------------|-------|
| {FieldPath} | {target} | {optional notes} |
| {Section.Field} | {target} | {optional notes} |
### Right Column -- Related Definitions:
| Target | DisplayName | Match Strategy | Cache Key | Notes |
|--------|------------|----------------|-----------|-------|
| {target} | {Display Name} | field: {field_key} == sourceID | {cache_key} | {notes} |
| {target} | {Display Name} | rawstruct: {field_path} | {cache_key} | {notes} |
| {target} | {Display Name} | nil (stub) | n/a | |
### CODER TASK:
Files to create:
internal/aws/{source}_related.go -- checker functions + cache helper (reuse shared relatedResult and assertStruct from ec2_related.go — do NOT redefine)
Files to modify:
internal/aws/{source}.go -- append RegisterRelated + RegisterNavigableFields in init()
internal/demo/fixtures/<service>.go -- ensure target resource fixtures contain matching IDs
internal/aws/{service}_interfaces.go -- append new interfaces (only if live-fetch fallback needs them)
Append point: after last narrow interface, before the aggregate {Service}API
Context files (read-only):
internal/aws/{source}.go -- verify Fields keys exist
internal/aws/ec2_related.go -- canonical checker pattern
internal/resource/related.go -- type definitions
docs/design/related-resources/{source}.md -- relationship details
.a9s/views_reference.yaml -- verify field paths
### QA TASK:
Test files to create:
tests/unit/aws_{source}_related_test.go -- checker + navigable field + demo tests
Test files to modify:
tests/unit/mocks_test.go -- append mocks (only if live-fetch fallback needs new interfaces)
Append point: last mock in file
tests/unit/related_registry_test.go -- append registration tests
Append point: last TestRelated_ function
What to test:
- Navigable field registration: all expected fields registered with correct target types
- Checkers: found / not found / cache-miss-no-clients / empty-source-ID
- Cold-cache checker: drives real checker through typed fakes via newDemoColdCacheApp
- Registry: all expected defs registered
Context files (read-only):
internal/aws/{source}_related.go -- function signatures
internal/resource/related.go -- type definitions