← 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
A docs/plans/2026-05-07-001-feat-cli-novel-feature-sql-validator-plan.mdA docs/plans/2026-05-07-002-feat-cli-multi-array-envelope-x-list-fields-plan.mdM internal/generator/auth_optional_test.goM internal/generator/templates/auth_simple.go.tmplM internal/generator/templates/config.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/auth.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/config/config.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/config/config.go
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 <5% across N>=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 →