Skip to content

fix(wire-shape): close 4 review-surfaced gaps from #182 + #184#185

Merged
saurabhjain1592 merged 8 commits into
mainfrom
fix/wire-shape-review-followups
Apr 25, 2026
Merged

fix(wire-shape): close 4 review-surfaced gaps from #182 + #184#185
saurabhjain1592 merged 8 commits into
mainfrom
fix/wire-shape-review-followups

Conversation

@saurabhjain1592

Copy link
Copy Markdown
Member

Summary

Follow-up to merged #182 (manifest version-alignment) and #184 (QF-12 wire-shape contract). Deep code review on both PRs surfaced four real gaps; this PR closes all four.

CI-internal hardenings. No user-visible behaviour change.

1. `refresh.js` — `--sha` with no value silently dropped

`node refresh.js /tmp/specs --sha` used to silently consume `--sha` as the positional `specsDir` (then fail later with a confusing "is not a directory" error), masking the missing argument. Now exits 2 with `error: --sha requires a value`.

2. `validate.js` — empty `registered_types` silently disables Gate 4

The previous `if (baseline.registered_types.length > 0)` meant an emptied list (whether by bug, malicious edit, or a refresh.js regression) would silently turn off the rename-escape guard. The validator now distinguishes:

  • Genuinely fresh baseline (every section empty) — Gate 4 still skipped because there's nothing to guard.
  • Corrupted baseline (empty `registered_types` but populated `per_type_drift` / `cross_spec_duplicates` / `intra_file_duplicates`) — Gate 4 fails the run with an explanatory message pointing the operator at `refresh.js`.

3. `lib.js writeBaseline` — `.tmp.` sidecar leak on crash

A failure between `writeFileSync(tmp, ...)` and `renameSync(tmp, BASELINE_PATH)` used to leave the partial tempfile behind. The next writer with a recycled PID would collide. Now wraps in try/catch + `unlinkSync` on throw.

4. `lib.js extractInterfaceProps` — `interface X extends Y` silently under-reports

Walking only `iface.members` misses members inherited via heritage clauses. The TS SDK doesn't currently have any extending wire interfaces, so this is forward-defence — when the first one lands, the validator now `console.warn`s loudly with a pointer at the function that needs to plumb in heritage walking. Better than silent under-counting.

Verifications (local)

  • `refresh.js` with `--sha` as the last token exits 2 with the new message; `--sha foo` still works.
  • `validate.js` against a manually corrupted baseline (`registered_types: []` plus existing drift entries) exits 1 with the new error.
  • `writeBaseline` synthetic crash leaves no `.tmp.` sidecar.
  • Real baseline at SHA `bf1ca22` regenerates byte-identical against `origin/main`'s baseline.
  • Validator passes against the unmodified baseline.

Out of scope (deliberately)

  • The `spec-pin-bump` label authorising both an SHA bump AND simultaneous `src/**/*.ts` changes — the design is that the label is a reviewer-applied signal that the PR has been audited end-to-end; further automation would over-restrict legitimate `refresh.js`-from-spec-bump PRs that update both `openapi_specs_sha` and `per_type_drift`.
  • Cross-SDK `_comment` / key-order alignment between TS and Java baselines — would invalidate already-shipped baselines on both sides for cosmetic gain.
  • YAML merge keys (`<<:`) and aliases — no spec uses them today; documented as a known limitation in the comparable Python/Go ports.

Test plan

  • `refresh.js --sha` (bare) exits 2
  • `validate.js` against corrupted baseline exits 1
  • `validate.js` against good baseline exits 0
  • Re-running `refresh.js` produces byte-identical baseline
  • CI green on this PR

saurabhjain1592 added a commit that referenced this pull request Apr 25, 2026
The wire-shape contract gate verifies that SDK type definitions
match the OpenAPI spec. It does NOT verify that the runtime code
(transformers, response decoders, request builders) actually
propagates every field from the type definition. PR #185's
post-merge audit found multiple bugs of this shape: a type gained
a wire-canonical field, but the hand-rolled transformer's return
literal didn't list the new key, so callers read `undefined`
even though the type said the field was there.

This gate closes that gap. For each method in `src/client.ts`
whose declared return type is one of a configured wire-bound set,
it parses the method body via the TypeScript Compiler API and:

  - PASSTHROUGH (safe): `return this.orchestratorRequest<T>(…)` or
    any typed-cast call returns whatever the wire emits; new
    fields on T flow through naturally.
  - LITERAL RETURN: `return { id: x, name: y, … };` — checks every
    non-@deprecated property of T appears as a key in the literal.
    Missing keys are GAPS.
  - HELPER CALL: `return this.parseFoo(…)` — checked at the
    helper's definition site (recursive).
  - CONDITIONAL: ternary branches checked independently.
  - DYNAMIC: variable returns are not flagged (Phase 1 limitation).

Properties marked @deprecated via JSDoc are skipped — they're
intentionally not populated. Wire-shape coverage requires every
non-@deprecated property to flow through the transformer.

Baseline mechanism mirrors the wire-shape contract gate and the
falsey-clobber lint:

  .lint_baselines/transformer_coverage.json captures the 3
  pre-existing gaps on origin/main (executePlan + getPlanStatus
  on PlanExecutionResponse.policyInfo, resumePlan on 5
  ResumePlanResponse fields). CI fails on any finding NOT in the
  baseline. Burndown via targeted PRs.

Wired into the existing wire-shape-contract.yml workflow as a
new step alongside the wire-shape validator, so it runs on every
PR that touches src/**/*.ts.

Validation:

- On origin/main: 3 findings (the pre-existing gaps), all
  baselined — gate exits 0.
- Inserting a new gap (e.g. dropping `success` from cancelPlan's
  return literal): gate exits 1 with file:line and the missing
  keys. Verified in the PR #185 post-fix worktree.

Phase 1 covers READ-path object-literal returns. Phase 2 will
extend to write-path (request body builders) and helper-call
recursion.

Cross-SDK status:
- Python equivalent: planned next; pydantic's
  `Type.model_validate(...)` and `request.model_dump()` cover
  the typical paths but hand-rolled dict-builders need the
  same gate.
- Go: not needed — struct tags + json.Marshal/Decode are
  declarative; new fields with `,omitempty` propagate both
  directions automatically. Documented in the rationale doc
  shipping alongside this gate's Python port.
- Java: not needed — Jackson @JsonProperty annotations on
  @JsonCreator constructors auto-propagate. Same rationale.
CI-internal hardenings; no user-visible behaviour change.

- refresh.js: `--sha` with no value used to be silently consumed as
  the positional `specsDir`, masking the missing argument and routing
  the script through the gitHeadSHA fallback. Now exits 2 with a
  clear message.
- validate.js: an empty `registered_types` paired with non-empty
  cross_spec / intra_file / per_type_drift entries used to silently
  disable Gate 4 (rename-escape). The validator now treats this
  combination as a baseline corruption and fails the run. A genuinely
  fresh baseline (every section empty) still skips the gate as
  before, since the first-pin run has nothing to guard.
- lib.js writeBaseline: a crash between writeFileSync(tmp, ...) and
  renameSync(tmp, BASELINE_PATH) used to leave a `.tmp.<pid>`
  sidecar, which a future writer with the recycled PID would
  collide on. Now wraps in try/catch + unlinkSync on any throw.
- lib.js extractInterfaceProps: an `interface X extends Y` would
  silently under-report fields because we walk only iface.members.
  No such interface ships in the SDK today; the gate now warns
  loudly the first time one appears so coverage gets resolved
  before drift can hide. Full heritage walking is the proper fix
  if this becomes a routine pattern.

Verifications:

- refresh.js with `--sha` as last token exits 2; with `--sha foo` works.
- validate.js with corrupted baseline (empty registered_types but
  populated drift/cross_spec) exits 1 with explanatory message.
- writeBaseline temp-file is cleaned up on synthetic write error.
- Real baseline regenerates byte-identical to origin/main.
Gate 1 (cross-spec divergence) only iterated currently observed
schemas. A baselined divergence that the platform has since
reconciled silently lingered in the baseline forever; the same old
incompatible shape could be reintroduced and pass the gate because
its fingerprint matched a stale entry that should have been deleted.

Adds the reverse pass that Gate 2 already does for intra-file
duplicates: any baselined cross-spec name that is no longer
observed in the current specs fails the run with a pointer at the
specific baseline key to delete.

Mirrors the same fix landing on the Java arm in PR #137.

Verified locally:
- Positive run on clean baseline still exits 0.
- Adding a phantom 'PhantomDivergence' entry to baseline
  cross_spec_duplicates causes the validator to exit 1 with a clear
  remove-from-baseline message naming the stale key.
The accidental git add -A in the prior commit swept up a
session-export .txt file that lives in this worktree. Removing.
The TS validator was extracting in-memory property names (camelCase
by TS idiom) and diffing them directly against OpenAPI snake_case
wire names, generating ~22 false-positive drift entries (Category A
in the audit) where the SDK's hand-rolled transformer in
client.ts already correctly bridged the two names. The other three
SDK validators don't have this issue: pydantic alias for Python,
`json:"…"` struct tag for Go, `@JsonProperty(…)` for Java — each
extracts the WIRE name natively. TS has no first-class equivalent.

This change adds equivalent wire-name extraction for TS:

- toSnakeCase() converts camelCase to snake_case with three boundaries
  (acronym→word, lowercase→uppercase, lowercase→digit). The digit
  boundary fixes ModelPricing.inputPer1k → input_per_1k, which the
  earlier categorization regex got wrong.
- loadTransformerEvidence() reads src/client.ts once and collects
  every snake_case-shaped identifier. This is the bridge audit: a
  TS field is treated as 'bridged to its snake_case wire form' only
  when the snake form actually appears somewhere in client.ts.
- canonicalizeWireNames() applies the conversion per-field. If the
  snake form is found in client.ts, the field is normalized; if not,
  the camelCase form is kept and the type is recorded as having
  unbridged fields.
- The safety net: a type's unbridged fields are warned about ONLY
  when the same type has at least one OTHER field that IS bridged.
  If no field in the type is bridged, the type is almost certainly
  SDK-internal (configs, options, adapter metadata, etc.) and the
  whole type isn't wire-bound; warning would be noise.

Effect: 22 Category A drift entries disappear from the baseline
(AuditToolCallRequest, BudgetCheckRequest, FEATAssessment,
KillSwitch, ModelPricing, PlanVersionEntry, PolicyMatch, etc.).
63 → 41 drift entries. The 19 unbridged-field warnings the
validator now emits all line up with audit-flagged broken/orphan
reads in client.ts (PolicyEvaluationResult.databaseAccessed,
StaticPolicy.hasOverride, StepGateResponse.policiesMatched, etc.) —
the validator is now correctly distinguishing 'bridged drift' from
'genuinely missing transformer'.

No SDK property names changed. Wire behavior is unchanged. This is
purely a validator measurement fix; the SDK's existing transformer
architecture is preserved as-is.

Per platform-side spec corrections needed (filed separately):
- AISystemRegistry.materiality_classification (#1708)
- DynamicPolicyInfo schema completely wrong (#1709)
…VERABLE)

Audit-driven cleanup against the wire-shape contract gate. All
changes are additive (new fields are optional). Where a TS field
was reading `undefined` because the decoder is JSON.parse passthrough
and the wire emits a different key, the wire-canonical name is added
and the orphan field is marked `@deprecated` (kept for compile-time
compat; removed in v7).

Per-type changes (full detail in CHANGELOG):

Pure additions (Cat B):
- WebhookSubscription.{secret, org_id, tenant_id} — security-critical;
  `secret` is the HMAC-SHA256 signing key required to verify inbound
  webhook payload signatures.
- StepGateRequest.{cost_usd, tokens_in, tokens_out}
- StepGateResponse.decision_id
- ListWorkflowsResponse.{limit, offset}
- StaticPolicy.{policy_id, priority, has_override}
- CreateStaticPolicyRequest.{priority, tags}
- UpdateStaticPolicyRequest.{priority, tags}
- UpdatePlanRequest.metadata
- UsageBreakdownItem.group_by
- BudgetAlert.acknowledged
- Budget.{org_id, tenant_id}
- WorkflowStatusResponse.metadata
- ExecutionSnapshot.retryCount
- Finding.article

RENAME_SAFE (orphan reads → wire-aligned canonical, old marked @deprecated):
- StepGateResponse.policies_evaluated/policies_matched (snake) +
  policiesEvaluated/policiesMatched @deprecated
- StaticPolicy.has_override (canonical) + hasOverride @deprecated
- DynamicPolicyMatch.message + reason @deprecated
- ExfiltrationCheckInfo.{exceeded, limit_type} + within_limits @deprecated
- PolicyOverride.{id, enabled_override} + active @deprecated
- PolicyVersion.{id, policy_id, change_summary, snapshot} +
  {changeDescription, previousValues, newValues} @deprecated
- CancelPlanResponse.success + message @deprecated
- CreateWorkflowResponse.started_at + {created_at, source} @deprecated
- UsageRecord.{created_at, success, error_message, latency_ms,
  team_id, tenant_id, user_id, workflow_id} + timestamp @deprecated
- ResumePlanResponse.result + 5 declared-but-never-populated fields
  marked @deprecated (workflowId, message, stepResult, nextStep,
  nextStepName, totalSteps)
- PolicyEvaluationResult.{required_actions, processing_time_ms,
  database_accessed} (snake) + camelCase forms @deprecated

UNRECOVERABLE realignments (no decoder; safe to add wire shape):
- EffectivePoliciesResponse: tier-stratified wire shape
  ({static, dynamic, tenant_id, organization_id, computed_at})
- Policy: rich wire shape (policy_id, category, tier, pattern,
  severity, action, actions, conditions, description,
  organization_id, tenant_id, created_at/_by, updated_at/_by,
  version) — was exposing only 5 of 21 wire fields
- PlanResponse: wire top-level fields (success, version, result,
  error, workflow_execution_id, policy_info)

Transformer updates:
- BudgetAlert decoder propagates acknowledged
- UsageBreakdownItem decoder propagates groupBy
- ExecutionSnapshot decoder propagates retryCount

Wire-shape baseline regenerated:
- 41 → 34 drift entries
- All Cat B types fully resolved (RESOLVED ✓)
- Remaining entries are all SDK-only fields (mostly @deprecated
  aliases retained for type-compat + Plugin Batch 1 SDK additions
  pending platform-side spec coverage)

Filed alongside (no SDK change needed; spec is wrong):
- axonflow-enterprise#1708 — AISystemRegistry.materiality_classification
- axonflow-enterprise#1709 — DynamicPolicyInfo schema wrong shape

Tests: 859 pass. Lint clean.
Companion to the type-definition sweep in this PR. The type updates
added wire-canonical fields, but the hand-rolled transformers in
client.ts weren't updated in parallel — so the fields were unreachable
at runtime even though TypeScript types said they existed.

The 5 reported gaps + 4 systematic-audit-found gaps = 9 fixes:

Read-path fixes (decoders that dropped new wire fields):

- generatePlan: PlanResponse now carries the wire top-level fields
  (success, version, result, error, workflow_execution_id,
  policy_info), sourced from agentResponse.* with data.* fallback.
- resumePlan: ResumePlanResponse.result now populated from
  data.result. The deprecated `message` alias remains populated for
  back-compat.
- getStaticPolicyVersions: inline-typed wire shape now declares
  the canonical fields (id, policy_id, change_summary, snapshot)
  alongside the legacy `change_description`/`previous_values`/
  `new_values` keys; the transformer emits both halves.
- listUsageRecords: UsageRecord transformer now emits the 8 new
  canonical fields (created_at, success, error_message, latency_ms,
  team_id, tenant_id, user_id, workflow_id). Legacy `timestamp`
  still emitted (it has always been undefined; both kept for source
  compat).
- getExecution: inline step shape now declares retry_count, and
  the per-step mapper emits retryCount. (getExecutionSteps was
  already correct; only this method was out of sync.)
- mapBudgetResponse: emits tenant_id, org_id (used by every Budget-
  returning method).
- mapFindingResponse: emits article (used by mapAssessmentResponse).

Write-path fixes (request builders that dropped new SDK fields):

- updatePlan: body now includes metadata when set on the request.
- createStaticPolicy: requestBody now includes priority, tags when
  set on the request.

Verified clean (no gap): updateStaticPolicy (passthrough; field
names match wire), stepGate (passthrough; new tokens_in/tokens_out/
cost_usd field names match wire), every method that flows through
orchestratorRequest<T>/policyRequest<T> with a typed cast (the JSON
parses naturally into the typed shape, so new fields appear without
explicit transformer code).

Meta-pattern observed: any method with a hand-rolled mapper helper
or an explicit return-literal builder is a candidate gap when types
gain fields. Methods that pass through to orchestratorRequest<T>
inherit the new fields for free. The audit covered every type
touched in the sweep across both directions.

Tests: 859 pass. Lint clean.
Same class as the 5 transformer gaps fixed in commit 2fac16f. The
type-update added CancelPlanResponse.success but the cancelPlan()
method's return literal didn't include it. The deprecated `message`
slot is also kept populated so existing callers that read it see
no behavior change (it has always been undefined against current
servers, but zero-clobber).

The systematic audit clearly missed cancelPlan in the previous
round. The expanded sweep below documents every type/method pair
to keep the contract honest:

Read-path verifications (every method that returns a touched type):
- BudgetAlert via getBudgetAlerts: acknowledged emitted ✓ (round 2)
- UsageBreakdownItem via getUsageBreakdown: groupBy emitted ✓ (round 2)
- ExecutionSnapshot via getExecutionSteps + getExecution:
  retryCount emitted ✓ (round 2)
- PolicyEvaluationResult: embedded in PlanResponse.policy_info
  populated by generatePlan ✓ (round 2)
- PlanResponse via generatePlan: 6 wire top-level fields ✓ (round 2)
- CancelPlanResponse via cancelPlan: success now emitted (this commit)
- ResumePlanResponse via resumePlan: result emitted ✓ (round 2)
- StaticPolicy via list/get/create/update/toggle/getEffective:
  passthrough through policyRequest<T> ✓
- PolicyOverride via createPolicyOverride/listPolicyOverrides:
  passthrough through policyRequest<T> ✓
- PolicyVersion via getStaticPolicyVersions: id, policy_id,
  change_summary, snapshot emitted ✓ (round 2)
- WorkflowStatusResponse via getWorkflow: passthrough ✓
- ListWorkflowsResponse via listWorkflows: passthrough ✓
- WebhookSubscription via createWebhook/getWebhook/updateWebhook:
  passthrough ✓
- ExfiltrationCheckInfo, DynamicPolicyMatch: embedded in MCP
  response shapes; passthrough ✓
- Finding via mapFindingResponse: article emitted ✓ (round 2)
- Budget via mapBudgetResponse (used by createBudget/getBudget/
  listBudgets/updateBudget/checkBudget/getBudgetStatus):
  tenant_id + org_id emitted ✓ (round 2)
- UsageRecord via listUsageRecords: 8 wire fields emitted ✓ (round 2)
- StepGateResponse via stepGate: passthrough; decision_id +
  policies_evaluated + policies_matched flow through ✓
- CreateWorkflowResponse via createWorkflow: passthrough; started_at
  flows through ✓

Write-path verifications (every method that accepts a touched
request type):
- StepGateRequest via stepGate: JSON.stringify passthrough; new
  cost_usd / tokens_in / tokens_out names match wire ✓
- UpdatePlanRequest via updatePlan: metadata in body builder ✓
  (round 2)
- CreateStaticPolicyRequest via createStaticPolicy: priority + tags
  in body builder ✓ (round 2)
- UpdateStaticPolicyRequest via updateStaticPolicy: passthrough;
  field names match wire ✓

Falsey-clobber audit: 0 || patterns introduced in this PR's
client.ts diff (verified with git diff grep). The transformer code
uses ?? semantics or explicit guards.

Tests: 859 pass. Lint clean.
…wn resumePlan

PR #185's transformer fix populated all 5 fields previously missing on
ResumePlanResponse (nextStep, nextStepName, stepResult, totalSteps,
workflowId). Drop the stale baseline entry; remaining entries are
executePlan and getPlanStatus on PolicyEvaluationResult, untouched
this round.
@saurabhjain1592 saurabhjain1592 force-pushed the fix/wire-shape-review-followups branch from df3ed2b to 7f400b9 Compare April 25, 2026 12:09
@saurabhjain1592 saurabhjain1592 merged commit c88c871 into main Apr 25, 2026
10 checks passed
@saurabhjain1592 saurabhjain1592 deleted the fix/wire-shape-review-followups branch April 25, 2026 12:11
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.

1 participant