<!-- task=m-provider-usage-attribution-hot-path/01_attribution_binding_foundation plan=0 tag=API -->

# Provider Usage Attribution Binding Foundation

## For the Implementing Agent

Filling implementation-owned sections in `CODE_REVIEW-cloud-G07.md` is mandatory. Run every verification command, paste actual notes/output, leave the active pair in place, and report ready for review. Finalization belongs only to the code-review skill. If blocked, record the exact blocker, attempted commands/output, and resume condition in implementation-owned evidence fields; do not ask the user, call user-input tools, create stop files, classify the next state, archive logs, or write `complete.log`.

## Background

OpenAI usage is currently keyed by the request model group, while direct dispatch results do not retain a verified provider identity. Before per-attempt metrics can be correct, config, route resolution, and both normalized/tunnel service paths need one stable actual-provider binding contract.

## Analysis

### Files Read

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/profiles/edge-smoke.md`
- `agent-test/local/profiles/platform-common-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-usage-attribution-hot-path.md`
- `agent-roadmap/sdd/operational-observability-provider-management/provider-usage-attribution-hot-path/SDD.md`
- `agent-contract/index.md`
- `agent-contract/inner/edge-config-runtime-refresh.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/index.md`
- `agent-spec/input/openai-compatible-surface.md`
- `agent-spec/runtime/provider-pool-config-refresh.md`
- `agent-spec/runtime/edge-node-execution.md`
- `packages/go/config/provider_types.go`
- `packages/go/config/edge_types.go`
- `packages/go/config/load.go`
- `packages/go/config/validate.go`
- `packages/go/config/edge_openai_config_test.go`
- `packages/go/config/provider_catalog_validation_config_test.go`
- `packages/go/config/stream_evidence_gate_config_test.go`
- `apps/edge/internal/openai/route_resolution.go`
- `apps/edge/internal/openai/dispatch_context.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/chat_completion.go`
- `apps/edge/internal/openai/provider_tunnel.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/provider_dispatch_test.go`
- `apps/edge/internal/openai/server_test_support_test.go`
- `apps/edge/internal/openai/provider_test_support_test.go`
- `apps/edge/internal/openai/stream_gate_dispatcher.go`
- `apps/edge/internal/service/run_types.go`
- `apps/edge/internal/service/run_submit.go`
- `apps/edge/internal/service/provider_tunnel.go`
- `apps/edge/internal/service/provider_pool.go`
- `configs/edge.yaml`
- `Makefile`
- `scripts/e2e-provider-capacity-smoke.sh`

### SDD Criteria

The approved, unlocked SDD is `agent-roadmap/sdd/operational-observability-provider-management/provider-usage-attribution-hot-path/SDD.md`. This prerequisite packet prepares S01 (`group-policy`) and S02 (`dispatch-binding`) but intentionally has no `Roadmap Targets`: it does not emit the final provider-attributed metric series and therefore cannot complete either task. Evidence Map rows S01 and S02 require config/route basis tests plus direct, pool-normalized, and pool-tunnel binding assertions; those requirements define API-1 through API-3 and the focused verification below.

### Verification Context

No handoff was supplied. Verification sources actually read were `agent-test/local/rules.md`, `agent-test/local/profiles/edge-smoke.md`, `agent-test/local/profiles/platform-common-smoke.md`, `Makefile`, and `scripts/e2e-provider-capacity-smoke.sh`. Preflight on branch `provider-usage-attribution-hot-path` at HEAD `88f518f0294d461d5e05b9a14229fed3d6126d12` found a clean checkout, Go `go1.26.2 linux/arm64`, module `/config/workspace/iop-s1/go.mod`, and `go`, `curl`, and `jq` available. The inherited `GOCACHE` points at an inaccessible sibling-workspace temporary path; using `/tmp/iop-s1-provider-usage-attribution-gocache` made fresh baseline tests pass for `./packages/go/config`, `./apps/edge/internal/service`, and `./apps/edge/internal/openai`. Existing valid OpenAI config fixtures and handler test doubles predate explicit provider binding, so their minimal migration is part of compatibility verification rather than a behavioral expansion. No external runner, credential, host, or port is required. Cached output is not acceptable.

### Test Coverage Gaps

- Existing config tests do not know `models[].usage_attribution` or direct `provider_id`.
- Existing OpenAI dispatch tests distinguish pool/direct paths but do not assert attribution policy or verified provider identity.
- Provider-pool dispatch already returns actual provider/model/node values, but direct normalized and direct tunnel results leave `ProviderID` empty.
- No test rejects a provider-attribution direct route whose route-level and top-level provider ids are both absent.
- Existing valid config fixtures in `edge_openai_config_test.go`, `provider_catalog_validation_config_test.go`, and `stream_evidence_gate_config_test.go` enable OpenAI without the new explicit fallback binding; a new validation rule would otherwise turn unrelated passing and diagnostic cases into missing-provider failures.
- Shared OpenAI test services in `server_test_support_test.go` and `provider_test_support_test.go` construct successful `RunDispatch` values without a provider id, so strict runtime binding would break broad handler coverage unless the fixtures receive stable test-only identities.

### Symbol References

- `validateOpenAIRoutes` is called by `packages/go/config/load.go:63` before principal, stream-gate, provider, and model validation; keep it as the early structural route validator and add a separate full-config attribution-binding validator after existing validation so legacy diagnostics retain precedence.
- `actualOpenAIProvider` is called by initial and recovery attempt binding construction in `apps/edge/internal/openai/stream_gate_runtime.go`, `apps/edge/internal/openai/responses_stream_gate.go`, and `apps/edge/internal/openai/stream_gate_dispatcher.go`.
- `SubmitRunRequest`, `SubmitProviderTunnelRequest`, and `RunDispatch` are constructed throughout Edge service/OpenAI tests; add fields without renaming existing symbols so keyed literals remain source-compatible.

### Split Judgment

- `01_attribution_binding_foundation`: stable contract is a validated attribution policy plus actual provider/served-model/node binding on every successful direct or provider-pool dispatch. PASS evidence is config rejection/default tests and direct/pool normalized/tunnel dispatch assertions.
- `02+01_attempt_usage_emission`: consumes these fields to separate attempt usage emission from the one-per-request terminal counter.

This packet has no predecessor. No active or archived sibling index exists for this task group.

### Scope Rationale

Do not change provider selection, capacity, queueing, retry budgets, response bytes, metric label vectors, Grafana queries, or request-ledger/billing behavior. `agent-contract/outer/openai-compatible-api.md` and usage emitter semantics stay for the dependent packet. A direct `provider_id` is an explicit stable attribution identity; this packet does not require it to reference `nodes[].providers[].id`, because legacy direct adapter routes can be valid without a provider-catalog candidate. It must never be inferred from adapter text. No protobuf/wire schema change is needed because provider, served-model, and node identity already exist on Edge-local dispatch results.

### Final Routing

- evaluation_mode: `first-pass`
- finalizer: `finalize-task-policy.sh pair`
- build: all closures true; scores `scope=2,state=1,blast=2,evidence=1,verification=1`; grade `G07`, route `local`, basis `local-fit`, filename `PLAN-local-G07.md`
- review: all closures true; scores `scope=2,state=1,blast=2,evidence=1,verification=1`; grade `G07`, route `cloud`, basis `official-review`, filename `CODE_REVIEW-cloud-G07.md`
- large_indivisible_context: `false`
- positive loop risks: `boundary_contract`, `variant_product` (count `2`)
- recovery signals: review rework `0`, evidence-integrity failure `false`
- capability-gap evidence: none

## Implementation Checklist

- [ ] Add the provider-default/model-group-approved attribution policy and validated direct provider identity config.
- [ ] Propagate attribution policy and actual provider/model/node binding through OpenAI route contexts and every service dispatch result.
- [ ] Add deterministic config and dispatch tests, then synchronize the config contract and matching implementation specs.
- [ ] Run fresh focused, race, formatting, contract-pointer, and diff verification.
- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.

### [API-1] Define and Validate Attribution Config

#### Problem

`packages/go/config/provider_types.go:155` has no attribution policy, while `packages/go/config/edge_types.go:105` and `packages/go/config/edge_types.go:118` have no route-level or fallback provider id. `packages/go/config/validate.go:12` validates route structure before the rest of `LoadEdge`, so a direct route cannot be proven attributable and adding the check there would mask established validation diagnostics.

#### Solution

Add canonical `provider` and `model_group` attribution constants, `ModelCatalogEntry.UsageAttribution`, and an effective accessor that defaults omission to `provider`. Accept only those two values. Add `ProviderID` to `OpenAIRouteEntry` and `EdgeOpenAIConf`; resolve a nonblank route-level value before the nonblank top-level fallback. Every enabled OpenAI direct fallback and each explicit model route that can dispatch directly must have that syntactic binding; a provider-catalog cross-reference is deliberately not required for legacy adapter-only routes. Keep `validateOpenAIRoutes` as the early structural check, validate model policy through the existing model-validation phase, and call a new `validateOpenAIAttributionBindings(cfg.OpenAI)` only after all pre-existing `LoadEdge` validation succeeds. It then rejects a provider-attribution route whose effective binding is absent while explicitly approved provider-pool groups retain `model_group`.

```go
// Before (packages/go/config/provider_types.go:155)
type ModelCatalogEntry struct {
    ID string `mapstructure:"id" yaml:"id"`

// After
const (
    UsageAttributionProvider   = "provider"
    UsageAttributionModelGroup = "model_group"
)

type ModelCatalogEntry struct {
    ID               string `mapstructure:"id" yaml:"id"`
    UsageAttribution string `mapstructure:"usage_attribution" yaml:"usage_attribution,omitempty"`
}
```

```go
// Existing structural validation remains early.
if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil {
    return nil, err
}

// New attribution validation runs after established config validation.
if err := validateOpenAIAttributionBindings(cfg.OpenAI); err != nil {
    return nil, err
}
```

#### Modified Files and Checklist

- [ ] `packages/go/config/provider_types.go` — add constants, field, default accessor, and enum validation.
- [ ] `packages/go/config/edge_types.go` — add route-level and top-level `provider_id`.
- [ ] `packages/go/config/load.go` — invoke attribution binding validation after all established config checks.
- [ ] `packages/go/config/validate.go` — add the separate full-config binding precedence and missing/blank provider-id validator.
- [ ] `configs/edge.yaml` — document provider-default policy, approved group opt-in, and legacy binding examples without private values.
- [ ] `packages/go/config/usage_attribution_config_test.go` — add focused YAML/default/invalid/direct/fallback cases.
- [ ] `packages/go/config/edge_openai_config_test.go` — migrate valid enabled/direct fixtures and preserve existing validation diagnostics.
- [ ] `packages/go/config/provider_catalog_validation_config_test.go` — give the provider-pool legacy-compatibility fixture an explicit fallback identity.
- [ ] `packages/go/config/stream_evidence_gate_config_test.go` — migrate enabled OpenAI fixtures without changing stream-gate assertions.

#### Test Strategy

Write `TestModelUsageAttributionValidationAndDefault` and `TestOpenAIDirectProviderBindingValidation` in `packages/go/config/usage_attribution_config_test.go`. Use temporary public YAML fixtures covering omitted/provider/model_group/invalid policy, route-level override, top-level fallback, blank values, and missing binding rejection. Minimally add stable public `provider_id` values to existing valid fixtures; keep intentionally invalid route/queue/stream-gate cases failing for their original reason so attribution validation does not mask earlier diagnostics.

#### Verification

```bash
mkdir -p /tmp/iop-s1-provider-usage-attribution-gocache
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./packages/go/config -run 'UsageAttribution|DirectProviderBinding|OpenAIRouteCatalog|ProviderPoolLegacyCompatibility|StreamEvidenceGate'
```

Expected: fresh tests pass and missing provider-attribution direct bindings fail only at config load.

### [API-2] Carry Actual Dispatch Binding End to End

#### Problem

`apps/edge/internal/openai/route_resolution.go:54` drops provider identity and attribution policy. `apps/edge/internal/service/run_types.go:57` describes `ProviderID` as pool-only, and `apps/edge/internal/service/run_submit.go:182` returns a direct `RunDispatch` without it. `apps/edge/internal/openai/stream_gate_dispatcher.go:276` substitutes adapter or node id when provider id is missing, contrary to SDD S02.

#### Solution

Add `ProviderID` and `UsageAttribution` to `routeDispatch`, both service request DTOs, and `RunDispatch`. Resolve catalog policy at provider-pool ingress; resolve route-level provider id before the top-level fallback for direct routes. Propagate the immutable values through Chat/Responses normalized and tunnel request builders. Provider-pool normalized/tunnel results keep the selected candidate's actual provider, served target, and node; direct results use the validated configured provider and resolved node. Make `actualOpenAIProvider` return only `RunDispatch.ProviderID`, so missing identity fails at the binding boundary instead of becoming an adapter alias. Update shared handler test services that bypass `LoadEdge` to echo the request binding or use an explicit fixed test-only provider/node identity. For broad legacy fixtures that directly construct `fakeRunResult`/`fakeTunnelHandle`, their test-only dispatch accessor may fill the fixed identity only when absent; the strict missing-binding regression must use a non-normalizing spy. Never derive any fixture identity from adapter text, and keep focused request/dispatch propagation assertions so the fixture default cannot conceal a missing production field.

```go
// Before (apps/edge/internal/service/run_types.go:45)
type RunDispatch struct {
    RunID      string
    NodeID     string
    ProviderID string // non-empty for provider-pool dispatches
}

// After
type RunDispatch struct {
    RunID            string
    NodeID            string
    ProviderID        string
    UsageAttribution string
}
```

```go
// Before (apps/edge/internal/openai/stream_gate_dispatcher.go:276)
func actualOpenAIProvider(dispatch edgeservice.RunDispatch) string {
    if provider := strings.TrimSpace(dispatch.ProviderID); provider != "" {
        return provider
    }
    if provider := strings.TrimSpace(dispatch.Adapter); provider != "" {
        return provider
    }
    return strings.TrimSpace(dispatch.NodeID)
}

// After
func actualOpenAIProvider(dispatch edgeservice.RunDispatch) string {
    return strings.TrimSpace(dispatch.ProviderID)
}
```

#### Modified Files and Checklist

- [ ] `apps/edge/internal/openai/route_resolution.go` — resolve provider id and effective policy.
- [ ] `apps/edge/internal/openai/chat_completion.go` — copy binding into normalized Chat requests.
- [ ] `apps/edge/internal/openai/chat_handler.go` — copy binding into provider-pool base requests.
- [ ] `apps/edge/internal/openai/provider_tunnel.go` — copy binding into direct Chat/Responses tunnel requests.
- [ ] `apps/edge/internal/openai/responses_handler.go` — copy binding into normalized and provider-pool Responses requests.
- [ ] `apps/edge/internal/openai/stream_gate_dispatcher.go` — remove adapter/node provider substitution.
- [ ] `apps/edge/internal/service/run_types.go` — extend request/dispatch DTOs.
- [ ] `apps/edge/internal/service/run_submit.go` — fill direct and queued run dispatch binding.
- [ ] `apps/edge/internal/service/provider_tunnel.go` — fill direct and queued tunnel dispatch binding.
- [ ] `apps/edge/internal/service/provider_pool.go` — preserve policy across one-shot normalized/tunnel selection.
- [ ] `apps/edge/internal/openai/server_test_support_test.go` — return explicit provider/node binding from normalized handler fakes.
- [ ] `apps/edge/internal/openai/provider_test_support_test.go` — return explicit provider/node binding from direct/tunnel services and normalize only shared legacy fake handles.

#### Test Strategy

Extend `apps/edge/internal/openai/provider_dispatch_test.go` with `TestUsageAttributionRouteDispatchBinding` for catalog, route override, and top-level fallback request DTOs. Add `apps/edge/internal/service/usage_attribution_dispatch_test.go` with `TestRunDispatchActualProviderBinding` and `TestTunnelDispatchActualProviderBinding`; assert direct, pool-normalized, and pool-tunnel `ProviderID`, `Target`, `NodeID`, and `UsageAttribution`. Include a non-normalizing regression spy proving that an empty provider id fails and adapter text is never accepted as provider identity. Run the existing handler suites against the migrated shared test services to prove strict binding does not regress unrelated direct, pool, and stream-gate behavior.

#### Verification

```bash
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./apps/edge/internal/service -run 'ActualProviderBinding'
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./apps/edge/internal/openai -run 'UsageAttributionRouteDispatchBinding|ProviderPoolDispatch|LegacyRoute'
```

Expected: all binding variants pass with actual provider/model/node values and no adapter fallback.

### [API-3] Synchronize Contract and Current Specs

#### Problem

`agent-contract/inner/edge-config-runtime-refresh.md:40` defines legacy model routes only as adapter/target mappings, and `agent-spec/input/openai-compatible-surface.md:81` describes dispatch without provider-attribution policy. Leaving them unchanged would make config refresh and current implementation specs disagree with code.

#### Solution

Document the new fields, default/allowed values, direct binding precedence, validation failure, and refresh classification. Record that these are Edge-local binding fields and do not change `ProviderTunnelRequest`/`RunRequest` protobuf payloads. Update only current matching specs; do not add Grafana queries or claim final attempt metric behavior.

#### Modified Files and Checklist

- [ ] `agent-contract/inner/edge-config-runtime-refresh.md` — define config schema/default/validation/refresh behavior.
- [ ] `agent-spec/input/openai-compatible-surface.md` — record route and dispatch binding behavior.
- [ ] `agent-spec/runtime/provider-pool-config-refresh.md` — record model policy default and live-apply semantics.
- [ ] `agent-spec/runtime/edge-node-execution.md` — clarify Edge-local dispatch identity preservation without a wire change.

#### Test Strategy

No separate code test is needed; API-1/API-2 tests are the executable contract. Verify exact schema terms and prohibit accidental Grafana/ledger scope.

#### Verification

```bash
rg --sort path -n 'usage_attribution|provider_id' configs/edge.yaml agent-contract/inner/edge-config-runtime-refresh.md agent-spec/input/openai-compatible-surface.md agent-spec/runtime/provider-pool-config-refresh.md agent-spec/runtime/edge-node-execution.md
```

Expected: output shows the same default, allowed values, precedence, and Edge-local binding meaning in every current source.

## Dependencies and Execution Order

1. Complete API-1 before runtime propagation so every request carries validated values.
2. Complete API-2 before API-3 so documentation matches the implemented field flow.
3. Run all final verification after tests and current specs are synchronized.

## Modified Files Summary

| File | Item |
|------|------|
| `packages/go/config/provider_types.go` | API-1 |
| `packages/go/config/edge_types.go` | API-1 |
| `packages/go/config/load.go` | API-1 |
| `packages/go/config/validate.go` | API-1 |
| `configs/edge.yaml` | API-1 |
| `packages/go/config/usage_attribution_config_test.go` | API-1 |
| `packages/go/config/edge_openai_config_test.go` | API-1 |
| `packages/go/config/provider_catalog_validation_config_test.go` | API-1 |
| `packages/go/config/stream_evidence_gate_config_test.go` | API-1 |
| `apps/edge/internal/openai/route_resolution.go` | API-2 |
| `apps/edge/internal/openai/chat_completion.go` | API-2 |
| `apps/edge/internal/openai/chat_handler.go` | API-2 |
| `apps/edge/internal/openai/provider_tunnel.go` | API-2 |
| `apps/edge/internal/openai/responses_handler.go` | API-2 |
| `apps/edge/internal/openai/stream_gate_dispatcher.go` | API-2 |
| `apps/edge/internal/service/run_types.go` | API-2 |
| `apps/edge/internal/service/run_submit.go` | API-2 |
| `apps/edge/internal/service/provider_tunnel.go` | API-2 |
| `apps/edge/internal/service/provider_pool.go` | API-2 |
| `apps/edge/internal/openai/provider_dispatch_test.go` | API-2 |
| `apps/edge/internal/openai/server_test_support_test.go` | API-2 |
| `apps/edge/internal/openai/provider_test_support_test.go` | API-2 |
| `apps/edge/internal/service/usage_attribution_dispatch_test.go` | API-2 |
| `agent-contract/inner/edge-config-runtime-refresh.md` | API-3 |
| `agent-spec/input/openai-compatible-surface.md` | API-3 |
| `agent-spec/runtime/provider-pool-config-refresh.md` | API-3 |
| `agent-spec/runtime/edge-node-execution.md` | API-3 |
| `agent-task/m-provider-usage-attribution-hot-path/01_attribution_binding_foundation/CODE_REVIEW-cloud-G07.md` | API-1, API-2, API-3 |

## Final Verification

```bash
mkdir -p /tmp/iop-s1-provider-usage-attribution-gocache
test -z "$(gofmt -l packages/go/config/provider_types.go packages/go/config/edge_types.go packages/go/config/load.go packages/go/config/validate.go packages/go/config/usage_attribution_config_test.go packages/go/config/edge_openai_config_test.go packages/go/config/provider_catalog_validation_config_test.go packages/go/config/stream_evidence_gate_config_test.go apps/edge/internal/openai/route_resolution.go apps/edge/internal/openai/chat_completion.go apps/edge/internal/openai/chat_handler.go apps/edge/internal/openai/provider_tunnel.go apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/stream_gate_dispatcher.go apps/edge/internal/openai/provider_dispatch_test.go apps/edge/internal/openai/server_test_support_test.go apps/edge/internal/openai/provider_test_support_test.go apps/edge/internal/service/run_types.go apps/edge/internal/service/run_submit.go apps/edge/internal/service/provider_tunnel.go apps/edge/internal/service/provider_pool.go apps/edge/internal/service/usage_attribution_dispatch_test.go)"
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -race -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai
rg --sort path -n 'usage_attribution|provider_id' configs/edge.yaml agent-contract/inner/edge-config-runtime-refresh.md agent-spec/input/openai-compatible-surface.md agent-spec/runtime/provider-pool-config-refresh.md agent-spec/runtime/edge-node-execution.md
git diff --check
```

Expected: formatting is clean; fresh package and race tests pass; schema/spec pointers agree; diff check passes. Cached test output is not acceptable. After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`.
