[object Object]

← back to Cli Printing Press

fix(cli): correct named-array envelope detection and api_key set-token slot (#706)

c46cf0157409949d46b958836aadafa8fa281913 · 2026-05-08 00:22:54 -0700 · Trevin Chow

* fix(cli): correct named-array envelope detection and api_key set-token slot

Fixes three machine bugs surfaced by issue #689 (kalshi-pp-cli sync regression):

Bug 1 (parser): unwrapItemSchema only handled {data: [...]} envelopes;
named-array envelopes like {events: [...], cursor: "..."} fell through to
the wrapper, and resolveIDFieldFromResponseSchema then picked the wrapper's
required `cursor` string as the resource PK. New singleArrayProperty helper
descends into the sole array property when present, so item-level required
scalars (event_ticker, ticker, etc.) win for Kalshi-shaped APIs.

Bug 5.2 (auth): set-token for Auth.Type=="api_key" was writing to AccessToken,
but AuthHeader() doesn't read AccessToken for api_key auth. Saved tokens
were silently invisible. New SaveCredential method writes to the env-var-
derived field that AuthHeader actually reads. Clears precede the assignment
so a canonical env-var whose placeholder collides with a builtin tag (e.g.
XXX_ACCESS_TOKEN resolving to AccessToken) ends up holding the new token.

Bug 5.3 (doctor): auth_source field was blank when credentials came from
disk. Load() now sets AuthSource = "config" after env-var overrides if any
credential slot is populated. Label is the literal "config" rather than
"config:<path>" — config_path is exposed separately and embedding the path
in auth_source leaks the user's home directory through doctor's JSON.

Includes scoping plans for two deferred meta-fixes: SQL ↔ schema validator
in dogfood (Bug 4) and multi-array envelope x-list-fields (Bug 3). Bug 2
(multi-endpoint per resource) is tracked separately at #701.

Known limitation: api_key auth specs with 2+ required per_call EnvVarSpecs
(HMAC, RSA-PSS) get only the canonical field written. set-token reports
success but AuthHeader requires all fields populated. Surfaced by code
review; fix shape requires a spec-level method addition. Kalshi's absorbed
spec collapses to one env var, so Kalshi specifically isn't affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cli): polish plan docs with symbol-name refs and review feedback

Captures plan-doc edits that didn't make it into the previous commit because
the docs were staged before subsequent simplify and review-pass edits.

- Replace pinned line numbers in plan-002 with symbol-name refs
  (mapResources, extractPageItems, readPathItemResourceID, Endpoint,
  x-resource-id), per the convention used by all other docs/plans/ files.
- Rename "the *machine*" to "the Printing Press" in plan-002, per
  AGENTS.md naming rules.
- Add regex-bypass and promotion-gate risks to plan-001's Risks section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c46cf0157409949d46b958836aadafa8fa281913
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 00:22:54 2026 -0700

    fix(cli): correct named-array envelope detection and api_key set-token slot (#706)
    
    * fix(cli): correct named-array envelope detection and api_key set-token slot
    
    Fixes three machine bugs surfaced by issue #689 (kalshi-pp-cli sync regression):
    
    Bug 1 (parser): unwrapItemSchema only handled {data: [...]} envelopes;
    named-array envelopes like {events: [...], cursor: "..."} fell through to
    the wrapper, and resolveIDFieldFromResponseSchema then picked the wrapper's
    required `cursor` string as the resource PK. New singleArrayProperty helper
    descends into the sole array property when present, so item-level required
    scalars (event_ticker, ticker, etc.) win for Kalshi-shaped APIs.
    
    Bug 5.2 (auth): set-token for Auth.Type=="api_key" was writing to AccessToken,
    but AuthHeader() doesn't read AccessToken for api_key auth. Saved tokens
    were silently invisible. New SaveCredential method writes to the env-var-
    derived field that AuthHeader actually reads. Clears precede the assignment
    so a canonical env-var whose placeholder collides with a builtin tag (e.g.
    XXX_ACCESS_TOKEN resolving to AccessToken) ends up holding the new token.
    
    Bug 5.3 (doctor): auth_source field was blank when credentials came from
    disk. Load() now sets AuthSource = "config" after env-var overrides if any
    credential slot is populated. Label is the literal "config" rather than
    "config:<path>" — config_path is exposed separately and embedding the path
    in auth_source leaks the user's home directory through doctor's JSON.
    
    Includes scoping plans for two deferred meta-fixes: SQL ↔ schema validator
    in dogfood (Bug 4) and multi-array envelope x-list-fields (Bug 3). Bug 2
    (multi-endpoint per resource) is tracked separately at #701.
    
    Known limitation: api_key auth specs with 2+ required per_call EnvVarSpecs
    (HMAC, RSA-PSS) get only the canonical field written. set-token reports
    success but AuthHeader requires all fields populated. Surfaced by code
    review; fix shape requires a spec-level method addition. Kalshi's absorbed
    spec collapses to one env var, so Kalshi specifically isn't affected.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * docs(cli): polish plan docs with symbol-name refs and review feedback
    
    Captures plan-doc edits that didn't make it into the previous commit because
    the docs were staged before subsequent simplify and review-pass edits.
    
    - Replace pinned line numbers in plan-002 with symbol-name refs
      (mapResources, extractPageItems, readPathItemResourceID, Endpoint,
      x-resource-id), per the convention used by all other docs/plans/ files.
    - Rename "the *machine*" to "the Printing Press" in plan-002, per
      AGENTS.md naming rules.
    - Add regex-bypass and promotion-gate risks to plan-001's Risks section.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 ...01-feat-cli-novel-feature-sql-validator-plan.md | 148 ++++++++++++++++++
 ...-cli-multi-array-envelope-x-list-fields-plan.md | 169 +++++++++++++++++++++
 internal/generator/auth_optional_test.go           | 136 +++++++++++++++--
 internal/generator/templates/auth_simple.go.tmpl   |  21 ++-
 internal/generator/templates/config.go.tmpl        |  49 ++++++
 internal/openapi/parser.go                         |  61 ++++++--
 internal/openapi/parser_test.go                    | 131 ++++++++++++++++
 .../internal/config/config.go                      |  18 +++
 .../printing-press-rich-auth/internal/cli/auth.go  |  18 ++-
 .../internal/config/config.go                      |  48 ++++++
 .../printing-press-golden/internal/cli/auth.go     |  18 ++-
 .../internal/config/config.go                      |  30 ++++
 12 files changed, 805 insertions(+), 42 deletions(-)

diff --git a/docs/plans/2026-05-07-001-feat-cli-novel-feature-sql-validator-plan.md b/docs/plans/2026-05-07-001-feat-cli-novel-feature-sql-validator-plan.md
new file mode 100644
index 00000000..5f5b381d
--- /dev/null
+++ b/docs/plans/2026-05-07-001-feat-cli-novel-feature-sql-validator-plan.md
@@ -0,0 +1,148 @@
+---
+title: "feat(cli): Novel-feature SQL ↔ schema validator in dogfood"
+type: feat
+status: proposed
+date: 2026-05-07
+related:
+  - cli-printing-press#689
+---
+
+# feat(cli): Novel-feature SQL ↔ schema validator in dogfood
+
+## Overview
+
+Novel-feature commands (`portfolio winrate`, `portfolio attribution`, `markets correlate`, etc.) execute SQL against the printed CLI's local SQLite store. Today there is no machine check that the JSON paths those queries reference (`$.settled_at`, `$.cost`, `$.revenue`) actually exist on the synced response shape. The cost is the failure mode reported in #689 Bug 4: a command that builds, registers, passes scorecard, and returns `[]` against real data because every `WHERE`-clause path is null in the row.
+
+Add a `sql_schema_check` to the dogfood report: extract JSON paths from each novel-feature command's SQL, cross-reference them against the response schema for the resource the command targets, and surface mismatches as suspicious findings. Same shape as `reimplementation_check` — structural detection, regex/AST-based, no live API calls — so it runs in `printing-press dogfood` alongside the other gates.
+
+This plan covers the static-only first pass. Runtime validation against actually-synced data is a follow-up that can layer on once the static check is in place.
+
+---
+
+## Problem Frame
+
+Issue #689 ran the printed `kalshi-pp-cli` against a real account and discovered:
+
+- `portfolio winrate` filters on `WHERE type='settlement' OR settled_at IS NOT NULL` but Kalshi's settlement payload uses `settled_time`, not `settled_at`.
+- `portfolio attribution` projects `$.cost` but Kalshi splits cost into `$.yes_total_cost_dollars` / `$.no_total_cost_dollars` (also a unit mismatch — dollars-as-strings vs. cents).
+- `portfolio exposure` references positions schema fields that don't exist on the synced data (compounded by Bug 3's multi-array envelope drop).
+
+In every case, the SQL was authored against a generic mental model of what a Kalshi-shaped API "ought to" return, not the actual response schema captured in the absorb manifest's example payloads. Three independent commands shipped with three independent JSON-path errors. Scorecard, golden, and dogfood all passed.
+
+The compounding effect is the real failure: Bug 1 (dropped rows) masked Bug 2 (missing endpoints) which masked Bug 4 (wrong field names). A user who fixes Bug 1 still doesn't see real numbers. The validator's job is to break that compounding by failing loudly at generation time when *any* novel-feature SQL references a path the response schema doesn't promise.
+
+---
+
+## Requirements Trace
+
+- R1. The check inspects every novel-feature command listed in `research.json` (or the absorb manifest's transcendence table) that ships with SQL — that is, every handler whose file contains a SQLite/store query.
+- R2. The check extracts the JSON paths used in `WHERE`, `ORDER BY`, `SELECT json_extract(...)`, and projection expressions. Paths are the dot-suffixes of `json_extract(data, '$.<path>')` and the equivalent SQLite JSON1 forms.
+- R3. The check identifies the resource each query targets — either via a comment annotation (`// pp:novel-sql-target <resource>`) on the handler file, or by inspecting the SQL's `WHERE resource_type = '<x>'` clause as a fallback.
+- R4. The check cross-references each extracted path against the response schema's flattened property set for that resource. Paths whose head segment is not present on the item schema are flagged.
+- R5. Mismatches are reported as `SQLSchemaFinding{Command, File, Path, Resource, Reason}` and surfaced through `DogfoodReport.SQLSchemaCheck.Suspicious`. The check is `WARN` severity in the verdict matrix until the false-positive rate is known, then promoted to `FAIL`.
+- R6. The check skips gracefully when (a) the command has no SQL, (b) no resource target is declared or inferable, (c) the spec lacks a response schema for the targeted resource. `Skipped` and the reason are captured in the report so retro/scorer analytics can distinguish "passed" from "did not run."
+- R7. A unit test in `internal/pipeline/sql_schema_check_test.go` exercises: matching path on item schema (pass), mismatched path (flagged), envelope-only path (flagged), missing schema (skipped), missing resource target (skipped), and the Kalshi `$.settled_at` vs. `settled_time` regression specifically.
+- R8. A scorecard adjustment surfaces the finding count in the existing novel-features dimension. Skill prose in `printing-press/SKILL.md` directs the novel-feature subagent to consume the absorb manifest's example payloads when authoring SQL — the validator is the safety net, not the primary teacher.
+
+---
+
+## Scope Boundaries
+
+- Static check only. No live SQL execution against synced data, no SQLite engine in dogfood. Path extraction is regex/AST over Go source.
+- No SQL parsing beyond JSON-path extraction. We do not validate column existence on synthetic tables (`fills`, `settlements`), only paths threaded through `json_extract` against the `data` blob.
+- No proof that the path is *reachable* — a path can exist on the schema but be sparsely populated. That's a runtime concern.
+- No fixing the SQL. The validator names the offense; the agent fixes it.
+- The validator runs at machine generation time (printing-press's own dogfood/verify), not inside the printed CLI's runtime test suite. The printed CLI's tests are out of scope.
+
+### Deferred to Follow-Up Work
+
+- Runtime validation in live dogfood: when a sync against a real account has produced rows, run each novel-feature SQL and assert >0 rows, then assert each projected column is non-null on >N% of rows. Unblocks #701 (Bug 2: multi-endpoint per resource) by giving us a way to verify "this command's SQL targets resource X" mechanically.
+- AST-based path extraction. The first pass is regex over Go source, matching the existing `reimplementation_check` style. AST is a second-pass upgrade if false-positive pressure grows.
+- Unit-mismatch detection (cents vs. dollars, ISO timestamp vs. epoch). The Kalshi case had `revenue` in cents and `cost` in dollars; the SQL divided by 100 assuming uniform units. Surfacing this requires schema annotations the absorb step doesn't currently emit.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/dogfood.go` — owns `DogfoodReport`, the verdict matrix, and the Skipped/Issue plumbing the new check needs to slot into.
+- `internal/pipeline/reimplementation_check.go` — the closest existing pattern. Walks novel-feature handler files, extracts structural signals (client-call, store-call, annotation), reports findings. Same shape; new file `internal/pipeline/sql_schema_check.go` mirrors its layout.
+- `internal/pipeline/dogfood.go::checkReimplementation` call site — where the new check hooks into the report build.
+- `internal/spec/spec.go::Endpoint.Response` — the response schema we cross-reference. Already populated by the OpenAPI parser via `mapResponse`.
+- `internal/openapi/parser.go::resolveIDFieldFromResponseSchema` and `unwrapItemSchema` — the same item-schema descent logic the validator needs to find the property set to validate against.
+- `skills/printing-press/references/novel-features-subagent.md` — owns the prose instructions for authoring novel features. The skill must point the subagent at the absorb manifest's example payloads when authoring SQL; the validator is the enforcement.
+- `skills/printing-press/references/absorb-scoring.md::Reimplementation` — frames the policy template the new check inherits: "synthesizes API responses locally" → "Cut or rewrite." SQL referencing nonexistent fields synthesizes a query against a phantom shape; same severity class.
+
+### How Resource Targeting Resolves
+
+Three signal sources, in priority order:
+
+1. **Annotation.** Handler file contains `// pp:novel-sql-target <resource>` on the function or file level. Explicit and unambiguous; preferred when the SQL targets a non-obvious resource (joins across multiple resources, derived tables).
+2. **WHERE clause.** SQL contains `WHERE resource_type = '<x>'`. Inferred reliably from the existing pattern in `transcendence.go`-style handlers. Single-resource queries.
+3. **Skip.** No annotation, no inferable WHERE, multi-resource join with no annotation. Report as `Skipped` with reason; do not flag false positives.
+
+The annotation lives in the printed CLI's handler files (agent-authored), so emitting it is a skill change — `novel-features-subagent.md` is updated to require the annotation when the absorb manifest names a target resource.
+
+### Why Static Beats Runtime for the First Pass
+
+Runtime validation needs synced data, which needs a real account, which needs credentials, which the dogfood gate doesn't have access to. Static validation runs in CI on every commit, against every spec we have a fixture for, with no external dependencies. It catches the Kalshi `settled_at` vs. `settled_time` class of bug — which is the dominant class — without any live infrastructure.
+
+Runtime validation matters for the residual class: paths that exist on the schema but are populated under conditions the static check can't see (`type = 'settlement'` only when something was settled). That's the live-dogfood follow-up.
+
+---
+
+## Implementation Plan
+
+### Phase 1: Static path extraction and check skeleton
+
+- Add `internal/pipeline/sql_schema_check.go`. Define `SQLSchemaCheckResult` and `SQLSchemaFinding`. Mirror the shape of `ReimplementationCheckResult`.
+- Extract JSON paths from each novel-feature handler file. Regexes target `json_extract(<col>, '$.<path>')`, `json(<col>)->>'$.<path>'`, and the SQLite `->` / `->>` operator forms. Keep the regex set narrow for the first pass; expand only on observed misses.
+- Identify the resource target via `// pp:novel-sql-target <resource>` annotation; fall back to `resource_type = '<x>'` regex on the SQL string; skip otherwise.
+- Hook into `checkReimplementation`'s call site in `internal/pipeline/dogfood.go`.
+
+### Phase 2: Schema cross-reference
+
+- Reuse `unwrapItemSchema` (after Bug 1's named-array envelope fix) to get the item schema for the targeted resource's primary list endpoint.
+- Build a flattened property set: for each property on the item schema, recurse into nested objects up to a small depth bound (start with 3) and collect dotted paths. Keep the bound conservative; Kalshi's settlements payload is shallow.
+- Compare each extracted SQL path's head segment (and full path when nested) against the property set. Flag mismatches. Tolerate paths whose head matches a property typed as `additionalProperties: true` or `oneOf` (open shape — can't validate).
+
+### Phase 3: Reporting and verdict integration
+
+- Surface findings via `DogfoodReport.SQLSchemaCheck.Suspicious` in the JSON report.
+- Add a `WARN` row to the verdict matrix in `dogfood.go` mirroring `reimplementation_check`'s row. Promote to `FAIL` after one or two CLIs land cleanly.
+- Surface a one-line summary in the existing dogfood text output.
+
+### Phase 4: Tests
+
+- Unit tests in `internal/pipeline/sql_schema_check_test.go`. Fixture cases:
+  - Item schema has `event_ticker`; SQL filters on `$.event_ticker` → pass.
+  - Item schema has `settled_time`; SQL filters on `$.settled_at` → flagged with reason "path not in item schema; nearest match: settled_time".
+  - SQL filters on `$.cursor` (envelope-level scalar) → flagged with reason "path is on response wrapper, not item schema."
+  - No annotation, no inferable WHERE → skipped with reason "resource target not declared."
+  - Item schema is `additionalProperties: true` → skipped with reason "item schema is open-ended."
+  - Kalshi-shaped fixture: `WHERE type='settlement' OR settled_at IS NOT NULL` → flagged on `$.settled_at`.
+
+### Phase 5: Skill prose
+
+- Update `skills/printing-press/references/novel-features-subagent.md`: require `// pp:novel-sql-target <resource>` on every SQL-bearing novel-feature handler; instruct the subagent to consult the absorb manifest's example payloads when authoring SQL; surface the `sql_schema_check` failure as a Phase 4 blocker just like reimplementation findings.
+- Update `skills/printing-press/references/absorb-scoring.md::Reimplementation` row to reference SQL-schema fidelity alongside response-builder synthesis.
+
+---
+
+## Verification
+
+- `go test ./internal/pipeline/...` — unit tests pass.
+- `scripts/golden.sh verify` — generator output unchanged (this is a new check, not a generator change).
+- Manual: regenerate `kalshi-pp-cli` from the absorbed spec; run dogfood; assert `sql_schema_check.suspicious` lists `portfolio winrate ($.settled_at)`, `portfolio attribution ($.cost)`, and any other paths surfaced by the check. The validator does not fix the SQL — its job is to surface, not heal.
+- Manual: regenerate one or two existing public-library CLIs that have novel-feature SQL (steam-run3, recipe-goat, movie-goat). The check should run against them; any findings represent real bugs that were previously invisible. Triage results inform the WARN-vs-FAIL promotion decision.
+
+---
+
+## Risks
+
+- **False positives from open-ended schemas.** APIs that return `additionalProperties: true` or untyped maps will trigger the skip path and reduce signal. Acceptable for v1 — better to skip than flag noise. If the skip rate climbs above ~30% across the public library, revisit the property-set construction.
+- **Schema drift from API evolution.** A spec snapshot can lag the live API; the validator catches "SQL drifted from spec" but not "spec drifted from API." Runtime validation in the follow-up plan closes that gap.
+- **Annotation discipline.** If the novel-features subagent forgets the `pp:novel-sql-target` annotation, the check skips silently. Mitigation: dogfood surfaces `Skipped` reasons in the report; retro reviewers can scan for "resource target not declared" as a structural smell.
+- **Regex extraction has silent-miss bypass classes.** First-pass path extraction matches literal `'$.<path>'` arguments only. SQL using string concatenation (`json_extract(data, '$.' || :col)`), hex literals (`x'...'`), or parameterized paths (`json_extract(data, ?)`) defeats the regex and the validator silently passes. False-confidence risk grows after WARN-to-FAIL promotion. Mitigation: add a "computed-path detector" that flags any non-literal argument to `json_extract` for explicit annotation or acceptance.
+- **Promotion gate evidence.** Promoting from WARN to FAIL needs observed false-positive rates across the full public library, not "one or two clean CLIs." Define what "low enough" means (e.g., FP rate &lt;5% across N&gt;=10 CLIs) before the promotion. The composed-header-auth scorecard in `docs/solutions/logic-errors/scorer-dogfood-composed-header-auth-and-example-continuations-2026-05-05.md` is the closest precedent.
diff --git a/docs/plans/2026-05-07-002-feat-cli-multi-array-envelope-x-list-fields-plan.md b/docs/plans/2026-05-07-002-feat-cli-multi-array-envelope-x-list-fields-plan.md
new file mode 100644
index 00000000..2defbaa3
--- /dev/null
+++ b/docs/plans/2026-05-07-002-feat-cli-multi-array-envelope-x-list-fields-plan.md
@@ -0,0 +1,169 @@
+---
+title: "feat(cli): Multi-array envelope support via x-list-fields"
+type: feat
+status: proposed
+date: 2026-05-07
+related:
+  - cli-printing-press#689
+---
+
+# feat(cli): Multi-array envelope support via x-list-fields
+
+## Overview
+
+Sync's array extractor in `internal/generator/templates/sync.go.tmpl` bails when a response envelope contains more than one top-level array field, leaving the resource un-syncable. Kalshi's `/portfolio/positions` returns `{event_positions: [...], market_positions: [...], cursor: "..."}`; the extractor sees `arrayCount == 2` and falls through. The `exposure` novel-feature command, which depends on positions, cannot be unblocked even after #689 Bugs 1 and 2 are fixed.
+
+Add a path-level OpenAPI extension `x-list-fields` that explicitly declares the names of array fields to drain from a multi-array envelope. The Printing Press's pre-generation enrichment step auto-emits the extension when it detects multi-array shapes in the response schema or absorb-manifest example payloads — so spec authors never see the extension and operators never hand-write it. The generator threads the extension through the parser onto `Endpoint`, the sync template iterates all listed fields, and each row is tagged with a `kind` discriminator derived from the source array's name so downstream queries can distinguish event-level from market-level rows.
+
+This plan covers the extension contract, parser surface, template change, auto-emission hook, docs, and tests.
+
+---
+
+## Problem Frame
+
+The current sync extractor's logic (lines 605-620 of `sync.go.tmpl`):
+
+```go
+arrayCount := 0
+for key, raw := range envelope {
+    if json.Unmarshal(raw, &candidate); ... {
+        arrayKey = key
+        arrayItems = candidate
+        arrayCount++
+    }
+}
+if arrayCount == 1 {
+    return arrayItems, ...
+}
+return nil, "", false
+```
+
+This is the right *conservative* default — picking one array out of two would silently drop data. But it leaves multi-array envelopes unreachable. The Kalshi run in #689 worked around this by hand-adding `portfolio-settlements` as a separate sync resource; that doesn't generalize and doesn't solve `positions` (which is one endpoint serving two related arrays the API author chose to bundle).
+
+Three signals point at "explicit declaration" as the right contract, not "smarter heuristic":
+
+1. **Two arrays sharing an envelope are usually deliberate.** When an API author bundles `event_positions` and `market_positions` together, they're saying these are facets of one logical resource (positions). A heuristic that picks the longer one or picks alphabetically would be wrong half the time.
+2. **The `kind` discriminator must be deterministic and queryable.** Novel-feature SQL like `WHERE kind = 'market_position'` needs a stable name. The array key is the natural choice; that requires preserving it.
+3. **Spec authors don't author extensions.** `x-resource-id` is mostly emitted by our absorb step or is in vendor specs we control. Same model applies here: the Printing Press sets `x-list-fields`, not humans.
+
+The trio of changes — extension contract + parser surface + template emission — is small. The auto-emission step is the one with design weight, because it sits in the absorb pipeline where multiple agents author overlay deltas.
+
+---
+
+## Requirements Trace
+
+- R1. The OpenAPI extension `x-list-fields` is recognized at path-item level, parallel to `x-resource-id` and `x-critical`.
+- R2. The extension's value is an ordered list of strings, each naming a top-level property of the response schema's success envelope. Validation rejects non-array values, non-string elements, empty lists, and names that don't appear as object properties on the envelope schema.
+- R3. The parser threads the list onto `spec.Endpoint.ListFields []string`. Empty list means "fall through to the existing single-array detection."
+- R4. The sync template (`sync.go.tmpl`) drains each declared field's array, concatenates the rows, and tags each row with a synthetic `kind` field derived from the source field name.
+- R5. The store schema includes `kind` as a column on the `resources` table (or as a JSON-extracted virtual column) so downstream SQL can `WHERE kind = '<field>'`.
+- R6. The Printing Press's pre-generation enrichment step (the same hook that emits MCP enrichment) auto-detects multi-array envelopes and writes `x-list-fields` into the spec. Detection looks at the response schema and, when an absorb manifest with example payloads is available, uses those to confirm the arrays are populated in real responses.
+- R7. `docs/SPEC-EXTENSIONS.md` documents the extension alongside `x-resource-id`, with an example.
+- R8. Goldens cover one fixture spec with a multi-array envelope. The generator emits sync code that drains both arrays and tags rows; the parser's existing tests are extended with the new field.
+- R9. The `IDField` resolution path (Bug 1's `unwrapItemSchema`) does not regress: a multi-array envelope with `x-list-fields` declared resolves IDField from the *first* listed field's item schema. Without the extension, the fallback path (single-array detection) still applies as before.
+
+---
+
+## Scope Boundaries
+
+- One spec extension, one parser field, one template change, one auto-emission rule. No new core data model.
+- Auto-emission is a heuristic over the response schema and (when available) the absorb manifest's example payloads. Hand-authored `x-list-fields` is supported and wins over auto-emission.
+- The store schema gains one column (`kind`). Existing single-array resources leave it empty; multi-array resources populate it. No migration needed for first-time syncs.
+- Novel-feature SQL is responsible for using the `kind` discriminator when it cares about array-of-origin. The validator from #689 Bug 4 will catch SQL that ignores `kind` when it should — out of scope here.
+- No support for nested or N>2 multi-array shapes in v1 beyond what falls naturally out of the iterator. The two-array Kalshi case is the canonical fixture; a 3+ array envelope works the same way.
+
+### Deferred to Follow-Up Work
+
+- Per-array `kind` overrides (e.g. `x-list-fields: [{field: event_positions, kind: event}, ...]`). v1 derives `kind` from the field name directly; explicit overrides are a follow-up if API shapes prove awkward.
+- Cross-array deduplication. If two arrays share IDs (rare; would indicate a mis-modeled API), v1 lands both rows. A retro can decide whether to add dedup later.
+- Auto-emission for nested envelopes (`{data: {arrays: {...}}}`). Out of scope; the auto-emitter looks one level deep only.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/templates/sync.go.tmpl::extractPageItems` (the array extractor that currently bails on `arrayCount != 1`). The Phase 3 template change replaces that block with a per-resource `listFields` lookup that, when populated, drains the named fields; otherwise falls through to today's single-array path.
+- `internal/openapi/parser.go::readPathItemResourceID` — mirror pattern for `readPathItemListFields`: read `x-list-fields`, validate, return `[]string`.
+- The `pathResourceIDOverride` read site in `internal/openapi/parser.go::mapResources` — add a parallel `pathListFields := readPathItemListFields(...)` and assign it to each endpoint built under that path.
+- `internal/spec/spec.go::Endpoint` — add `ListFields []string` with YAML/JSON tags.
+- `docs/SPEC-EXTENSIONS.md::x-resource-id` — insert `x-list-fields` immediately above or below it; same shape.
+- `internal/generator/templates/store.go.tmpl` — owns the store schema. Add `kind TEXT` to the resources table DDL and to the upsert path.
+- `internal/pipeline/` — owns the pre-generation enrichment hooks the auto-emitter sits in. The closest existing pattern is the auth-enrichment Phase 2 step referenced in `skills/printing-press/SKILL.md:1937` ("pre-generation auth enrichment ran correctly"). The list-fields auto-emitter lives in the same orchestration layer.
+
+### How Auto-Emission Decides
+
+Two-stage detection, run after the spec is loaded but before the parser builds endpoints:
+
+1. **Response schema has 2+ top-level array properties on the success envelope.** This is the strict trigger. Single-array envelopes already work via the `data:` fast path and the new single-named-array path landed in Bug 1.
+2. **Absorb manifest example payloads (when present) confirm both arrays are populated in at least one captured response.** This is the safety net. If the schema declares two arrays but real responses only ever populate one, prefer the single-array fallback rather than emitting an extension that splits the data unnecessarily.
+
+When both conditions hold, write `x-list-fields: [<field1>, <field2>, ...]` to the path item. Order is the order of property declaration in the schema (kin-openapi preserves this through the `Required` slice when fields are listed there; otherwise alphabetical for determinism). Hand-authored `x-list-fields` short-circuits both detection stages.
+
+### Why an Extension Beats a Heuristic
+
+Two failure modes for a "drain-all-arrays" template heuristic:
+
+1. **Metadata arrays.** `{users: [...], errors: [], warnings: []}` — `errors` and `warnings` are status, not data. A drain-all heuristic ships warning rows into the resources table.
+2. **Mixed-shape arrays.** `{data: [...], links: [{rel, href}, ...]}` — `links` is HATEOAS metadata with a different schema. Concatenation would produce schema-violating rows.
+
+An extension says "these specific fields are list payloads," which is the information the parser needs but cannot infer from shape alone. The auto-emitter applies a cautious heuristic *once* (at enrichment time, with the manifest in hand for confirmation); the runtime template has no heuristic at all.
+
+### Where the `kind` Tag Comes From
+
+Field name → snake_case → trim `_positions` / `_list` / `_items` / etc. suffix when present (heuristic; gives `event` from `event_positions`); fall back to the raw field name when the trim would empty the string. Alternative: skip the trim and use the field name as-is (`event_positions` literally) — simpler, less pretty. The trim heuristic is a v1 nicety that can be cut if it surfaces edge cases.
+
+---
+
+## Implementation Plan
+
+### Phase 1: Extension contract and parser surface
+
+- Add `readPathItemListFields(pathItem, path) []string` in `internal/openapi/parser.go`. Validate: must be a YAML sequence of non-empty strings; reject other shapes with `warnf` and return nil.
+- Read the extension at the same place `readPathItemResourceID` is called inside `mapResources`. Pass through to each endpoint built under the path.
+- Add `ListFields []string` to `internal/spec/spec.go::Endpoint` with `yaml:"list_fields,omitempty"` and `json:"list_fields,omitempty"`.
+- Cross-validate against the response schema: every name in `x-list-fields` must appear as a property on the envelope schema. Names that don't appear emit a warning and are dropped from the resolved list.
+- Unit tests in `internal/openapi/parser_test.go`: valid two-name list passes through; non-array value rejected; empty strings rejected; names absent from schema dropped with warning; missing extension leaves field empty.
+
+### Phase 2: Sync template
+
+- Replace the existing `arrayCount`-based fallback block in `sync.go.tmpl::extractPageItems` with: if `Endpoint.ListFields` is non-empty, iterate the named fields in order, accumulate rows, and tag each row with `kind` derived from the field name. Fall through to the existing single-array path when `ListFields` is empty.
+- Update `extractID` and the row write path to include `kind` in the row metadata.
+- Update `internal/generator/templates/store.go.tmpl` to declare `kind TEXT` in the resources table DDL and accept it in the upsert. Existing rows without `kind` carry an empty string — backwards-compatible since old queries don't filter on it.
+- Goldens: add a fixture spec under `testdata/golden/fixtures/multi-array-envelope.yaml` whose `/positions` endpoint returns `{event_positions: [...], market_positions: [...], cursor}` with `x-list-fields: [event_positions, market_positions]`. Golden case under `testdata/golden/cases/generate-multi-array-envelope/`.
+
+### Phase 3: Auto-emission hook
+
+- Add `internal/pipeline/list_fields_enrichment.go`. Runs after spec load, before parser. Walks the OpenAPI doc; for each path with a multi-array envelope on its success response schema, emits `x-list-fields` if not already present.
+- When an absorb manifest with example payloads is reachable, gate auto-emission on "at least one captured response populates each named array." When no manifest is available (test fixtures, lib-imported specs without research), apply schema-only emission with a warning logged.
+- Unit tests in `internal/pipeline/list_fields_enrichment_test.go`: schema with 2+ arrays + populated examples → emits extension; schema with 2+ arrays + only one populated in examples → does not emit; schema with 1 array → no-op; hand-authored extension → respected without modification.
+
+### Phase 4: Docs and skill
+
+- Add `x-list-fields` section to `docs/SPEC-EXTENSIONS.md` immediately after `x-resource-id`. Include a Kalshi-shaped example.
+- Update `skills/printing-press/SKILL.md` to mention the extension's existence and the auto-emission step in the same paragraph as auth/MCP enrichment. The skill doesn't need to teach the extension — the agent never writes it directly — but reviewers should know it exists when reading absorbed specs.
+
+### Phase 5: Manual verification on Kalshi
+
+- Re-absorb Kalshi (or apply the new auto-emitter to the existing absorbed spec); confirm `x-list-fields: [event_positions, market_positions]` is on `/portfolio/positions`.
+- Regenerate `kalshi-pp-cli`; run sync against a real account; confirm both arrays land as rows in the `resources` table with distinct `kind` values.
+- Run `portfolio exposure` against the synced data; confirm it returns non-empty rows. (The novel-feature SQL itself may also need to filter on `kind` — that's a printed-CLI concern, but documenting the manual repro is worth it for the retro.)
+
+---
+
+## Verification
+
+- `go test ./...` — all unit tests pass.
+- `scripts/golden.sh verify` — new multi-array fixture produces stable output; existing fixtures unchanged because `Endpoint.ListFields` is empty by default.
+- `scripts/golden.sh verify` after adding the `kind` column — store DDL diff is the only generator-output change; existing store fixtures show an additional column with no behavioral effect on single-array sync paths.
+- Manual re-sync of `kalshi-pp-cli` populates positions; `exposure` returns non-empty rows; the dogfood SQL-schema validator (#689 Bug 4 plan) accepts the new schema once present.
+
+---
+
+## Risks
+
+- **Auto-emitter false positives.** A schema declares two arrays but the API rarely populates the second; the emitter still writes the extension. Outcome: a `kind` column with one rare value. Low harm. Mitigation: the manifest-gated detection path catches the common cases.
+- **`kind` column on existing single-array CLIs.** Adding a column to the store DDL is technically a schema change for already-deployed CLIs. SQLite tolerates additive columns, and existing rows will have `kind = ''`. No migration script needed; existing user data survives.
+- **Order sensitivity.** The order in `x-list-fields` determines which array's item schema feeds `IDField` resolution. If an author hand-writes the list with the "wrong" array first, the resolved IDField may differ. Acceptable: the auto-emitter is the dominant author and uses schema-property order; manual authors can be guided by docs.
+- **Field-name collisions across arrays.** `event_positions[].id` and `market_positions[].id` both produce rows with `id` populated, but the values are distinct namespaces. Sync's existing PK uniqueness assumes one array per resource; the `kind` column is part of the deduplication key for multi-array rows. The store DDL change in Phase 2 must include `kind` in the unique constraint.
diff --git a/internal/generator/auth_optional_test.go b/internal/generator/auth_optional_test.go
index 803f3fbe..624a5b7d 100644
--- a/internal/generator/auth_optional_test.go
+++ b/internal/generator/auth_optional_test.go
@@ -149,14 +149,17 @@ func TestAuthConfig_Optional_ZeroValue(t *testing.T) {
 // TestSetToken_ClearsLegacyAuthHeader verifies the generated set-token handler
 // clears cfg.AuthHeaderVal before saving. Without this clear, a pre-existing
 // auth_header in config.toml (the common regenerate scenario) shadows the
-// newly-saved access_token via Config.AuthHeader()'s resolver order, and
+// newly-saved credential via Config.AuthHeader()'s resolver order, and
 // set-token silently has no effect on the active credential.
 //
 // Verification is a substring match on the generated source: the rendered
-// auth.go must contain `cfg.AuthHeaderVal = ""` immediately before the
-// SaveTokens call. The presence of the line is the contract; the exact
-// behavior is exercised end-to-end by the generated CLI's own set-token
-// command at runtime.
+// auth.go must contain `cfg.AuthHeaderVal = ""` before the save call. The
+// presence of the line is the contract; the exact behavior is exercised
+// end-to-end by the generated CLI's own set-token command at runtime.
+//
+// For api_key auth, the save call is SaveCredential (writes to the env-var-
+// derived field that AuthHeader() consults). For other auth types it is
+// SaveTokens (writes to AccessToken).
 //
 // Refs cal-com retro #334 WU-4.
 func TestSetToken_ClearsLegacyAuthHeader(t *testing.T) {
@@ -176,13 +179,126 @@ func TestSetToken_ClearsLegacyAuthHeader(t *testing.T) {
 	require.Contains(t, src, "func newAuthSetTokenCmd",
 		"set-token handler must be emitted for the default auth template")
 	require.Contains(t, src, `cfg.AuthHeaderVal = ""`,
-		"set-token must clear legacy auth_header before SaveTokens; without this, a pre-existing auth_header shadows the new token")
+		"set-token must clear legacy auth_header before saving; without this, a pre-existing auth_header shadows the new token")
 
-	// Confirm ordering: the clear comes before SaveTokens in the same handler.
+	// Fixture uses Auth.Type=api_key, so the save call should be
+	// SaveCredential — writing to the field AuthHeader() actually reads.
 	clearIdx := strings.Index(src, `cfg.AuthHeaderVal = ""`)
-	saveIdx := strings.Index(src, "cfg.SaveTokens(")
+	saveIdx := strings.Index(src, "cfg.SaveCredential(")
 	require.NotEqual(t, -1, clearIdx, "auth_header clear must be present")
-	require.NotEqual(t, -1, saveIdx, "SaveTokens call must be present")
+	require.NotEqual(t, -1, saveIdx, "SaveCredential call must be present for api_key auth")
 	require.Less(t, clearIdx, saveIdx,
-		"the auth_header clear must occur BEFORE SaveTokens — otherwise SaveTokens persists with the stale auth_header still set")
+		"the auth_header clear must occur BEFORE SaveCredential — otherwise the save persists with the stale auth_header still set")
+}
+
+// TestConfigLoad_LabelsConfigFileAuthSource verifies that Config.Load() labels
+// AuthSource as "config:<path>" when credentials come from the persisted
+// config file rather than env vars. Without this, doctor's auth_source field
+// stays blank and a user who saved a token via set-token cannot tell whether
+// their persisted credentials are being picked up.
+//
+// Verification asserts substring presence in the rendered Load() body — the
+// config-path label set must appear after the env-var override block and
+// guard on a non-empty credential slot.
+func TestConfigLoad_LabelsConfigFileAuthSource(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("config-source-label")
+	apiSpec.Auth.EnvVars = []string{"CONFIG_SOURCE_LABEL_API_KEY"}
+
+	outputDir := filepath.Join(t.TempDir(), "config-source-label-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	configSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	src := string(configSrc)
+
+	require.Contains(t, src, `cfg.AuthSource = "config"`,
+		"Load() must label config-derived credentials so doctor's auth_source surface isn't blank")
+	require.Contains(t, src, `cfg.AuthHeaderVal != "" || cfg.AccessToken != ""`,
+		"the config-source label must guard on a populated credential slot — empty configs should not be labeled")
+
+	// The label must apply to env-var-derived persisted fields too, not just
+	// the bearer/access-token slots — for api_key auth the env-var field is
+	// the *only* slot that matters.
+	require.Contains(t, src, `cfg.ConfigSourceLabelApiKey != ""`,
+		"the env-var-derived field must also trigger the config-source label")
+
+	// The new logic must run after the env-var overrides — otherwise the env
+	// override would set AuthSource = "env:..." which we'd then stomp.
+	envOverrideIdx := strings.Index(src, `cfg.AuthSource = "env:CONFIG_SOURCE_LABEL_API_KEY"`)
+	configLabelIdx := strings.Index(src, `cfg.AuthSource = "config"`)
+	require.NotEqual(t, -1, envOverrideIdx, "env-var override block must be present")
+	require.NotEqual(t, -1, configLabelIdx, "config-source label must be present")
+	require.Less(t, envOverrideIdx, configLabelIdx,
+		"env-var overrides must run before the config-source fallback so env wins over disk")
+
+	// The label must not include cfg.Path — embedding the path leaks the
+	// user's home directory through doctor's JSON envelope. config_path is
+	// exposed as a separate field for callers that need the location.
+	require.NotContains(t, src, `cfg.AuthSource = "config:" + cfg.Path`,
+		"the label must be the literal \"config\" — embedding cfg.Path leaks the home directory through doctor JSON")
+}
+
+// TestSetToken_BearerTokenUsesSaveTokens verifies the else-branch of the
+// auth_simple template's Auth.Type conditional: for non-api_key auth, set-token
+// must call SaveTokens(...) (the bearer/access-token slot), not SaveCredential.
+// Pinning both branches prevents a regression where the bearer path is
+// accidentally rewired to SaveCredential and silently sends saved tokens to a
+// slot AuthHeader() doesn't read for that auth type.
+func TestSetToken_BearerTokenUsesSaveTokens(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("bearer-set-token")
+	apiSpec.Auth.Type = "bearer_token"
+	apiSpec.Auth.EnvVars = []string{"BEARER_SET_TOKEN_TOKEN"}
+
+	outputDir := filepath.Join(t.TempDir(), "bearer-set-token-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	authSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+	require.NoError(t, err)
+	src := string(authSrc)
+
+	require.Contains(t, src, "func newAuthSetTokenCmd",
+		"set-token handler must be emitted for bearer_token auth")
+	require.Contains(t, src, `cfg.AuthHeaderVal = ""`,
+		"set-token must clear legacy auth_header before saving")
+	require.Contains(t, src, "cfg.SaveTokens(",
+		"bearer_token auth must use SaveTokens, not SaveCredential")
+	require.NotContains(t, src, "cfg.SaveCredential(",
+		"SaveCredential is api_key-only; bearer_token must not emit it")
+}
+
+// TestConfigLoad_LabelsConfigFileAuthSource_EnvVarSpecsBranch covers the
+// EnvVarSpecs branch of the new config-source label block in Load(). The
+// existing TestConfigLoad_LabelsConfigFileAuthSource exercises the legacy
+// EnvVars branch via minimalSpec; this companion test pins the EnvVarSpecs
+// branch so a future template change to the rich-auth path does not silently
+// drop the labeling.
+func TestConfigLoad_LabelsConfigFileAuthSource_EnvVarSpecsBranch(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("envvar-specs-source-label")
+	apiSpec.Auth.EnvVars = nil
+	apiSpec.Auth.EnvVarSpecs = []spec.AuthEnvVar{
+		{
+			Name:      "ENVVAR_SPECS_SOURCE_LABEL_API_KEY",
+			Kind:      spec.AuthEnvVarKindPerCall,
+			Required:  true,
+			Sensitive: true,
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "envvar-specs-source-label-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	configSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	src := string(configSrc)
+
+	require.Contains(t, src, `cfg.AuthSource = "config"`,
+		"EnvVarSpecs branch must emit the config-source label")
+	require.Contains(t, src, `cfg.EnvvarSpecsSourceLabelApiKey != ""`,
+		"EnvVarSpecs branch must guard on the rich-model env-var-derived field")
 }
diff --git a/internal/generator/templates/auth_simple.go.tmpl b/internal/generator/templates/auth_simple.go.tmpl
index 8c183341..7bb49314 100644
--- a/internal/generator/templates/auth_simple.go.tmpl
+++ b/internal/generator/templates/auth_simple.go.tmpl
@@ -97,17 +97,26 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Clear any legacy auth_header so AuthHeader() falls through to
-			// "Bearer " + AccessToken with the new token. Without this, a
-			// pre-existing auth_header value (common after regenerate) shadows
-			// the newly-saved access_token and set-token silently has no effect.
-			// Silent clear (no log line): a masked-tail variant could leak
-			// token bytes through scripted dogfood that captures stderr.
+			// the newly-saved credential. Without this, a pre-existing
+			// auth_header value (common after regenerate) shadows the saved
+			// token and set-token silently has no effect. Silent clear (no
+			// log line): a masked-tail variant could leak token bytes through
+			// scripted dogfood that captures stderr.
 			cfg.AuthHeaderVal = ""
 
-			// Save the token directly via the config's save mechanism
+{{- if and (eq .Auth.Type "api_key") .Auth.CanonicalEnvVar}}
+			// api_key auth: AuthHeader() reads the env-var-derived field, not
+			// AccessToken. Writing the token to AccessToken via SaveTokens
+			// would persist the bytes but leave doctor reporting "not
+			// configured" — the slot the header builder consults stays empty.
+			if err := cfg.SaveCredential(args[0]); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+{{- else}}
 			if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
 				return configErr(fmt.Errorf("saving token: %w", err))
 			}
+{{- end}}
 
 			// JSON envelope: {saved, config_path}.
 			if flags.asJSON {
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index ecb95d63..47887509 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -112,6 +112,35 @@ func Load(configPath string) (*Config, error) {
 		cfg.AuthSource = "env:{{.}}"
 	}
 {{- end}}
+{{- end}}
+
+	// Label config-file-derived credentials so doctor can distinguish
+	// "credentials persisted on disk" from "no credentials at all" — without
+	// this, users who saved via set-token without an env var see a blank
+	// auth_source and can't tell whether their config is being picked up.
+	// The label is the literal "config" rather than "config:<path>"; the
+	// config file path is exposed separately as report["config_path"], and
+	// embedding it in auth_source leaks the user's home directory through
+	// doctor's JSON envelope.
+	if cfg.AuthSource == "" && (cfg.AuthHeaderVal != "" || cfg.AccessToken != "") {
+		cfg.AuthSource = "config"
+	}
+{{- if .Auth.EnvVarSpecs}}
+{{- range .Auth.EnvVarSpecs}}
+{{- if not (envVarIsBuiltinField .Name)}}
+	if cfg.AuthSource == "" && cfg.{{envVarField .Name}} != "" {
+		cfg.AuthSource = "config"
+	}
+{{- end}}
+{{- end}}
+{{- else if .Auth.EnvVars}}
+{{- range .Auth.EnvVars}}
+{{- if not (envVarIsBuiltinField .)}}
+	if cfg.AuthSource == "" && cfg.{{envVarField .}} != "" {
+		cfg.AuthSource = "config"
+	}
+{{- end}}
+{{- end}}
 {{- end}}
 
 	// Base URL override (used by printing-press verify to point at mock/test servers)
@@ -365,6 +394,26 @@ func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken st
 	return c.save()
 }
 
+{{- if and (eq .Auth.Type "api_key") .Auth.CanonicalEnvVar}}
+
+// SaveCredential persists a single API credential to the field that
+// AuthHeader() consults for api_key auth. Writing to AccessToken (the
+// bearer slot) would silently no-op since AuthHeader() reads the env-var-
+// derived field, not AccessToken, when Auth.Type == "api_key".
+//
+// The clears precede the assignment so a canonical env-var whose placeholder
+// collides with a builtin tag (e.g. an env var named XXX_ACCESS_TOKEN
+// resolving to the AccessToken field) ends up holding the new token.
+func (c *Config) SaveCredential(token string) error {
+	c.AuthHeaderVal = ""
+	c.AccessToken = ""
+{{- with .Auth.CanonicalEnvVar}}
+	c.{{resolveEnvVarField .Name}} = token
+{{- end}}
+	return c.save()
+}
+{{- end}}
+
 {{- if .BearerRefresh.Enabled}}
 func (c *Config) SaveBearerToken(accessToken string, refreshedAt time.Time) error {
 	c.AuthHeaderVal = ""
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index c11bdbe3..f7d45e04 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -2672,9 +2672,17 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation) string {
 }
 
 // unwrapItemSchema returns the schema of items inside a list response. Handles
-// three shapes: bare object (no array, treat as a single-item resource and
-// inspect its fields), bare array (return Items.Value), or {data: [...]}
-// wrapper (mirroring mapResponse's handling).
+// four shapes: bare array (return Items.Value), {data: [...]} wrapper (fast
+// path; mirrors mapResponse's handling), single-named-array envelope where the
+// resource is wrapped under its own key alongside scalar/object metadata
+// siblings (e.g. Kalshi's {events: [...], cursor: "..."}), and bare object
+// (no array, treat as a single-item resource and inspect its fields).
+//
+// The single-named-array envelope detection is deliberately strict: it
+// requires *exactly one* array-typed property at the top level. Multi-array
+// envelopes (e.g. /portfolio/positions returning event_positions and
+// market_positions) fall through to the bare-object path; they need explicit
+// per-resource declaration since picking one would lose data.
 func unwrapItemSchema(schema *openapi3.Schema) *openapi3.Schema {
 	if schema == nil {
 		return nil
@@ -2682,14 +2690,47 @@ func unwrapItemSchema(schema *openapi3.Schema) *openapi3.Schema {
 	if isArraySchema(schema) && schema.Items != nil {
 		return schemaRefValue(schema.Items)
 	}
-	if isObjectSchema(schema) {
-		// {data: [...]} wrapper convention
-		if dataRef, ok := schema.Properties["data"]; ok {
-			if data := schemaRefValue(dataRef); isArraySchema(data) && data.Items != nil {
-				return schemaRefValue(data.Items)
-			}
+	if !isObjectSchema(schema) {
+		return nil
+	}
+	// {data: [...]} wrapper convention — fast path, preserved verbatim.
+	if dataRef, ok := schema.Properties["data"]; ok {
+		if data := schemaRefValue(dataRef); isArraySchema(data) && data.Items != nil {
+			return schemaRefValue(data.Items)
 		}
-		return schema
+	}
+	// Single-named-array envelope: object whose only array-typed property is
+	// the resource list, with the rest being pagination/metadata fields. This
+	// catches API patterns like {events: [...], cursor: "..."} where the
+	// wrapper key matches the resource name. Without this, the PK profiler
+	// would walk the wrapper itself and pick a scalar sibling (cursor,
+	// has_more) as the resource ID.
+	if items := singleArrayProperty(schema); items != nil {
+		return items
+	}
+	return schema
+}
+
+// singleArrayProperty returns the items schema of an object's sole
+// array-typed property, or nil if zero or multiple array properties exist.
+// Non-array siblings (scalars, objects) are ignored — they're typically
+// pagination metadata.
+func singleArrayProperty(schema *openapi3.Schema) *openapi3.Schema {
+	var items *openapi3.Schema
+	count := 0
+	for _, propRef := range schema.Properties {
+		prop := schemaRefValue(propRef)
+		if !isArraySchema(prop) || prop.Items == nil {
+			continue
+		}
+		count++
+		if count > 1 {
+			return nil
+		}
+		items = schemaRefValue(prop.Items)
+	}
+	if count == 1 {
+		return items
 	}
 	return nil
 }
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 05fc3ed9..feb2a64a 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -2854,6 +2854,137 @@ paths:
 	}
 }
 
+// TestParseIDFieldEnvelopeUnwrapping covers list responses whose payload is an
+// object envelope wrapping a single named array (e.g. {events: [...],
+// cursor: "..."}; many list APIs use this shape with the resource name as the
+// array key). The profiler must descend into the array's item schema and pick
+// the item's PK, not a scalar sibling on the wrapper.
+func TestParseIDFieldEnvelopeUnwrapping(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name       string
+		schemaYAML string
+		wantID     string
+	}{
+		{
+			name: "named-array envelope with cursor sibling: descends into items",
+			schemaYAML: `              schema:
+                type: object
+                required: [events, cursor]
+                properties:
+                  events:
+                    type: array
+                    items:
+                      type: object
+                      required: [event_ticker]
+                      properties:
+                        event_ticker: {type: string}
+                        title: {type: string}
+                  cursor: {type: string}
+`,
+			wantID: "event_ticker",
+		},
+		{
+			name: "named-array envelope with object-typed sibling: still descends",
+			schemaYAML: `              schema:
+                type: object
+                properties:
+                  items:
+                    type: array
+                    items:
+                      type: object
+                      required: [sku]
+                      properties:
+                        sku: {type: string}
+                  pagination:
+                    type: object
+                    properties:
+                      next: {type: string}
+`,
+			wantID: "sku",
+		},
+		{
+			name: "data-wrapper envelope still works (preserved fast path)",
+			schemaYAML: `              schema:
+                type: object
+                properties:
+                  data:
+                    type: array
+                    items:
+                      type: object
+                      properties:
+                        id: {type: string}
+                  cursor: {type: string}
+`,
+			wantID: "id",
+		},
+		{
+			name: "two top-level arrays: ambiguous, falls back to wrapper",
+			schemaYAML: `              schema:
+                type: object
+                properties:
+                  event_positions:
+                    type: array
+                    items: {type: object}
+                  market_positions:
+                    type: array
+                    items: {type: object}
+`,
+			wantID: "",
+		},
+		{
+			// A malformed array property (no items) sits alongside a
+			// well-formed one. singleArrayProperty must skip the malformed
+			// entry without it counting toward the "exactly one" cap, so the
+			// well-formed sibling still wins and PK detection succeeds.
+			name: "named-array envelope with one malformed sibling: well-formed array still wins",
+			schemaYAML: `              schema:
+                type: object
+                properties:
+                  events:
+                    type: array
+                    items:
+                      type: object
+                      required: [event_ticker]
+                      properties:
+                        event_ticker: {type: string}
+                  legacy:
+                    type: array
+                  cursor: {type: string}
+`,
+			wantID: "event_ticker",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Test
+  version: "1.0"
+servers:
+  - url: https://api.example.com
+paths:
+  /things:
+    get:
+      operationId: listThings
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+` + tt.schemaYAML)
+			parsed, err := Parse(yamlSpec)
+			require.NoError(t, err)
+
+			ep := findEndpoint(t, parsed, "/things")
+			assert.Equal(t, tt.wantID, ep.IDField)
+		})
+	}
+}
+
 // TestParseXResourceIDAppliesToEveryOperationOnPath exercises the "extensions
 // live on the path item" rule — both GET and POST operations under /widgets
 // inherit the x-resource-id and x-critical values, even though x-critical is
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
index 9a5ddc07..d5bac5f0 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
@@ -61,6 +61,24 @@ func Load(configPath string) (*Config, error) {
 		cfg.AuthSource = "env:PRINTING_PRESS_OAUTH2_CLIENT_SECRET"
 	}
 
+	// Label config-file-derived credentials so doctor can distinguish
+	// "credentials persisted on disk" from "no credentials at all" — without
+	// this, users who saved via set-token without an env var see a blank
+	// auth_source and can't tell whether their config is being picked up.
+	// The label is the literal "config" rather than "config:<path>"; the
+	// config file path is exposed separately as report["config_path"], and
+	// embedding it in auth_source leaks the user's home directory through
+	// doctor's JSON envelope.
+	if cfg.AuthSource == "" && (cfg.AuthHeaderVal != "" || cfg.AccessToken != "") {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.PrintingPressOauth2ClientId != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.PrintingPressOauth2ClientSecret != "" {
+		cfg.AuthSource = "config"
+	}
+
 	// Base URL override (used by printing-press verify to point at mock/test servers)
 	if v := os.Getenv("PRINTING_PRESS_OAUTH2_BASE_URL"); v != "" {
 		cfg.BaseURL = v
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go
index 0de66975..baeb813d 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.go
@@ -88,15 +88,17 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Clear any legacy auth_header so AuthHeader() falls through to
-			// "Bearer " + AccessToken with the new token. Without this, a
-			// pre-existing auth_header value (common after regenerate) shadows
-			// the newly-saved access_token and set-token silently has no effect.
-			// Silent clear (no log line): a masked-tail variant could leak
-			// token bytes through scripted dogfood that captures stderr.
+			// the newly-saved credential. Without this, a pre-existing
+			// auth_header value (common after regenerate) shadows the saved
+			// token and set-token silently has no effect. Silent clear (no
+			// log line): a masked-tail variant could leak token bytes through
+			// scripted dogfood that captures stderr.
 			cfg.AuthHeaderVal = ""
-
-			// Save the token directly via the config's save mechanism
-			if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+			// api_key auth: AuthHeader() reads the env-var-derived field, not
+			// AccessToken. Writing the token to AccessToken via SaveTokens
+			// would persist the bytes but leave doctor reporting "not
+			// configured" — the slot the header builder consults stays empty.
+			if err := cfg.SaveCredential(args[0]); err != nil {
 				return configErr(fmt.Errorf("saving token: %w", err))
 			}
 
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
index 8d3180d3..ec271074 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.go
@@ -86,6 +86,39 @@ func Load(configPath string) (*Config, error) {
 		cfg.AuthSource = "env:RICH_AUTH_USER_TOKEN"
 	}
 
+	// Label config-file-derived credentials so doctor can distinguish
+	// "credentials persisted on disk" from "no credentials at all" — without
+	// this, users who saved via set-token without an env var see a blank
+	// auth_source and can't tell whether their config is being picked up.
+	// The label is the literal "config" rather than "config:<path>"; the
+	// config file path is exposed separately as report["config_path"], and
+	// embedding it in auth_source leaks the user's home directory through
+	// doctor's JSON envelope.
+	if cfg.AuthSource == "" && (cfg.AuthHeaderVal != "" || cfg.AccessToken != "") {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthApiKey != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthClientId != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthClientSecret != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthSessionCookie != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthOptionalToken != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthBotToken != "" {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.RichAuthUserToken != "" {
+		cfg.AuthSource = "config"
+	}
+
 	// Base URL override (used by printing-press verify to point at mock/test servers)
 	if v := os.Getenv("PRINTING_PRESS_RICH_BASE_URL"); v != "" {
 		cfg.BaseURL = v
@@ -129,6 +162,21 @@ func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken st
 	return c.save()
 }
 
+// SaveCredential persists a single API credential to the field that
+// AuthHeader() consults for api_key auth. Writing to AccessToken (the
+// bearer slot) would silently no-op since AuthHeader() reads the env-var-
+// derived field, not AccessToken, when Auth.Type == "api_key".
+//
+// The clears precede the assignment so a canonical env-var whose placeholder
+// collides with a builtin tag (e.g. an env var named XXX_ACCESS_TOKEN
+// resolving to the AccessToken field) ends up holding the new token.
+func (c *Config) SaveCredential(token string) error {
+	c.AuthHeaderVal = ""
+	c.AccessToken = ""
+	c.RichAuthApiKey = token
+	return c.save()
+}
+
 func (c *Config) ClearTokens() error {
 	c.AccessToken = ""
 	c.RefreshToken = ""
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
index 3c63c448..5b90a744 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
@@ -85,15 +85,17 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Clear any legacy auth_header so AuthHeader() falls through to
-			// "Bearer " + AccessToken with the new token. Without this, a
-			// pre-existing auth_header value (common after regenerate) shadows
-			// the newly-saved access_token and set-token silently has no effect.
-			// Silent clear (no log line): a masked-tail variant could leak
-			// token bytes through scripted dogfood that captures stderr.
+			// the newly-saved credential. Without this, a pre-existing
+			// auth_header value (common after regenerate) shadows the saved
+			// token and set-token silently has no effect. Silent clear (no
+			// log line): a masked-tail variant could leak token bytes through
+			// scripted dogfood that captures stderr.
 			cfg.AuthHeaderVal = ""
-
-			// Save the token directly via the config's save mechanism
-			if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+			// api_key auth: AuthHeader() reads the env-var-derived field, not
+			// AccessToken. Writing the token to AccessToken via SaveTokens
+			// would persist the bytes but leave doctor reporting "not
+			// configured" — the slot the header builder consults stays empty.
+			if err := cfg.SaveCredential(args[0]); err != nil {
 				return configErr(fmt.Errorf("saving token: %w", err))
 			}
 
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
index 9ccaf699..a4d51dcb 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
@@ -56,6 +56,21 @@ func Load(configPath string) (*Config, error) {
 		cfg.AuthSource = "env:PRINTING_PRESS_GOLDEN_API_KEY"
 	}
 
+	// Label config-file-derived credentials so doctor can distinguish
+	// "credentials persisted on disk" from "no credentials at all" — without
+	// this, users who saved via set-token without an env var see a blank
+	// auth_source and can't tell whether their config is being picked up.
+	// The label is the literal "config" rather than "config:<path>"; the
+	// config file path is exposed separately as report["config_path"], and
+	// embedding it in auth_source leaks the user's home directory through
+	// doctor's JSON envelope.
+	if cfg.AuthSource == "" && (cfg.AuthHeaderVal != "" || cfg.AccessToken != "") {
+		cfg.AuthSource = "config"
+	}
+	if cfg.AuthSource == "" && cfg.PrintingPressGoldenApiKey != "" {
+		cfg.AuthSource = "config"
+	}
+
 	// Base URL override (used by printing-press verify to point at mock/test servers)
 	if v := os.Getenv("PRINTING_PRESS_GOLDEN_BASE_URL"); v != "" {
 		cfg.BaseURL = v
@@ -99,6 +114,21 @@ func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken st
 	return c.save()
 }
 
+// SaveCredential persists a single API credential to the field that
+// AuthHeader() consults for api_key auth. Writing to AccessToken (the
+// bearer slot) would silently no-op since AuthHeader() reads the env-var-
+// derived field, not AccessToken, when Auth.Type == "api_key".
+//
+// The clears precede the assignment so a canonical env-var whose placeholder
+// collides with a builtin tag (e.g. an env var named XXX_ACCESS_TOKEN
+// resolving to the AccessToken field) ends up holding the new token.
+func (c *Config) SaveCredential(token string) error {
+	c.AuthHeaderVal = ""
+	c.AccessToken = ""
+	c.PrintingPressGoldenApiKey = token
+	return c.save()
+}
+
 func (c *Config) ClearTokens() error {
 	c.AccessToken = ""
 	c.RefreshToken = ""

← d1c3371e fix(cli): dedupe TypeField identifiers in same struct (#705)  ·  back to Cli Printing Press  ·  fix(cli): close MCP sql tool exfiltration vector with allowl b196bd81 →