Skip to content

v0.18 — contract hardening 2 (audit fixes before the 1.0 freeze)#18

Merged
wavyx merged 7 commits into
mainfrom
feat/v0.18-contract-hardening-2
Jun 15, 2026
Merged

v0.18 — contract hardening 2 (audit fixes before the 1.0 freeze)#18
wavyx merged 7 commits into
mainfrom
feat/v0.18-contract-hardening-2

Conversation

@wavyx

@wavyx wavyx commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Second contract-hardening pass from a full quality audit + adversarial contrarian review, closing the exit-code, machine-output, and data-safety gaps a 1.0 freeze would lock in. 1503 tests, 100% coverage; live-verified on the sandbox.

Data safety (headline)

  • CRITICAL: CSV --upsert no longer deletes a matched record's other emails/phones (the match/identity field is excluded from the update diff). Live-verified: a matched update kept both emails.
  • Upsert on a monetary/address custom field is refused (64) instead of silently duplicating every run.
  • Excel "CSV UTF-8" (BOM) imports work; non-integer org_id/owner_id cells rejected (65) instead of nulling the link.
  • changes --limit no longer skips rows sharing the cut second; --output csv no longer emits silently-blank rows (the changes watermark feed-loss).

Security

  • Transport sets redirect: 'manual' and refuses any 3xx (78) — the API token is never re-sent off the host-locked domain.

Exit-code ladder (BREAKING — observable codes)

  • network/timeout → 69 (was 70); 429-exhaust → 75 (was 69); conversion fail → 65 (was 70); failed OAuth refresh → 77 (was 65); malformed api --body65 (was 70). 70 is now internal-bug-only.
  • Aliased commands keep their real exit code (e.g. watch's 8) instead of collapsing to 1.

Machine output

  • Every delete/convert/import/bulk/backup honors --output; converts expose the new record id in JSON; --fields works for json/yaml.

Also

  • api reads piped stdin; TTY usage errors honor --output json; search --limit clamp; bulk-target validation; deal history --limit cost cap; digest flag guard.
  • chore: docs:commands clean-build, removed unused undici, 10 missing help topics, config tests isolated (PDCLI_CONFIG_DIR); exit-codes docs rewritten for the real contract.

See CHANGELOG for the full list. Deferred to v1.0: a set of LOW cosmetic items (funnel shape, date-grammar unification, etc.).

wavyx added 7 commits June 14, 2026 18:22
Cluster C of the v0.18 contract-hardening audit fixes:

- CRITICAL: CSV --upsert no longer deletes a matched record's extra
  emails/phones. The match (identity) field is now excluded from the
  UPDATE diff via stripMatchField, so re-asserting the single match
  email never narrows the record's email/phone set to one value.
- Matching --by a monetary/address custom field is refused (exit 64)
  instead of silently failing to match and creating a duplicate every
  run: v2 returns those as objects, which the scalar compare can't match.
- parseCsv strips a leading UTF-8 BOM so Excel 'CSV UTF-8' exports import
  cleanly instead of failing with a misleading 'missing name column'.
- Non-integer org_id/owner_id CSV cells are rejected (exit 65) instead of
  serializing as null and silently unlinking the relation (new intCell);
  prepareImportBodies now wraps special-column errors with the row number.
- Non-numeric values for numeric custom fields are rejected (exit 65)
  instead of writing null (input.js coerceValue).

1444 tests, 100% coverage.
…ross-host redirects

Cluster A + D of the v0.18 contract-hardening audit:

- Network unreachable / DNS failure / timeout now exits 69 (service
  unavailable), not 70 (internal bug): the fetch rejection is caught,
  retried like a 5xx, and surfaced as ServiceUnavailableError with the
  cause. The user-set --timeout no longer reports an 'internal bug'.
- SECURITY: transport sets redirect:'manual' and refuses any 3xx (exit
  78) so the x-api-token is never re-sent to a redirect target off the
  host-locked company domain (Node strips authorization cross-origin but
  not custom headers).
- Exhausted 429 retries now exit 75 (rate limited) with the last wait,
  not 69 'API is unavailable' — a sleep-and-retry script keys on 75.
- The alias hook maps oclif ExitError/CLIError exit codes (carried on
  err.oclif.exit, parse errors → 64) instead of collapsing every aliased
  failure to 1, and prints the human message that oclif's bypassed
  top-level handler would have shown.
- Failed/rejected deal & lead conversions exit 65 (bad data), not 70.
- A rejected OAuth refresh (invalid_grant 400 / invalid_client 401) exits
  77 with re-auth guidance, not 65; 5xx refresh failures stay 69.

Tests pin real oclif error shapes (not fabricated ones) and the corrected
429/network/redirect codes. 1453 tests, 100% coverage.
Cluster B of the v0.18 contract-hardening audit — the machine-output
contract that v1.0 will freeze:

- New BaseCommand.outputAction(machineObject, humanMessage): prints the
  human one-liner in interactive table mode, else emits the structured
  object through outputResults (honoring --output/--jq/--fields).
- Every delete/remove, both converts, deal bulk-update, person/org import,
  and backup now route their result through it instead of printing prose
  to stdout. `deal delete 42 --output json | jq` yields { id, deleted }
  instead of an unparseable string; the converts expose conversion_id and
  the NEW record id (deal_id/lead_id) in JSON — no more scraping prose.
  Per-row failure detail in bulk-update/import moves to stderr so a piped
  stdout stays a clean parseable summary.
- formatCsv with no explicit columns (the machine-format path passes {})
  now derives columns from the union of the rows' keys and JSON-encodes
  nested values, instead of returning a silently-blank header+rows — which
  in `changes --output csv` advanced the watermark over never-written rows
  (silent feed data loss).
- --fields now projects keys for json/yaml output, not only table/csv.

1485 tests, 100% coverage.
Cluster E of the v0.18 contract-hardening audit:

- api: the documented "pipe stdin" body now works (resolveBody is called for
  any body-bearing method, not gated on --body); malformed --body JSON exits
  65 (was an internal 70); the dead --body-required path exits 64 (was an
  off-ladder 2).
- errors: a usage/parse error in a TTY now honors an explicit --output by
  recovering it from argv (this.flags isn't populated when parsing fails),
  so --output json gets the JSON error envelope instead of a human message.
- changes --limit: no longer silently skips rows that share the cut row's
  update-time second — on truncation it drops the trailing same-second rows
  so they replay next run, and warns (never silently loses) when one second
  exceeds --limit.
- search: clamps a scoped per-entity --limit to 100 (the live API 400s above
  it) instead of surfacing the 400 as exit 65; corrects the stale comment.
- bulk targets: blank/empty segments no longer become a phantom id 0; an
  empty target set exits 64; malformed piped JSON exits 65; entries without
  an integer id are rejected.
- deal history --limit now caps collection (passes the limit to collectPages)
  instead of walking the whole changelog at 20 tokens/page.
- digest rejects an explicit --output combined with --format (exit 64).

1500 tests, 100% coverage.
…ing help topics, isolate config tests

Cluster F (build/hygiene) of the v0.18 audit:

- docs:commands now runs 'oclif manifest' first, so it works from a clean
  checkout instead of ENOENT-ing on the gitignored manifest.
- Removed the 'undici' dependency: it was declared and pinned but never
  imported (the client uses Node's global fetch).
- Added the 10 missing oclif.topics entries (file, filter, goal, lead, note,
  pipeline, product, project, stage, webhook) so 'pdcli --help' shows real
  topic summaries instead of a random subcommand's description.
- config tests now isolate the conf store to a temp dir via PDCLI_CONFIG_DIR
  (a new override hook) so a test run never reads or mutates the user's real
  pdcli profiles.
- exit-codes.mdx: corrected to the actual ladder (parse errors 64, network/
  timeout 69, 429-exhaust 75, conversion-fail 65, refresh-fail 77, redirect
  refusal 78) and the error-output-mirrors-success-format rule; documents
  watch's exit 8. It previously described the pre-0.17 behavior.
- Fixed two stale facts: scoped vs itemSearch both cost 20 tokens (not 20 vs
  40); the daily-budget message says 'token budget', not 'request budget'.
- Regenerated the command reference for the 10 new help-topic descriptions.
- Bumped version to 0.18.0.
Adversarial review of the v0.18 diff confirmed the fixes are correct (no
functional regressions); these address the papercuts it surfaced:

- upsert stripMatchField no longer over-strips: it drops the match field on
  update ONLY when the body holds just the single injected match value (the
  CSV path). A multi-value emails/phones body is an explicit full set and is
  written as-is; scalar/custom matches are left to diffBody (which already
  drops equal values and allows a genuine rename) — so matching by a field no
  longer blocks editing it.
- lookup: the not-searchable error no longer lists 'address' as a valid type
  (it was removed from SEARCHABLE_TYPES in v0.18).
- changes: the 'splits a single second' warning no longer fires spuriously when
  --limit truncation lands on null-update_time rows (they can't be resumed by
  updated_since anyway); corrected the secondOf comment.

1503 tests, 100% coverage.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@wavyx wavyx merged commit ba2b889 into main Jun 15, 2026
11 checks passed
@wavyx wavyx deleted the feat/v0.18-contract-hardening-2 branch June 15, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants