Skip to content

wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold) - #179

Open
mjerris wants to merge 103 commits into
mainfrom
wave6/ctor-dunder-fold
Open

mjerris wants to merge 103 commits into
mainfrom
wave6/ctor-dunder-fold

Conversation

@mjerris

@mjerris mjerris commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

Retires the PORT_SIGNATURE_OMISSIONS.md entries the shared-diff ctor/dunder fold
makes dead, per ALLOWLIST_DISCIPLINE.md:495:

ctor / dunder (__init__ as a member, __repr__, __enter__) → EMISSION (exclude).
Never a surface capability difference. Excluded unless the reference records the same
dunder on that class.

This is a deletion-only pass. No source change, no new omission/addition/allow-list
entry, and PORT_OMISSIONS.md / PORT_ADDITIONS.md are untouched (different tool, hard
dead-entry gate).

Counts

Measured with the gate's own parse_omissions against
porting-sdk/python_signatures.json:

metric before after
PORT_SIGNATURE_OMISSIONS.md entries 249 208
__init__ entries 59 18
non-__init__ dunder entries 0 0
excused divergences (DRIFT gate) 642 642

249 is the count on origin/main. (The Wave-6 dispatch table cited 247 — that was
measured on the sibling wave6/ledger-burndown branch, which had already removed 2
logger entries. The 41-entry ctor target is identical either way.)

The excused-divergence count is UNCHANGED at 642 across a 41-entry deletion. That is
the direct proof the entries were dead paperwork rather than load-bearing: the fold skips
those symbols before the ledger is consulted, so had any entry been excusing a real
divergence, deleting it would have surfaced one.

The construction node is unchanged

__init__-as-a-member and the §10 construction node are different contracts, and
only the former folds. port_signatures.json:

before after fresh re-enumeration
construction classes 150 150 150
construction ctor params 536 536 536

All 41 classes remain compared by name in compare_construction. That is the
comparison that is actually meaningful for a TS constructor — the member comparison
matches params by POSITION, which says nothing useful when a Python wide-kwargs __init__
is held against a TS options-object constructor.

__init__ entries the rule does NOT cover — 18 RETAINED

These classes have no construction entry, so the member comparison is the only
comparison there is. Folding them would trade a visible ledger entry for a real blind
spot, which the rules forbid — they stay:

  • signalwire.core.pom_builder.PomBuilder.__init__
  • signalwire.rest._base.CrudResource.__init__
  • signalwire.rest._base.CrudWithAddresses.__init__
  • signalwire.rest._base.ReadResource.__init__
  • signalwire.livewire.* (14): Agent, AgentServer, AgentSession, CartesiaTTS,
    DeepgramSTT, ElevenLabsTTS, LLM, OpenAILLM, RunContext, STT, SileroVAD,
    StopResponse, TTS, ToolError

Gate output actually seen

DRIFT — the real gate path (drift.sh, which supplies --numeric-monotype from this
repo's committed .drift-numeric-monotype marker; TS is a single-numeric-type language,
and without that flag the invocation reports ~20 spurious int vs float findings):

$ bash ~/src/porting-sdk/scripts/drift.sh .
==> diff vs python oracle (--numeric-monotype)
✓ signatures match (1528 reference symbols, 1857 port symbols, 642 excused divergences).
==> DRIFT exit=0

Identical result with a fresh re-enumeration (drift.sh . npx tsx scripts/enumerate-signatures.ts) — exit 0, 642 excused.

Full CI:

$ bash scripts/run-ci.sh
[SURFACE] surface parity suite (SIGNATURES/DRIFT/SURFACE-FRESH/SURFACE-DIFF/SEMVER-DIFF/GEN-TYPE-DEGENERACY/GEN-IDIOM) ... PASS
[GEN] generated-code freshness suite (GEN-FRESH/-TESTS/-RELAY/-SWAIG/-SWML) ... PASS
[FMT] scripts/run-format.sh ... PASS
[TEST] scripts/run-tests.sh (vitest) ... PASS
[LINT] scripts/run-lint.sh (tsc src+examples+tests + eslint) ... PASS
[BEHAVIORAL] behavioral suite (...) ... PASS
==> CI PASS

Exit 0. All 22 tier=pr gates PASS.

Two findings, both pre-existing and out of scope here

  1. Stale rationale on a deleted entry. The removed
    signalwire.agents.bedrock.BedrockAgent.__init__ entry read "reference signatures
    oracle records no BedrockAgent class"
    . That is no longer true — the oracle's
    construction node records BedrockAgent with 7 params (max_tokens, name, route,
    system_prompt, temperature, top_p, voice_id). The ctor IS compared, so the
    entry was doubly dead. (The sibling BedrockAgent.set_prompt_llm_params /
    set_post_prompt_llm_params entries carry the same stale claim and are still present —
    worth a look, but they are method entries and outside this fold.)

  2. port_signatures.json is stale on main. A fresh
    npx tsx scripts/enumerate-signatures.ts on clean origin/main produces an 11-line
    diff: the Device union gained class:signalwire.relay.types.FabricDevice. Introduced
    by 1a15395 (merged as Add typed fabric device to Relay connect (#19964) #166, rpc/apex-19964-fabric-connect-device), which changed
    src/relay/types.ts without re-committing the artifact. It does not turn CI red —
    SIGNATURES regenerates the file and DRIFT consumes the fresh copy, while SURFACE-FRESH
    byte-compares only port_surface.json — so the drift is invisible to the gates.
    Deliberately NOT included in this PR, which is a ledger-only diff; it wants its own
    regen commit.

Merge order: porting-sdk #125 FIRST. Until it merges (or PORTING_SDK_REF is pinned),
this PR's CI is red BY DESIGN — the fold and the prune are mutually dependent.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf

Coordinated-With: porting-sdk@wave6/ctor-dunder-fold

mjerris and others added 30 commits July 27, 2026 01:12
…m fold)

Deletes the two `PORT_SIGNATURE_OMISSIONS.md` entries whose member is exactly
`logger`. Both excused a symbol that neither side emits any more, so they were
dead paperwork:

  - signalwire.core.skill_base.SkillBase.logger
  - signalwire.agent_server.AgentServer.logger

Counts (committed tree, measured with the gate's own `parse_omissions`):
PORT_SIGNATURE_OMISSIONS.md 249 -> 247 entries; `logger`-member entries 2 -> 0.

Why each removal is correct (not merely dead): owner ruling 2026-07-24, recorded
in ALLOWLIST_DISCIPLINE.md §8 and implemented at
porting-sdk/scripts/enumerate_python.py:365 (`_LOGGER_FACTORY_RETURN`) —
logging is a MODULE-LEVEL capability a port may reach however its language does.
The per-instance `logger` attribute was Python's structlog idiom leaking into the
enumerated surface; it is not contract. The reference exclusion keys on the
logging factory's RETURN TYPE rather than a hand-typed symbol list, so it cannot
drift as classes are renamed.

Verified dead on BOTH sides, not just the reference:
  - oracle: `query_signatures.py python_signatures.json search logger` returns
    only `modules.signalwire.core.logging_config.functions.get_logger` — the
    module-level factory, no class-attribute `logger`.
  - port: the same query over `port_signatures.json` returns the same single
    hit; the TS port emits no `.logger` member either.

The capability itself is unchanged and still satisfied by the 5 module-level
free functions the oracle records (get_logger, configure_logging,
get_execution_mode, reset_logging_configuration, strip_control_chars), all of
which the TS port implements. Nothing in `src/` changes.

The excused-divergence count is UNCHANGED at 684 across the deletion, which is
the direct proof the two entries were excusing nothing.

No new omission/addition/allow-list entry was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
…ff fold)

Deletes the 41 `PORT_SIGNATURE_OMISSIONS.md` entries whose member is `__init__`
and whose class the reference's `construction` node covers. The shared-diff fold
(`_is_folded_dunder_member`, porting-sdk #125) skips those symbols before the
ledger is ever consulted, so each entry was excusing nothing.

Counts (measured with the gate's own `parse_omissions`):
  PORT_SIGNATURE_OMISSIONS.md   249 -> 208 entries
  `__init__` entries             59 ->  18
  excused divergences            642 -> 642  (UNCHANGED)

The unchanged excused count is the direct proof the 41 were dead paperwork: had
any been load-bearing, deleting it would have surfaced a live divergence.

The construction contract is untouched. `port_signatures.json` still records 150
construction classes / 536 ctor params before and after — `__init__`-as-a-member
and the §10 construction node are different contracts, and only the former is
folded. Every one of the 41 classes remains compared by NAME in
`compare_construction`, which is the meaningful comparison for a ctor (the member
comparison matches params by POSITION, which is meaningless against a TS
options-object constructor).

18 `__init__` entries the rule does NOT cover are deliberately RETAINED, because
their classes have no `construction` entry — the member comparison is the only
one there is, so folding them would trade a visible ledger entry for a real blind
spot:
  signalwire.core.pom_builder.PomBuilder.__init__
  signalwire.rest._base.{CrudResource,CrudWithAddresses,ReadResource}.__init__
  signalwire.livewire.* (14: Agent, AgentServer, AgentSession, CartesiaTTS,
    DeepgramSTT, ElevenLabsTTS, LLM, OpenAILLM, RunContext, STT, SileroVAD,
    StopResponse, TTS, ToolError)

Stale rationale found while auditing: the deleted
`signalwire.agents.bedrock.BedrockAgent.__init__` entry claimed "reference
signatures oracle records no BedrockAgent class". That is no longer true — the
oracle's construction node records BedrockAgent with 7 params (max_tokens, name,
route, system_prompt, temperature, top_p, voice_id), so the ctor IS compared and
the entry was doubly dead.

No source change. No new omission/addition/allow-list entry — this is a deletion
pass. `PORT_OMISSIONS.md` and `PORT_ADDITIONS.md` are untouched (a different tool
with a hard dead-entry gate).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
… signatures

The oracle now records 7 DERIVED public `__init__` attributes that are
caller-observable VALUES (porting-sdk d7c859d, ALLOWLIST_DISCIPLINE class B2).
Six of the seven were ALREADY implemented in this port and only needed the
signature artifact regenerated so the enumerator's reference-driven
field-accessor rule would project them:

  SignalWireRestError.request_id  -> RestError.requestId   (src/rest/RestError.ts)
  SWMLService.ssl_enabled         -> SWMLService.sslEnabled
  SWMLService.ssl_cert_path       -> SWMLService.sslCertPath
  SWMLService.ssl_key_path        -> SWMLService.sslKeyPath
  SWMLService.domain              -> SWMLService.domain
  Action.completed                -> Action.completed      (src/relay/Action.ts)

The seventh, `SpiderSkill.remove_xpaths`, existed only as a hardcoded literal
inside `_fastTextExtract`, so the value was not caller-observable. Promoted it
to a public prefilled `readonly removeXpaths: string[]` carrying the reference's
exact XPath spelling, and drove the strip loop off the field. Cheerio takes CSS,
so `removeTagFor()` maps the `//tag` descendant-axis form to a tag-name selector
and passes anything else through.

Two tests added: one mirroring python's `test_remove_xpaths_populated` (the list
is prefilled, not empty), and one behavioral proof that the field is load-bearing
— dropping `//aside` from it leaves aside text in the extracted output.

DRIFT: 29 -> 22. All 22 remaining are the pre-existing `RestClient.<resource>`
accessor drift from a separate task; zero derived-attr drifts remain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… served plain HTTP

MY ORIGINAL DIAGNOSIS WAS WRONG and the fix it prescribed would NOT have fixed this.
I inferred from `new SslConfig()` (no arguments) that TS had no config-file layer at
all. It did: SecurityConfig.loadFromConfigFile, wired from the ctor. SslConfig is the
env-defaults object; the config file is applied over it afterward. Adding "the missing
layer" would have left the real defect untouched.

THE REAL DEFECT (reproduced live), three causes:

1. KEY-SHAPE MISMATCH — load-bearing. TS read `security.ssl.{enabled,certPath,keyPath}`;
   the reference and every other port use FLAT snake_case `security.{ssl_enabled,
   ssl_cert_path,ssl_key_path}` (signalwire-python core/security_config.py:123-127).
   An operator writing the documented shape hit a branch that never matched — plain
   HTTP, no error, no warning. That camelCase shape appears in no doc, test or example:
   dead-on-arrival code, not TS idiom.
2. NO DISCOVERY — the reference falls back to find_config_file(service_name); TS only
   honoured an explicitly-passed path.
3. STALE BACKING OBJECT — sslConfig was never rebuilt, so isConfigured() /
   validateSslConfig() / getSslContextKwargs() kept reading env paths regardless.

Also aligned to the reference while here: getSection() so ${VAR} interpolation applies,
the non-SSL keys (allowed_hosts, cors_origins, use_hsts, hsts_max_age), and the auth
shape (`security.basicAuth.*` -> reference `security.auth.basic.*`), which was wrong
the same way.

VERIFIED BEHAVIOURALLY, NOT STRUCTURALLY: tests/SecurityConfig.configFileTls.test.ts
starts a real service from a config file with NOTHING in the environment, completes a
real TLS handshake against its socket, and asserts a plaintext client is rejected on
that same port. 7 tests pass.

MUTATION-TESTED (re-run independently by the orchestrator): with the fix stashed,
6 failed / 1 passed — and the HTTPS case fails as the SECURITY BUG itself, not an
assertion mismatch ("SSL routines:tls_validate_record_header:wrong version number"
against a server that logged "starting on http://"). The 7th is the negative control
(plain HTTP when nothing enables SSL) and passes both ways by design.

Adds ZERO public surface (verified by diffing fresh enumerations with and without).
No omission/addition/allow-list entry.

Pre-existing, NOT from this change (identical on clean HEAD): SURFACE (DRIFT,
SURFACE-FRESH, SURFACE-DIFF) and BEHAVIORAL (SECURE-DEFAULT). SURFACE-FRESH is stale
port_surface.json from becf955, which added the SWMLService TLS attrs without
regenerating the artifact.

Refs #90
…four commits behind

The committed artifact carried generated_from '@ 34d8804' (the pre-wave consolidation
SHA), not the wave tip. A plain regen at the tip emits all 7 derived attrs; the
enumerator needed ZERO changes.

ORCHESTRATOR CORRECTION: I told this lane a regen produced a byte-identical file and
implied the enumerator needed new collection logic. That measurement was mine and it
was WRONG — I ran the regen before the wave-tip commit existed, then re-checked after
committing it. The lane instrumented the emission pass and proved every target member
was already in both the AST-collected set and the reference set. ruby hit the identical
stale-artifact cause independently.

Non-RestClient SURFACE-DIFF gaps: 7 -> 0. Total 29 -> 22 (the pre-existing RestClient
set). SIGNATURE gate unchanged (byte-identical drift list vs HEAD). Tests 2824 pass,
FMT/LINT exit 0.
Both TS enumerators walked only a class's OWN members, so every member
declared on a private base was recorded nowhere. `RestClient extends
_GeneratedResourceTree`, and that generated base declares all 22 REST
resource-tree accessors -- `calling`, `fabric`, `video`, every flat resource
and every namespace container. The private base is skipped as an
implementation detail (mirroring griffe's treatment of Python's
`_GeneratedResourceTree`), and nothing lifted its members onto the subclass,
so `port_signatures.json` recorded 1 of 23 members on RestClient and
`port_surface.json` recorded 1 of 23.

That read as 22 missing features on both drift axes. It was not: the
accessors are wired, reachable, and spec-derived. The new
tests/rest/resource_tree_mock.test.ts proves it behaviourally -- each of the
22 makes a real call through the accessor against the shared porting-sdk mock
and asserts the request lands with the expected method + path and matches a
spec route.

The fix is the TS analogue of the reference enumerator's
`_wired_base_attributes` (porting-sdk/scripts/enumerate_python_signatures.py),
keyed on STRUCTURE rather than a name list: for a public class, lift the
public class-typed properties declared on the PRIVATE bases it extends.
Because the base file is generated from porting-sdk/rest-apis/*/openapi.yaml,
the accessor set tracks the specs by construction -- a new resource in the
specs appears in both artifacts with no change to the enumerators.

Scoped narrow, matching the reference's rule:
  - private bases only; a public base is its own surface symbol whose members
    are already enumerated on the base itself,
  - class-typed properties only (a primitive scalar field is internal state),
  - private/protected members excluded,
  - transitive through a chain of private bases, stopping at the first public
    one,
  - locally-declared members always win, so an override is never masked.

The private base itself remains absent from the emitted surface, and is kept
out of the name-keyed inheritance maps so it cannot leak through
`resolveInherited`.

Both axes go 22 drifts -> 0 with no ledger entry added: signatures exit 0
(642 excused divergences, unchanged), surface exit 0 (36 excused omissions /
468 excused additions, unchanged). port_surface.json also picks up its
regeneration at the branch tip.

Verification: SIGNATURES exit 0, SURFACE exit 0, run-lint exit 0,
run-format --check exit 0, run-tests exit 0 (135 files, 2847 tests).
The SECURE-DEFAULT gate was RED: scripts/secure-default-dump.ts still spoke the
pre-2026-07-27 protocol, emitting {secure_default_true, wire_reflects_secure} —
two booleans the PORT computed by inspecting its own render. The differ
(porting-sdk/scripts/diff_port_secure_default.py) now rejects that shape outright
rather than tolerating it, because a gate whose comparable is the port's own
conclusion can only re-confirm the port's own premise. That vacuity is how java
shipped a token in `meta_data_token` — the SWML metadata SCOPING key the engine
MD5-derives from public config and never validates — alongside a TOKENLESS
web_hook_url, while its self-classification stayed green.

The dump now emits the wire payload and makes no judgement about it:

  {"<fixture id>": {"secure_default_true": bool, "rendered": {<functions[] entry>}}}

  * secure_default_true is read back from the LIVE registry
    (agent.getTool(name).secure), never the value passed to defineTool — echoing
    the input back makes the field incapable of ever failing.
  * rendered is the tool's own SWAIG.functions[] entry, verbatim, with token
    VALUES replaced by the corpus placeholder <TOKEN>. Keys and key paths are
    preserved exactly, so the DIFFER derives has_own_webhook and token_carrier.

Structure mirrors the php port's migrated bin/secure-default-dump (d3cbc47),
including per-fixture agent isolation and the redaction helpers that make the
differ's re-application of redact_entry a no-op.

The TS emitter itself was already correct and is unchanged: AgentBase.ts:2347-2353
is a faithful three-way branch (explicit webhookUrl wins -> else token/query params
build a local URL carrying __token -> else NO web_hook_url key), matching
agent_base.py:1085-1099.

Also tightens Contract 9's wire assertion in the tier-2 behavioral suite. It
previously checked only that the secure URL contains `__token=` and the insecure
URL does not, which is weak in both directions: a token relocated to a
`meta_data_token` field passes it, and an insecure tool handed its own tokenless
per-tool webhook — an unauthenticated function-specific callback — passes it too.
The new case pins the topology the differ derives: the token rides ONLY as a
__token query param on the function's own webhook, no other token-suffixed key is
present, and an insecure tool has no web_hook_url property at all. Verified by
mutating the emitter to the java shape (token -> meta_data_token, webhook always
emitted): the pre-existing assertions all still passed; only the new one caught it.

Verification:
  SECURE-DEFAULT gate before: exit 1 (2 FAIL, both "LEGACY self-classifying dump")
  SECURE-DEFAULT gate after:  exit 0 ("PASS - typescript")
  Mutation-tested the dump 4 ways, each red with the correct diagnosis:
    token -> meta_data_token field  -> "TOKEN IN THE WRONG PLACE: field:meta_data_token"
    drop the secure web_hook_url    -> "NO TOKEN ON THE WIRE"
    give the insecure tool a webhook-> "WEBHOOK SHAPE: expected NO per-tool web_hook_url"
    __token -> token query param    -> "TOKEN IN THE WRONG PLACE: webhook_query:token"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
porting-sdk 90164e9 unified the drift checker to compare `type`, `kind`,
`required` and `default` on ALL params, not just `__init__` ones. That surfaced
6 live divergences in typescript. Each is a real port divergence — TypeScript
expresses optional params, default parameter values and union-with-undefined,
so none of these was a language ceiling.

WebService.start(host)  —  default-mismatch
  The reference declares `host: str = "0.0.0.0"` (web/web_service.py:543). The
  port declared `host?: string` and resolved the value in the body with
  `host ?? '0.0.0.0'` — same runtime behaviour, wrong signature. Moved the
  initializer onto the parameter.

AgentBase.onSummary(rawData)      —  required-flip
AgentBase.onFunctionCall(rawData) —  required-flip
  Both are override hooks the reference declares with `raw_data=None`
  (core/agent_base.py:511, core/mixins/tool_mixin.py:235), so a caller may
  invoke — and a subclass may override — without it. The port required it.
  Made both optional.

AIChatClient.summarize(options)  —  default-mismatch
  The reference's `summary_prompt` defaults to None; the port's options bag
  carried an invented `= {}` initializer. Dropped it (`options?:`) and made the
  member reads optional-chained.

PromptManager.defineContexts(contexts)  —  required-flip + default-invented
  The reference's PromptManager.define_contexts(contexts) REQUIRES the argument
  and stores it (core/agent/prompt/manager.py:79); get_contexts() reads it back
  (:309). TS's PromptManager had NEITHER method — the enumerator's mixin
  projection pointed both names at AgentBase.defineContexts, whose optional-arg
  builder-entry signature is a different method, so the projection clobbered the
  contract with a required-flip. Implemented both for real on PromptManager
  (including the reference's reject-a-non-object branch) and dropped the two
  names from the projection list so the real signatures are enumerated.

  Also wired the delegation the reference performs: PromptMixin.define_contexts
  forwards a supplied value to `self._prompt_manager.define_contexts(contexts)`
  (core/mixins/prompt_mixin.py:149). TS's AgentBase.defineContexts only set its
  own contextsBuilder, so the manager's store was never populated and the new
  reader would have been permanently null.

tests/ParamDefaults.test.ts pins all of it. Every test exercises the parameter
by OMITTING it — a test that always passes the argument explicitly proves
nothing about a default. The two required-flips are pinned at TYPE-CHECK time
(the call sites do not compile if the param goes back to required), and
`defineContexts()` with no argument is pinned with `@ts-expect-error`, which
fails as an UNUSED directive if a default is ever reintroduced.

Each of the three new drift kinds was mutation-tested: reverting the fix turns
the gate RED (and, for the required-flips, fails tsc), restoring it turns it
green.

DRIFT 6 -> 0.
An overloaded TS method is spelled as N bodyless signature declarations followed
by one implementation declaration that has a body — all separate `cls.members`
entries sharing a name. The enumerator recorded the FIRST of them, so it saw only
the narrowest overload: `getBasicAuthCredentials` read as `(includeSource?) ->
[string,string]` when the method actually returns the `[string,string] |
[string,string,source]` union the Python reference records, and
`setSessionMetadata` read as 2 params returning void when the 3-param
`(sessionId, key, value) -> boolean` parity form is right there.

Record the IMPLEMENTATION declaration instead. TS requires it to be compatible
with every overload, so it carries the widened param types and the union return
by construction — the same shape a single polymorphic Python `def` records.

Separately, the general options-object unfold guarded on `ts.isTypeLiteralNode`,
which is false for an INTERSECTION bag (`{ event?: string } & Record<string,
unknown>` — TS's spelling of `def m(self, *, event=None, **kwargs)`).
`Call.user_event` was on the unfold allowlist but never unfolded because of it.
Resolve the literal arm through the intersection so it does.

Also give `getBasicAuthCredentials` the reference's `= false` default (behaviour
is unchanged; `if (includeSource)` already treated undefined and false alike),
and dedupe union arms that canonicalise to the same string — a string-literal
union like `'provided' | 'environment' | 'generated'` was recorded as
`union<string,string,string>`, a 3-arm union describing one type. The diff's own
normaliser already deduped at compare time, so this only ever made the stored
artifact noisier than the surface it describes; the finding sets are identical
before and after.

Retires 3 PORT_SIGNATURE_OMISSIONS entries (206 -> 204). Excused findings
479 -> 473 with drift flat at 300 and no new drift.

Retiring the `set_session_metadata` entry revealed a genuine divergence the
omission had been hiding: because the 2-arg bulk overload is a strict PREFIX of
the 3-arg one, `value` is optional in the union where the reference requires it.
That is real surface, not an enumerator limit — the entry stays, with a rationale
that now describes what the code actually does, pending a ruling on the bulk
overload (which tests and docs both use).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…lementation

202d79f recorded an overloaded method's IMPLEMENTATION declaration. That over-
corrected. TS requires the implementation signature to be compatible with every
overload, which makes it strictly LOOSER than anything a caller can invoke — it
is compiler plumbing, not surface.

`setSessionMetadata` is the case that shows it. It declares

    setSessionMetadata(sessionId, metadataOrKey: Record<string,unknown>): void;
    setSessionMetadata(sessionId, key: string, value: unknown): boolean;

The second overload matches the reference exactly — `set_session_metadata(
call_id, key, value) -> bool`, all three required. But the implementation must
write `value?` purely because the sibling 2-arg form takes no third argument, so
recording it reported a `required-flip` for a state no caller can reach: the
2-arg overload does not accept a third argument, and the 3-arg overload requires
it. The type system already enforces the reference's contract.

Reconcile across the DECLARED overloads instead, restricted to PREFIX-overloads
(java's `optional_param_names()` rule, commit ea7e0ba): the arities must be
strictly increasing, so every param keeps its position across the set and the
longest declaration carries them all with the required-ness each was actually
declared with. Per-position TYPES may differ — that is what overloading is for.
Same-arity declarations are a polymorphic dispatch rather than a growing prefix,
with no single representative, so those still fall back to the implementation's
honest union. Return types are unioned over the whole declared set independently,
since the param list comes from one declaration but the return genuinely is the
union of every overload's.

`set_session_metadata` now records (self, session_id, key, value) all required
returning union<void,bool> — comparing EQUAL to the reference. No source change,
and the TS-native bulk overload and its 8 test call sites are untouched.

Retires the 4th and last entry of the overload section (PORT_SIGNATURE_OMISSIONS
204 -> 203; the section is now empty and removed). Excused 473 -> 471 with ZERO
new findings; the drift set is byte-identical to the pre-campaign baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ot the ledger

Wave B: an omission stops the drift checker comparing a symbol forever, so a
divergence excused as "we spell it differently" / "language X has no analog" is a
blind spot, not a judgement call. Six of TypeScript's seven such entries are
reconciled here in the type/module tables so comparison KEEPS RUNNING. The
seventh is a real capability gap and is reported, not laundered.

Measured, as SETS (pristine HEAD -> here):
    drift    300 -> 300   (zero new findings, zero resolved)
    excused  471 -> 459   (13 divergences now compare EQUAL; net -12)
    omission entries 203 -> 197

Three of the retired rationales described behaviour the source does not have.

1. `RegExp` was mapped to `string` in type_aliases.yaml. It is a compiled-pattern
   OBJECT, and the reference records Python's `re.Pattern` as `class:Pattern`.
   Because a union dedupes, `string | RegExp` collapsed to a single `string` arm,
   erasing the RegExp affordance from the recorded surface and forcing an
   omission on DataMap.expression. ruby and perl already emit
   `union<string,class:Pattern>` and carry no omission. Now TS does too.

2. "The FastAPI `Request` has no Hono/TS analog" was false. Hono's per-request
   `Context` is that analog, and go already maps its `http.Request` and compares
   equal (the differ matches bare `class:` refs by leaf name). `Context` now maps
   to `class:Request`; it appears in exactly one audited public signature.

3. "TS types payloads as named shapes where Python uses dict" was a real
   divergence with the wrong cause. The diff checker has ALWAYS held a
   spec-generated TypedDict compatible with `dict<string,any>` in either
   direction. The rule never fired because this port's SIGNATURE enumerator was
   missing the `src/PlatformContracts.generated.ts` module alias that its own
   SURFACE enumerator already had, so `SwmlRequestData` fell back to
   `signalwire.platform_contracts.generated.*` — a path carrying none of the
   generator-module markers `normalize_type` keys on — and never normalised to
   the `gen:<Name>` token. Both that file and the reference's
   `swml_webhooks_types_generated.py` are generated from the SAME spec
   (rest-apis/swml-webhooks/openapi.yaml), so they must record the same module.
   Adding the one alias retired 8 excuses beyond the entries in scope.

Two REAL port defects the omissions were hiding, both fixed:

* `InfoGathererAgent.onSwmlRequest` was a LIVE override narrowed to one
  parameter, so neither the callback path nor the framework request object could
  reach it or any subclass — the reference passes both at its call sites. Widened
  to the base hook's full `(requestData, callbackPath, context)`.
* `AgentBase.onSwmlRequest` declared its first parameter REQUIRED where the
  reference defaults it to None (a `required` flip is contract drift, not idiom).

Left UNRESOLVED for an owner ruling, with the false rationale corrected in place:
`WebService.security` returns `SslConfig` (SSL/HSTS only) where the reference
returns the unified `SecurityConfig` (SSL + CORS + security headers + allowed
hosts + basic auth), so 4 capability groups are unreachable through the accessor.
This is a NARROWING, not the "config class-name spelling" difference the old
rationale claimed: TS's own faithful `SecurityConfig` already exists in
src/SWMLService.ts — `WebService` just does not use it. Closing it is a
behavioural change to a security-relevant surface, so it is not made unilaterally.

Verified: bash scripts/run-ci.sh -> exit 0 (SURFACE/TEST/LINT/BEHAVIORAL all PASS).
port_signatures.json regenerated AFTER run-ci (the surface suite's _restore_tree
reverts it) and its committed content checked, not just its diff status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ise was false

All 43 `ts-options-object` signature omissions shared one rationale: TS collapses
the reference's positional-or-keyword params into an options bag, "so the param
KIND reads as a mismatch."

That premise is false. `diff_port_signatures.py::compare_param_properties` folds
ref=`positional` -> port=`keyword` (:917, directional by design — a Python
positional-or-keyword param IS callable by name, so a port spelling it as a named
field preserves the exact capability). The fold predates none of these entries;
they were simply never re-audited against it. So these methods can UNFOLD and be
COMPARED rather than excused.

Promoting them exposed a second, real enumerator defect: the unfold recorded
`default: null` for every optional member, which ASSERTS "the default is null" —
false wherever the reference declares a real one, manufacturing 44 spurious
`default-mismatch` findings against a port that behaves identically (verified in
source: PomSection's ctor applies `?? ''` / `?? false`; FunctionResult.
joinConference omits each key when it equals the reference default). A TS optional
property carries no syntactic default — the value is applied downstream in the body
or a callee's constructor — so the declaration site cannot supply it. Omit the key
instead: the differ then counts it as UNRECORDED (direction (b)) rather than drift,
which is the truthful classification. `required` still compares, so an
optional->required flip is still caught.

Also keyed 8 entries by their SOURCE class (`agent_base.AgentBase.*`) rather than
the mixin-projected canonical name — the unfold runs at enumeration time, before
MIXIN_PROJECTIONS moves the computed signature, so a projected key never fired.

Measured (pinned oracle, --numeric-monotype as the gate runs it):
  drift    200 -> 200   (set IDENTICAL: 0 resolved, 0 introduced)
  excused  435 -> 409   (-26)
  omission entries 197 -> 168; ts-options-object 43 -> 8
  params compared 1259 -> 1428 (+169 now genuinely compared)
  param-property drift stays ZERO

Ten entries could NOT be retired and keep a corrected rationale, because the
mechanism genuinely does not reach them (each verified in source, not from the old
text): a NAMED interface rather than an inline type literal (add_language,
add_pronunciation, build_config, SkillBase.define_tool, load_skill); a generic
INTERSECTION over a mapped `Omit<...>` (ToolRegistry/ToolMixin.define_tool);
a genuine object argument, not a bag (SkillMixin.add_skill); flat optional
positionals (AgentServer.run); and WebMixin.run, whose bag members are declared in
a different ORDER than the reference's positional list — the differ compares by
position, so unfolding it pairs event<->host and manufactures four mismatches out of
correctly-corresponding members.

DataMap.webhook unfolds (its `opts` members are read off individually onto separate
wire keys); DataMap.foreach stays out, as the boundary comment requires — it takes
ONE required param passed through whole (`webhook['foreach'] = config`), and its
members are already snake_case because they ARE the wire shape.

Default coverage drops 99.5% -> 78.3% (308 unrecorded) as a direct consequence of
not asserting defaults the port cannot see. That is 308 honest "unknown"s replacing
44 false assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
porting-sdk dcff742 turned BasicCredentials/BearerCredentials from DANGLING oracle
refs into REAL classes. Before it, griffe could not resolve FastAPI's names into the
signalwire. tree, so the oracle named a class with no definition anywhere — and the
gate still compared it by leaf name. A port carrying the real shape drifted against a
phantom and a port carrying nothing also drifted; neither could win. This port carried
nothing, and papered over the gap with two excuses.

Both excuses were wrong on the same point: they claimed FastAPI's wrapper types "have
no Hono/TS analog". FastAPI was never the contract. Those objects are just the fields
parsed out of the Authorization header, and that shape is entirely portable — cpp,
ruby and perl had each independently hand-rolled the same two carriers with the same
field names. So the fix is to CARRY the contract, not to keep excusing it:

  src/AuthHandler.ts now exports two carriers matching the oracle exactly
    BasicCredentials  { username, password }
    BearerCredentials { scheme, credentials }

  verifyBasicAuth / verifyBearerToken take them, matching the reference's
  verify_basic_auth(credentials) / verify_bearer_token(credentials).

The carriers are LOAD-BEARING, not decorative: validate() now parses the Authorization
header into a carrier and calls the verify method, so the one comparison path is the
one the parity gate checks. verifyBearerToken compares only `credentials`, the same
field the reference compares — `scheme` is carried for header fidelity and is not part
of the secret comparison. Both methods had ZERO callers and ZERO tests before this, so
changing their shape broke nothing; 6 tests now cover the fields, the not-configured
short-circuits, and the validate() round trip.

Retired, not re-excused, in PORT_SIGNATURE_OMISSIONS.md:
  - AuthHandler.verify_basic_auth  ("TS takes the unwrapped (username, password) pair")
  - AuthHandler.verify_bearer_token ("TS takes the already-unwrapped token: string")
  - the stale framework/typed-shape bullet still asserting the wrapper types are omitted

Measured as SETS, both arms against a scratchpad-PINNED oracle whose md5 was asserted
identical across the two runs (python_signatures 7cb4b078…, python_surface d15aced4…),
with the flags this port's gate actually passes (--omissions on both sides,
--numeric-monotype):

  DRIFT       4 hard -> 0     4 resolved, 0 INTRODUCED
                BasicCredentials.username/.password, BearerCredentials.scheme/.credentials
                (all missing-port)
  excused   584   -> 580      4 resolved, 0 INTRODUCED
                verify_basic_auth param-count-mismatch, verify_bearer_token param-mismatch,
                and both classes' construction-missing-class
  SURFACE     6 missing -> 0  6 resolved, 0 INTRODUCED
  excused_additions 431 -> 431 UNCHANGED, excused_omissions 34 -> 34 UNCHANGED

Nothing outside the credential surface moved in either direction.

Artifact staleness, checked before starting (ruby's c36ddd0 defect): port_signatures.json
regenerated byte-identical to git show HEAD: — not stale. port_surface.json differed from
HEAD in exactly one leaf, the `generated_from` provenance SHA, with zero surface content
drift; the previous commit stamped its parent. Both are regenerated and committed here.

SEMVER-DIFF reports verifyBasicAuth as a breaking param-count change against the last
release. That is accurate and intended — it is the point of the change — and the gate is
--report-only, so no allow-list entry was added.

Coordinated-With: porting-sdk dcff742 (oracle change; pin via PORTING_SDK_REF per
COORDINATED_PASS.md). Workflow audit: all 8 CI-path porting-sdk checkouts already use
`ref: ${{ vars.PORTING_SDK_REF || 'main' }}`. The one literal `ref: main` is in
publish.yml and is a DELIBERATE, documented exception — a release must never ship from
an unmerged wave branch — not the leftover the coordinated pass is looking for.

Verification (real exit codes captured to a file, not read off the harness — it
misreported this run's exit as 0 when it was 1):
  bash scripts/run-ci.sh                          -> 1, gates: SURFACE only
      SURFACE:SIGNATURES PASS  SURFACE:DRIFT PASS  SURFACE:SEMVER-DIFF PASS
      SURFACE:GEN-TYPE-DEGENERACY PASS  SURFACE:GEN-IDIOM PASS
      SURFACE-FRESH + SURFACE-DIFF FAIL — both because the regenerated artifacts were
      still UNCOMMITTED: SURFACE-FRESH compares a fresh regen against the COMMITTED
      blob, and surface.py:_restore_tree() git-checkouts the artifacts after the suite.
      This commit is that missing step; the other 28 gates all PASS.
  scripts/run-tests.sh AuthHandler                -> 0, 24 passed (18 before)
  scripts/run-lint.sh                             -> 0
  diff_port_signatures.py (pinned oracle, --numeric-monotype, --omissions) -> 0
      signatures match (1561 reference symbols, 1893 port symbols, 605 excused)
  diff_port_surface.py (pinned oracle, --omissions --additions)            -> 0
      port matches Python reference (2709 symbols; 36 excused omissions, 468 excused additions)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…r the __init__ oracle fix

The three `PORT_SIGNATURE_OMISSIONS.md` entries for `Call.on`, `RelayClient.on_call`
and `RelayClient.on_message` all justified themselves with the same claim: that
Python "wraps" the handler in an `EventHandler` / `CallHandler` / `MessageHandler`
*class*, and that TS instead types the handler as a plain callback returning void.

Both halves of that claim are false. In the reference these are type ALIASES, not
classes:

    signalwire/relay/call.py:56
      EventHandler = Callable[[RelayEvent], Coroutine[Any, Any, None] | None]
    signalwire/relay/client.py:74-75
      CallHandler    = Callable[["Call"], Coroutine[Any, Any, None]]
      MessageHandler = Callable[["Message"], Coroutine[Any, Any, None]]

There is no wrapper class to be "internal" to. And TS does not return void: it
declares the same three aliases in src/relay/types.ts:60-66 and
`RelayClient.onCall`/`onMessage` return the handler for decorator-style use,
exactly as the reference does. The two implementations are structural mirrors.

The entries were only ever paying for oracle bug 85e12c7, which emitted these
aliases as dangling `class:` refs naming classes defined nowhere. With that fixed
the oracle records the callable type the alias denotes, and the port's recorded
signatures are now byte-identical to the reference's — so the omissions suppress
nothing. A/B against a pinned oracle (python_signatures.json md5
7d9f2fa2e1f385df2afac88b657a6475), `--omissions` on both sides plus
`--numeric-monotype`: 0 drift / 603 excused WITH the entries and 0 drift / 603
excused WITHOUT, the differ's full output byte-for-byte identical. Dead entries,
deleted rather than re-tagged.

port_surface.json is regenerated for oracle fix 8828dd2, which taught the surface
oracle to record a synthesized `__init__`. The delta is exactly the 30 classes
that fix named — 19 relay events, 3 ai_chat carriers, RequestOptions and the 2
credential carriers — 30 added, 0 removed, every one an `__init__`. SURFACE-DIFF
goes from 30 unexcused missing symbols to clean without a single ledger entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Owner ruling 2026-07-28: the whole port fleet lands on a single unreleased
3.0.0. This port was at 3.2.0; nothing in the 3.x range was ever published
(typescript's published tags top out at v2.0.5), so the downgrade regresses
no artifact. The credential-carrier work removed the public 2-arg
verifyBasicAuth(username, password) — genuinely breaking — and the fleet
absorbs that into one coordinated 3.0.0 rather than staggering majors.

Declaration site: package.json "version". package-lock.json self-references
the package's own version in both its root entry and packages[""], so both
are updated in lockstep. The REST User-Agent is derived at runtime from
package.json (buildUserAgent in src/rest/HttpClient.ts) and needs no edit.
The CHANGELOG's "## 3.2.0" heading is a historical release entry, not a
declaration site, and is left as-is.

This sets version INTENT only. No tag, no release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ce payload

Per the owner ruling (2026-07-28) every port's release floor becomes 3.0.0.
`port_signatures.baseline.json` gets `baseline_version = 3.0.0` AND its recorded
surface payload replaced with the current enumeration.

Both halves are required. SEMVER-DIFF does not merely compare version numbers —
it diffs the surface against the floor's recorded `modules` payload, so bumping
`baseline_version` alone leaves `required = 'major'` against a floor whose payload
still describes an older surface. Before this change the floor reported:

    3.0.2 (3.0.2) -> 3.0.0  actual bump = 'downgrade', required = 'major'  [MISMATCH]
      BREAKING — 15 member(s) removed since last release

After the payload swap all 15 breaking removals are gone:

    3.0.0 (3.0.0) -> 3.0.0 (package.json)  actual bump = 'none'
      no public surface change since last release.

This is a PAYLOAD SWAP, not a file copy: the floor carries release-anchor
metadata the current artifact does not. `modules` + `construction` come from a
FRESH `npx tsx scripts/enumerate-signatures.ts` (94 -> 98 modules; the regen was
byte-identical to the committed port_signatures.json, confirming that artifact was
already current); `baseline_version` is set to 3.0.0; and the provenance anchor
(`generated_from_tag` / `tag_sha` / `generated_from_commit`) is re-pointed from
the stale 80381af to this branch's HEAD fc00922. No v3.0.0 tag exists yet, so a
bare 40-hex commit sha is the correct anchor — the same commit-anchored
convention the rust floor already uses, and one semver_diff accepts
unconditionally (_SHA_RE, semver_diff.py:169/176).

Semantics: the floor stops being "the surface as last published" and becomes
"the surface as of the 3.0.0 wave". That is coherent because nothing 3.x/4.x
ever shipped. SEMVER-DIFF will no longer flag anything already in today's
surface; future breaking changes are still caught, measured against the new floor.

KNOWN, PRE-EXISTING, NOT INTRODUCED HERE: run standalone (without --report-only)
semver_diff still exits 1 with `required = 'patch'` despite reporting "no public
surface change". That is a defect in the checker, not in this floor:
semver_diff.py:495 fires on `if allow:` — the mere EXISTENCE of a non-empty
SEMVER_DIFF_ALLOW.md — and overwrites an already-correct `required = 'none'` with
'patch', even when none of the allowlisted symbols appear in the diff. This port's
2 allowlist entries (CrudResource.get/list) are pre-existing and unrelated to the
current diff. run-ci is unaffected: it invokes SEMVER-DIFF with --report-only
(the D5 "re-anchor at cut" wave setting), which exits 0. Reported for the owner
rather than papered over; no allowlist entry was added.

run-ci: real exit 0, 23 gates PASS / 0 FAIL — UNCHANGED from the pre-change
baseline. No gate moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
The inbound `Authorization` guards compared the scheme token with a
case-sensitive `startsWith('Bearer ')` / `startsWith('Basic ')`. RFC 7235
makes the auth-scheme token case-insensitive, and the reference
(FastAPI `HTTPBearer` / `HTTPBasic`) partitions the header on the FIRST
space and compares `scheme.lower() != "bearer"` / `!= "basic"`. So a
legal `authorization: bearer <token>` authenticated against the
reference and was 401'd here.

Fixed at all four inbound comparison sites, via a `schemeParam()` helper
that mirrors `get_authorization_scheme_param` (partition on the first
space, strip the credential, case-insensitive scheme compare):

  src/AuthHandler.ts   Bearer branch
  src/AuthHandler.ts   Basic branch
  src/SWMLService.ts   _checkBasicAuthHeaders
  src/AgentBase.ts     checkAgentBasicAuth

Outbound emitters that BUILD a canonical-case `Basic `/`Bearer ` header
(HttpClient, AIChatClient, swaig-test, the skills) are unchanged —
emitting the canonical case is correct.

The Bearer branch now carries the scheme through to `BearerCredentials`
exactly as the client sent it, matching the reference, which reports the
wire scheme rather than a canonicalized one.

Colon-handling was already correct in all three Basic-decoding paths and
is now covered by tests: the reference does
`username, separator, password = data.partition(":")` and rejects when
there is no separator, so a colon-less payload must never authenticate as
a user with an empty password.

Tests assert both directions — lowercase/mixed-case `bearer`/`basic` are
accepted, while `Digest`, `Negotiate`, `Basicx`/`basicx`,
`Bearer`-on-the-Basic-branch, a scheme-less header, and a colon-less
Basic payload all stay rejected. The 8 accept assertions fail against the
unfixed guards; every rejection assertion passes both before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Per the owner's 2026-07-28 ruling, every CHANGELOG heading above a port's last
genuinely published tag collapses into a single unreleased 3.0.0 entry.

The TypeScript port's published tags top out at v2.0.5 (verified against
`git ls-remote --tags origin`, not just local tags), so the 3.2.0 / 3.1.0 /
3.0.2 headings were all unreleased drafts -- no released artifact regresses.
They are consolidated into one `## 3.0.0` entry with NO content dropped: all
nine bullets survive, regrouped under Added / Fixed / Notes.

The `## Unreleased (Wave 1)` section above them is untouched -- it carries no
version number, so it is not the "top entry" META-CONSISTENT resolves.

package.json's version was already 3.0.0; this is the static mirror that
META-CONSISTENT cross-checks against it:

  meta_consistent.py --port typescript   exit 1 -> exit 0
  (was: manifest version '3.0.0' != top CHANGELOG entry '3.2.0')

This sets version INTENT only -- no tag, no release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Owner ruling 2026-07-28. DOCUMENTATION ONLY: no gate reads this file and nothing
fails on its presence. It records why the version looks the way it does, so the
next session does not re-derive it or casually bump one — and it carries the
delete-before-release checklist in its own body.

Nothing 3.x/4.x was ever published (git ls-remote tops out at v1.1.2 for
rust/dotnet, v2.0.x for most others), so the freeze rewrote no real history.

Exempted in porting-sdk root_hygiene.py 287b7f2.
…re TOKEN-INTEROP

Node's 'base64url' encoding STRIPS the '=' padding. The reference mints with
base64.urlsafe_b64encode, which keeps it, and validates with urlsafe_b64decode,
which RAISES on a stripped '=' — so every token this port minted was unusable to
the reference and to any port that decodes strictly, even with a correct key and a
correct HMAC. In production every secure tool call fails authentication.

Now: standard base64, then the urlsafe alphabet, padding intact.

Also wires the TOKEN-INTEROP gate (property 3 of the SWAIG tool-token contract: a
token this port MINTS validates under the REFERENCE's own decoder). SECURE-DEFAULT
proves a token is minted and the fleet keying check proves the HMAC key; NEITHER
sees the base64 ENVELOPE, so a port can ship correct-key correct-HMAC tokens that no
other implementation accepts. Per-PR rather than nightly — a security property
should not wait.

Seven of the ten ports shipped an unpadded envelope, invisible to each port's own
tests because every port's DECODER tolerates missing padding while the reference's
urlsafe_b64decode RAISES on it — so round-tripping against ourselves could never
catch it. That is why the gate validates against the reference's decoder.

Verified: TOKEN-INTEROP exit 0; reverting to `toString('base64url')` reproduces "urlsafe_b64decode raised Error('Incorrect padding')", so the gate fails for the right reason. vitest 2882/2882.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…differ

porting-sdk 7034c33 stopped TYPE-EROSION from counting a MISALIGNED slot as an erased
type. The gate keyed on position, which is only meaningful while both param lists
describe the same parameters; where a port's list has a different SHAPE (different
arity, or a variadic catch-all standing in for a named param) index i was a different
parameter on each side, and an `any` there was reported as an erased type. Those methods
are already reported — correctly — by diff_port_signatures as param-count-mismatch.

So this port's old ratchet banked a number that was part real erosion and part
double-billed count-mismatch. Re-baselined onto what the corrected differ measures.

    ratchet 45 -> 5  (the delta is measurement correction, not a surface change)

No port code changed and no erosion was fixed by this commit: the number moves because
the MEASUREMENT was corrected, not because the surface improved. The ratchet doctrine is
unchanged — drive it DOWN, never up — and it now ratchets against a number that means
one thing.

Fleet-wide the same correction takes 524 -> 257; 292 of the 524 were the artifact. The
skip is never silent: each run prints how many methods went unmeasured and names the
gate that owns them.

Verified: diff_port_type_erosion.py --port typescript --repo . --max 5 -> exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ST the wiring

typescript is the ONE port that already had this right: `Logger.log` calls
`stripControlChars(serialized)` before merging (Logger.ts:339), so the scrub was
genuinely on the emission path. rust, cpp and java each shipped the same function
with ZERO call sites — public, correct, and protecting nothing. This commit does
not change ts behaviour; it closes the two gaps around it.

1. PARAM NAME. The reference's public contract is `strip_control_chars(event_dict)`
   (logging_config.py:33); this port recorded `data`. Renamed the parameter to
   `eventDict` — the enumerator's camelCase->snake_case fold turns that into
   `event_dict`, so the recorded signature now matches the oracle with NO
   rename-table entry needed. Verified by regenerating port_signatures.json:

     signalwire.core.logging_config [('event_dict', 'any')]

   The alternative was a free-function param-rename table, which does not exist in
   enumerate-signatures.ts (its rename tables are keyed by CLASS). Renaming in the
   port source is the smaller, more honest change: nothing about the reference is
   being reconciled away, the port simply says what it means. All three call sites
   are positional, so nothing else moved.

2. NO TEST COVERED THE WIRING. The protection was real but unguarded — the exact
   state in which rust/cpp/java's scrub rotted into a no-op without any gate
   noticing. The new test reads what the logger ACTUALLY emitted (via the existing
   console spies) rather than calling stripControlChars directly, so it fails when
   the wiring is removed. Verified by deleting the call from Logger.log — RED:

     × strips control characters from emitted log data
       Tests  1 failed | 48 passed (49)

   A helper-only test passes against that same break.

   The second test asserts tab/newline/CR SURVIVE: a scrub that ate them would
   satisfy "no control chars" while mangling every multi-line message.

Verified: run-tests.sh -> exit 0, 136 files / 2884 tests (2882 before; +2 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… doc comment

65a1a1c renamed the parameter to `eventDict` and explained WHY in the shipped
doc comment — naming the enumerator, the camelCase->snake_case fold, the rename
table, and "the oracle". The nightly PUBLIC-JARGON gate caught the last of those:

    src/Logger.ts:237: banned public-doc jargon 'oracle'
    [public-jargon] typescript: 1 jargon leak(s) in 1 file(s)

It is a nightly-tier gate, which is why the per-PR run-ci was green locally.

The gate is right, and not only about the one banned word. An SDK consumer reading
`stripControlChars` in their editor does not have a porting matrix, an enumerator,
or a reference oracle; every sentence of that paragraph described OUR process, not
what the parameter is or does. That reasoning belongs in the commit that made the
decision (65a1a1c, where it still is), not in shipped API documentation.

The parameter description is now what a caller needs: the log event record whose
string values get sanitized. Nothing about the behaviour changed.

Verified: run-ci.sh --rules PUBLIC-JARGON -> PASS. run-tests.sh -> 2884 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ionales

Part of the fleet-wide false-ledger sweep. Drift-neutral: excused divergences 602 -> 602,
SURFACE 7/7 PASS before and after — the proof that the 10 deleted entries were suppressing
nothing at all.

THREE CLAIMS IN MY OWN BRIEF DID NOT SURVIVE SOURCE:
  - "3 EventHandler entries" — `grep -c EventHandler PORT_*.md` returns 0 in BOTH ledgers.
    Whatever task #134 measured is not in this tree.
  - "the differ tolerates `device: Device` as a superset today" — implying a real shape
    difference underneath. Run with an EMPTY omissions file, the differ reports no device
    divergence at all. There was nothing for those entries to excuse.
  - "~22 entries" understated it. All 164 signature entries were probed MECHANICALLY (remove one
    line, ask the differ what it then says about that symbol): 38 suppress nothing, and the
    largest false-rationale cluster was a whole section the suggested grep never matched.

DELETED (10) — each verified against reference source, not against the rationale's own story:
  4x .device        claimed "Python sets it dynamically". relay/event.py:41,65,238 declare
                    `device: dict[str, Any] = field(default_factory=dict)` — STATIC dataclass
                    fields, and the oracle records all four.
  strip_control_chars  claimed the 3-arg structlog processor signature. Reference is
                    core/logging_config.py:33 `def strip_control_chars(event_dict)` — one
                    parameter, deliberately refactored (the _as_processor docstring says so).
                    FIXED-BUT-NOT-DELISTED, the same mode found in perl.
  SignalWireRestError.body  claimed "Python has no such attribute". rest/_base.py:88 is
                    `self.body = body`.
  4x typed getters  SkillBase.agent, FAQBotAgent.faqs, SurveyAgent.questions, Action.call each
                    claimed "the signatures oracle does not record" it. It records all four.

REWORDED AND KEPT (9) — THE TRAP THAT GOT GO, CAUGHT HERE BY PROBE.
The whole `## Idiom: TS fluent API returns this` section (8 entries) claims each method returns
`this`. NOT ONE DOES — getAgents(): Map<...>, createPaymentAction(): PaymentAction,
getSection(): PomSection | undefined, toDict(): PomSectionData[], debugToken(): DebugTokenResult,
getPromptSections(): SkillPromptSection[]. On the false-rationale finding alone that section
looked deletable. But all 8 suppress REAL divergences (7 return-type mismatches, 1 missing-port),
so deleting them would have red'd ts's nightly exactly as happened in go (85a2f72). Wording
corrected, entries kept — MISWORDED-BUT-REAL.
Ninth: clear_digit_bindings claimed a missing **kwargs passthrough. The reference does have one,
but the oracle strips kwargs tails; the divergence actually suppressed is `realm`'s kind,
keyword vs positional.

Deleted outright rather than commented — a tombstone keeps the symbol name and the stale claim
greppable, so an audit of "which symbols are excused" gets a false positive.

Verification:
  sw-verify typescript --gates SURFACE,LEDGER -> exit 0
    SURFACE PASS · LEDGER PASS
    NO-LAUNDER PASS (impossible: 28 · approved: 7 · idiom: 0 · unclassified: 0 · banned: 0)
  Drift-neutrality established by per-entry probe against a baseline, not by an after-state run.
  run-format.sh exit 0 and --check clean; it changed nothing beyond this ledger.
  Edit re-confirmed present AFTER the gate run, per the #112 run-ci-checkout trap.
  PORT_OMISSIONS.md audited, unchanged. NO TypeScript source touched.

TWO THINGS FOR AN OWNER, NOT DONE HERE:
  - WebMixin.run carries a stale MECHANISM narrative: it describes the differ unfolding the
    options bag and pairing event<->host, but the differ actually reports a plain
    `param-count-mismatch: reference has 5, port has 1`. Its FACTS (member order, return type)
    are true and verified at src/AgentBase.ts:3114, so rewriting it was out of scope.
  - diff_port_signatures.py HAS NO STALE-ENTRY DETECTION. An entry naming a symbol with zero
    divergence is silently ignored — which is precisely why these 10 survived. A "ledger entry
    that excuses nothing" check would make this whole campaign self-policing fleet-wide, instead
    of requiring a per-port manual sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…dded to the dump

ENVELOPE was DEFINED for typescript by porting-sdk and scheduled by only ONE port in the fleet (go).
The standalone `ENVELOPE` token elsewhere in this file was a COMMENT, not a rule. So the behavioural
request-options contract went unchecked here.

BURNED TO ZERO BEFORE WIRING, per the standing rule.
  BEFORE: (3 case(s) pending dump coverage: compose_ctx_timeout_alone, compose_abort_signal_alone,
          compose_ctx_and_signal_both)
          ✗ 3 FAIL — typescript
  AFTER:  ✓ PASS — typescript

THE REDS WERE MISSING DUMP CASES, NOT MISSING SDK BEHAVIOUR. An earlier audit called them
"unimplemented request-options behavior"; that was wrong and briefing it that way would have sent
someone hunting a bug that does not exist. scripts/envelope-dump.ts emitted four `envelope_*` cases
and zero `compose_*` cases. The SDK was fine all along.

WHAT THE COMPOSE CASES ACTUALLY PROVE. The corpus arms a slow 3s 200; the differ (_drive_compose,
diff_port_envelope.py:211-277) drives three legs and reduces each to one boolean. The timing
constants are a CONTRACT with the differ, not tunable knobs — they are the separation that makes
each leg attribute its cancellation to exactly ONE source:
  1500ms window  — the sole observable; sits between the 500ms short timeout (fires well inside)
                   and 3000ms natural completion (lands well outside), so a dropped cancellation
                   source cannot sneak in.
  500ms short    — < 3s, so it fires. Used by the `ctx` and `both` legs.
  10s long       — > 3s, so it CANNOT fire; that is what makes the `signal` leg prove the SIGNAL
                   did the cutting rather than a timeout.
  `both`         — arms the short timeout AND a live-but-untriggered signal, so the timeout fires
                   IN THE PRESENCE OF a signal. This reds a port that REPLACES the timeout with the
                   signal instead of MERGING them — the go GO-5 bug at rest/client.go:327-330.

TYPESCRIPT IS STRUCTURALLY IMMUNE TO THAT BUG, and I verified the mechanism rather than the
symptom: HttpClient.ts:285-291 `_attemptSignal` returns
`AbortSignal.any([opts.abortSignal, timeoutSignal])` — a genuine merge. The earlier note citing
:205-213 was pointing at the pre-attempt cooperative check, which is real but is not the line these
cases exercise.

An idiom note worth recording: TS has no context.Context, so the "ctx" source here is
RequestOptions.timeout (RequestOptions.ts:40) — which is exactly what the PYTHON ORACLE uses too.
TS maps to the oracle more directly than go does; go had to reach for GetContext plus a
client-default abortSignal.

Compose cases drive a local node:http server that sleeps 3s rather than arming a delay_ms scenario
on the shared mock — mirroring go's httptest approach and keeping these legs off the shared mock
entirely. The artifact is booleans only, so the oracle comparison is unaffected.

TIER: BLOCKING (per-PR). ENVELOPE joins the existing BEHAVIORAL suite line, so it costs no extra
gate invocation. The desc string is updated alongside the --rules list — a desc that enumerates
rules it does not schedule reads as coverage and is worse than no desc.

Verification:
  ENVELOPE alone, run three times consecutively: PASS each time (the compose legs are not
    timing-lucky).
  FULL BEHAVIORAL SUITE as run-ci now invokes it, minus SWAIG-HTTP-INVOKE: all 21 rules PASS.
  scripts/run-tests.sh   exit 0 — 136 files, 2884 tests passed
  scripts/run-format.sh  exit 0 — Prettier clean (the dump was reformatted BEFORE the passing runs
                                  above, so there is no formatting delta waiting to bite CI)
  scripts/run-lint.sh    exit 0 — tsc + eslint + check-ts-idioms clean

KNOWN SEPARATE RED, NOT CAUSED BY THIS COMMIT AND NOT FIXED HERE: SWAIG-HTTP-INVOKE now reports
"3 case(s) pending dump coverage: token_valid, token_forged, token_absent". That is fallout from
porting-sdk 04e24f2, which added inbound-token fixtures to the shared corpus; ts and go both need
their swaig-http dumps extended to emit them. Tracked separately — see task #57.

Found by the fleet-wide gate-wiring audit (task #55); part of that item's ENVELOPE tier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…fail-open finding, left RED

porting-sdk 04e24f2 extended swaig_http_corpus.py with a query-string dimension and three
token fixtures. Dump programs hardcode their fixtures per language, so until this commit
typescript reported "3 case(s) pending dump coverage: token_valid, token_forged, token_absent"
and SWAIG-HTTP-INVOKE was red for a reason that said nothing about this port. That red was
missing dump coverage, not a defect. This commit converts it into a real measurement.

RESULT — two go green, ONE IS A GENUINE DEFECT AND STAYS RED:
    ✓ token_valid    {handler_invoked: true,  refused: false}
    ✓ token_forged   {handler_invoked: false, refused: true}
    ✗ token_absent   oracle     {handler_invoked: false, refused: true}
                     typescript {handler_invoked: true,  refused: false}

The two greens are load-bearing: they prove this port DOES read `__token` off the query string
and DOES refuse a well-formed forgery on a secure function. So the remaining red is narrow and
specific — absent-token only — and cannot be dismissed as "the dump does not mint tokens".

WHY IT IS RED. src/AgentBase.ts:2864-2866:
    const url = new URL(c.req.url);
    const token = url.searchParams.get('__token') ?? url.searchParams.get('token');
    if (token) {
The entire validation block sits inside that `if`, so omitting the parameter skips the check
and the secure tool runs. That was CORRECT PARITY when it was written — the reference had the
identical shape — and the comment above it says so explicitly, citing agent_base.py:1413-1445.
signalwire-python 7c2f253 then made a secure tool REQUIRE a token (owner-ruled), which moved
the golden and left this port on the old contract.

DELIBERATELY NOT FIXED HERE. The fail-open→fail-closed rollout is one owner decision spanning
this port and the six mint-only ports (go, java, php, ruby, perl, dotnet); go is already
visibly red on the same fixture. Fixing typescript alone would hide the shape of the fleet
problem behind a green. Two things are therefore untouched and both are known:
  * the `if (token)` guard itself
  * ITS DOCTRINE COMMENT at AgentBase.ts:2858-2863, which now asserts something FALSE — that
    "a MISSING token does not by itself block dispatch" is "parity with the reference". It
    cites pre-7c2f253 line numbers. Whoever implements the ruling must delete the comment,
    not just the branch; a stale doctrine comment is how a fixed behaviour gets reverted later
    by someone reading it as intent.

Verification — the whole suite as CI runs it, not the new cases in isolation:
    BEHAVIORAL --port typescript, full --rules list -> 21 PASS / 1 FAIL (token_absent only);
      ENVELOPE, wired in 23afe42, stays green.
    run-tests.sh -> exit 0, 136 files / 2884 tests
    format + lint clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Document the last 69 undocumented exported symbols, all of them in two files,
and ratchet the DOC-SURFACE floor from 77.8% to 100.0%.

src/relay/constants.ts (65 symbols) — every RELAY protocol constant now carries
per-symbol TSDoc saying what wire field it is and what it means, not just the
group comment it sat under. Notable content beyond the literal:

  - AGENT_STRING / PROTOCOL_VERSION: the two `signalwire.connect` handshake
    fields; AGENT_STRING is per-port by design (python sends
    signalwire-agents-python/1.0), so the divergence is documented rather than
    read as drift.
  - END_REASON_NO_ANSWER: the camelCase `noAnswer` wire spelling is the
    server's, and is inconsistent with the snake_case siblings — called out so
    no port "fixes" it.
  - SERVER_PING_TIMEOUT: documented as a LOGGING-ONLY watchdog. Verified in
    RelayClient._resetServerPingTimeout: on expiry it logs at debug and does
    nothing else. A dead peer is caught by the client ping loop reaching
    CLIENT_PING_MAX_FAILURES, which is what forces the close.
  - EXECUTE_QUEUE_MAX: the bound on requests buffered while disconnected;
    exceeding it rejects with RelayError instead of growing the buffer.
  - MESSAGE_STATE_SENT documented as NOT terminal (a delivery receipt may still
    upgrade it), matching MESSAGE_TERMINAL_STATES.
  - RECORD_STATE_NO_INPUT documented as a terminal success-shaped outcome
    distinct from a failure.

src/rest/RequestOptions.ts (4 symbols) — the DEFAULT_* contract floor.
DEFAULT_RETRIES is documented as zero-by-design (retries are opt-in; the first
non-2xx raises), and DEFAULT_RETRY_ON_STATUS notes that membership in the set
is necessary but not sufficient: statusIsRetryable further restricts POST/PATCH
to 429/503 so a side effect is never replayed on 500/502/504.

Docs only — no code, signature, or behaviour change. Both files' diffs are
purely additive (zero removed lines), and the `^export ` declaration lines in
constants.ts are byte-identical to HEAD.

Verified: npm run build (exit 0) · scripts/run-lint.sh (exit 0) ·
scripts/run-tests.sh 136 files / 2884 tests passed · doc_surface.py 327/327 =
100.0% against the newly pinned 100.0 floor (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…open guard

The gate ran on every one of these ports and could never fail: it passed --report-only, so a
doc regression printed a line and the run stayed green. That was correct at graduation, when
the floor was well below 100 and the point was visibility. It is wrong now — the port is at
100.0% and .doc_surface_floor is pinned there, so a newly-undocumented public symbol is a real
regression with a pinned number to prove it.

ALSO REMOVED THE SKIP-WITH-PASS GUARD, which was a second fail-open and the more dangerous of
the two. Each invocation was wrapped in

    if [ -f "$1/scripts/doc_surface.py" ]; then ...; else echo "not on porting-sdk main yet — skip-pass"; fi

That existed because doc_surface.py once lived only on a porting-sdk plan branch. It is on the
pinned PORTING_SDK_REF today, so the branch is dead — and its behaviour is wrong on principle:
a MISSING gate script must fail the run, not pass it. A path typo or a bad checkout would have
silently disabled the gate with a reassuring green message.

The stale comment blocks went with them; they still described a report-only gate at a
graduation-era floor ("51.8% today", "92.0% today") that no longer exists.

Per-PR rather than nightly: a pure text scan with no build, free next to the language toolchain.

Verified per port against its pinned floor, exit 0, plus `bash -n` on the modified script.
Also verified the gate can FAIL — see porting-sdk 6712757, which fixes a tolerance that made a
100% floor unfailable. That fix is a prerequisite for this commit meaning anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…id, not merely ignored

Ripples signalwire-python f171ce3 (owner-ruled 2026-07-29: "if the server doesn't read
them, remove them"). The reference commit anticipated this pass explicitly: "each port's
own create_simple_api_tool equivalent carries the SAME body-forwarding bug, so the ports
must be touched regardless."

THREE INDEPENDENT SOURCES AGREE `body` ON A WEBHOOK IS WRONG:
  * THE SPEC FORBIDS IT. porting-sdk/schema.json $defs/Webhook declares exactly ten
    properties — error_keys, expressions, foreach, headers, input_args_as_params, method,
    output, params, require_args, url — under `unevaluatedProperties: {"not": {}}`.
    `body` is not among them, so emitting it is a SCHEMA VIOLATION, not a harmless extra.
    (Re-read from the schema here, not taken on the brief's word.)
  * THE ENGINE NEVER READS IT. mod_openai/actions.c:735-739 and bedrock.c:4920-4926 read
    url, method, form_param, `params`, `headers` and nothing else; `grep -n '"body"'`
    across both returns ZERO matches.
  * THE HELPER SILENTLY DISCARDED THE CALLER'S DATA. `createSimpleApiTool` accepted a
    `body` option and forwarded it to `DataMap.body()`, writing a key nothing consumes.

REPRODUCED BEFORE FIXING, not inferred — this is a WIRE fix, not a signature edit:
    createSimpleApiTool({..., method: 'POST', body: {query: '${args.q}'}})
    BEFORE  webhook KEYS: ["body","method","output","url"]
    AFTER   webhook KEYS: ["method","output","url"]
The caller's payload was riding an invalid key into a void. Both keysets match the
reference's recorded before/after exactly.

TS SHAPE NOTE — this port uses an OPTIONS OBJECT, not python kwargs, so the removal is of
the `body?: Record<string, unknown>` property plus the `if (opts.body) dm.body(opts.body)`
forward. Post-fix the options carry the reference's 7: name, url, responseTemplate,
parameters, method, headers, errorKeys.

WHY port_signatures RECORDED NINE PARAMS. Not an extra beyond `body`: the options-object
emitter explodes the object's 8 declared properties into keyword params and appends a
synthetic `kwargs` var_keyword (the options-object idiom fold). 8 + kwargs = 9. Removing
`body` leaves 7 + kwargs = 8, and the diff tolerates the trailing var_keyword, so DRIFT
lands clean against the oracle's 7.

ALSO CORRECTED — the params() TSDoc. It read "Set query or form parameters", giving no
signal that `body` and `params` are different keys with only one of them read. It now
states the distinction and cites the schema and the engine readers. (ts did NOT carry the
reference's false "alias for body" sentence — the one that propagated into
signalwire-cpp/include/signalwire/datamap/datamap.hpp:90 — so there was nothing to undo.)

SCOPE HELD, matching the reference: `DataMap.body()`, the public BUILDER METHOD, is NOT
removed. It is recorded port surface implemented by all nine ports, and its removal is a
separately-breaking change belonging to its own decision. The three `.body(...)` builder
examples in docs/datamap-guide.md are therefore left in place.

Call sites updated — 4, all of them doc/type, none in production code:
  * src/DataMap.ts            the option + the forward (the only internal caller)
  * docs/api-reference.md     the signature block
  * docs/datamap-guide.md     the signature block, the parameter table row, and the
                              POST example that passed `body: {q, max}`
No example under examples/ passed `body` to the helper. dist/ is gitignored and untracked
here, so there is no shipped build output to regenerate.

Tests — RED first, and the red landed on the new assertions, not upstream in setup:
  * has no body option — passing one is a compile error
      type layer:    @ts-expect-error on the literal property (tsc rejects it now; before
                     the fix the directive itself errored TS2578 "Unused")
      runtime layer: a cast-through caller still gets no `body` on the wire
  * emits no body key and stays inside the schema.json webhook properties
      asserts the emitted webhook's keyset is a subset of the schema's ten

Verification:
  BEFORE 136 files / 2884 tests passed, 0 failures (measured on the stashed clean tree)
  AFTER  136 files / 2886 tests passed, 0 failures — the +2 is exactly the two new tests
  drift.sh typescript -> exit 0 (was exit 1 with the single
    `param[6] (error_keys): type 'optional<list<string>>' vs 'dict<string,any>'` mismatch
    that appeared when porting-sdk 2410c9e regenerated the oracle onto f171ce3)
  SURFACE suite -> exit 0, all 7 rules PASS
  run-lint.sh / run-format.sh -> clean (formatter a no-op on the change)
  run-ci.sh -> `==> CI FAIL (gates: BEHAVIORAL)`, 25 gates PASS, one rule red:
    BEHAVIORAL:SWAIG-HTTP-INVOKE `token_absent`. That is PRE-EXISTING and NOT from this
    change — d47fbb4 left it deliberately red as a real fail-open finding belonging to the
    separate 7c2f253 secure-tool-requires-a-token rollout across seven ports. Confirmed by
    re-running the rule on clean HEAD with this change stashed: identical single failure,
    `oracle {handler_invoked: false, refused: true}` vs
    `typescript {handler_invoked: true, refused: false}`.

SEMVER-DIFF now reports the removal as BREAKING against the last release (3.0.0), required
bump = major, actual = none. The rule is report-only so it still passes, but the entry is
correct and should be settled with the coordinated release bump, not by re-adding the key.

The known ts artifact-checkout hazard did not bite: port_signatures.json still carries the
7-param shape after the full run-ci (suites/surface.py now snapshot-restores pre-run bytes
via TreeGuard instead of `git checkout`, which is what used to revert an unstaged regen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
mjerris and others added 30 commits August 6, 2026 14:04
SIGNATURES-FRESH and DOC-SURFACE-FRESH both compare a fresh enumeration against
`git show HEAD:<artifact>`, so the preceding SWAIG regen staled both the moment
it landed. Regenerated with the port's own enumerators, nothing else touched:

  npx tsx scripts/enumerate-signatures.ts  --out    port_signatures.json
  npx tsx scripts/enumerate-doc-surface.ts --output docs_audit_surface.json

Both diffs are exactly the previous commit's four field changes and nothing
more. port_signatures.json: SwaigAction.SWML and SwaigRequest.SWMLCall/SWMLVars
appear; extensive_data and functions_on_speaker_timeout go bool ->
union<string,bool>. docs_audit_surface.json: the same three member names, plus
its `generated_from` provenance line.

The enumerators were run with --out/--output explicitly. Without the flag
enumerate-* writes nothing and still exits 0, which leaves a stale oracle
looking like a clean no-op diff; the 99-module / 2445-method write line is the
receipt that real content landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
…prose

getVerbDescription is an accessor over whatever description text the schema
happens to carry. Descriptions are editorial, owned by the docs pipeline
(relocated to api-reference-specs on 2026-08-07), not wire facts owned by this
SDK. Two of the three assertions encoded editorial content as a port contract:

  - `expect(desc).toContain('End the call')` for hangup pinned the literal
    English. A copy edit in another repo -- "Terminates the call" -- reds this
    port for a reword that changed no behaviour.
  - `expect(desc.length).toBeGreaterThan(0)` for tap REQUIRED tap to be
    enriched. If docs declines to enrich a verb (deprecated, internal, not yet
    written), the port fails on an editorial decision made elsewhere. Per the
    owner: "it shouldn't fail without the enrichment if docs chooses to not
    enrich for example a deprecated."

Both are now behavioural and driven from the fixture rather than from prose
spelled out in the test: for every verb the schema enriches, the accessor must
surface exactly that schema text; for every verb it does not, ''. Those hold
whatever docs writes, and they are strictly stronger than what they replace --
`toContain('End the call')` passes on a truncated or padded description, an
exact fixture comparison does not.

Adds the case the original trio never covered: a verb that EXISTS but is
deliberately NOT enriched must return '' and must not throw. The bundled schema
enriches all 39 of its verbs, so this loads a copy with one verb's description
stripped, and checks that dropping the prose degrades nothing else (the verb
still lists, still exposes properties, still validates).

The un-enriched block guards against testing the wrong schema. loadSchema()
falls back to the bundled schema SILENTLY on a load failure while `schemaPath`
still reads back whatever was passed in -- a bogus path yields both the bogus
readback and the full 39-verb bundled schema -- so the guard asserts the loaded
CONTENT, not the path readback, which would have been vacuous.

`toBe('')` for an unknown verb was already correct accessor behaviour and is
kept as-is.

Negative-controlled: three seeded accessor defects (return undefined; return the
neighbouring verb's text; truncate the text) each drive these tests RED (3, 2,
and 1 failures respectively) and pristine restores GREEN; the silent-fallback
guard was itself controlled by pointing it at a missing path.

Suite: 133 files / 2815 tests before, 133 / 2821 after, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
…pt the string form

GEN-FRESH-SWAIG was stale on this branch. The prior regen here (297c5ef) picked
up the dea604b vendoring; porting-sdk has since re-vendored swaig-specs from
mod_openai at 58e1e00 ("contexts/steps grammar, named prompt keys") and nothing
regenerated this port against it. `generate-swaig-payloads --check` exited 1
before this commit and exits 0 after.

WIRE-SURFACE CHANGE, not a reformat, and narrow: six params that were `boolean`
now also accept `string`, because the re-vendor gave each a
`type: ["boolean","string"]` (or, for the two that already carried a literal, an
extra `type: "string"` arm in its `oneOf`).

  ContextSwitchAction.consolidate            boolean -> boolean | string
  ContextSwitchAction.full_reset             boolean -> boolean | string
  PlaybackBgAction.wait                      boolean -> boolean | string
  TransferAction.summarize                   boolean -> boolean | string
  SwaigAction.extensive_data                 boolean -> boolean | string
  SwaigAction.functions_on_speaker_timeout   boolean -> boolean | string
  SwaigAction.back_to_back_functions         + string  (keeps 'forever')
  SwaigAction.wait_for_user                  + string  (keeps 'answer_first')

The string arm is real wire behaviour, not laxness. Quoting the x-truthiness the
re-vendor attached to back_to_back_functions (swaig-specs/swaig-response.yaml:75):
read with cJSON_FSTrue (step_management.c:1009) — the JSON literal `true`, or a
string case-insensitively one of `yes`, `on`, `true`, `t`, `enabled`, `active`,
`allow`, or any numeric string whose integer value is non-zero. A JSON `false`
does NOT trigger it, and neither does a JSON NUMBER: the string arm reads
`valuestring`, which is NULL on a number — so `"1"` fires the action and `1` does
not. Typing these `boolean` alone made the accepted string form unrepresentable.

NOT INCLUDED: src/swml_verbs_generated.ts
==========================================
The working tree this came from also carried a regenerated
swml_verbs_generated.ts (158 interfaces -> 74). That emit is CORRECT and
byte-reproducible, but it is deliberately left out, because committing it would
trade one red gate for two:

  GEN-FRESH-SWML  wants it to match porting-sdk/schema.json, now 60 $defs
  DRIFT/SURFACE   want it to match the Python oracle, still 169-$defs-shaped

The Python reference has not been regenerated against the canonical schema —
signalwire-python's swml_verbs_generated.py still declares 159 classes incl.
AIObject, and python_surface.json records 158 in that module. Measured on this
exact base: committing the regen takes SURFACE from exit 0 (all 7 rules PASS) to
exit 1 with DRIFT + SURFACE-FRESH red — SURFACE-FRESH reporting 118 differing
leaves, all of the form `classes.AIObject[0]: committed='SWAIG' fresh='<absent>'`.
That is an upstream fan-out (python first, then the ports), not a TS-local fix,
and it is owner-held. GEN-FRESH-SWML stays red here meanwhile.

The 158 -> 74 deficit is a property of the SCHEMA, not of this generator —
verified by running the generator unmodified against the legacy 169-$defs
schema.json (porting-sdk 756ea55): it emits 158 interfaces + 34 types, and its
symbol set is identical to the committed file's, 192/192.

Verified: vitest 139 files / 2932 tests passed, tsc --noEmit exit 0,
run-lint.sh exit 0, run-format.sh a no-op on the generated output,
generate-swaig-payloads --check exit 0, and the SURFACE parity suite fully green
(exit 0: SIGNATURES/DRIFT/SURFACE-FRESH/SURFACE-DIFF/GEN-TYPE-DEGENERACY/
GEN-IDIOM/SEMVER-DIFF all PASS) — this change is surface-neutral.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbNcuqH8o9WEoEnT24q4wB
…ktrees

`npm test` failed one suite that passes 7/7 in isolation. Two independent
defects compounded; both are fixed here.

1. tests/SecurityConfig.configFileTls.test.ts built its fixture dir as
   join(process.cwd(), '__config_file_tls_test_tmp__'). cwd is a property of
   the RUNNER, not of the file, so every copy of this test resolved to the
   SAME directory. It is now derived from the test file's own location and
   made unique per run with mkdtemp into the gitignored .sw-tmp/ -- the
   convention already used by SchemaUtils.verb.test.ts. Two concurrent copies
   can no longer collide by construction.

2. vitest.config.ts declared no `include`, so vitest's default glob swept the
   whole tree -- including sibling agent worktrees under .claude/worktrees/*.
   Measured: 955 files collected, 816 of them (85%) belonging to OTHER
   checkouts, among them four copies of this suite, all EXECUTED. Four copies
   then raced on the one fixture path until one copy's afterAll rm -rf deleted
   it mid-openssl.

Chose include over exclude deliberately. An exclude list is open by
construction: it bars only the directory shapes we thought to name, so the
next stray checkout starts running again and the resulting failure looks like
a flake, because whether the suite is green depends on what else happens to be
on disk. An include is closed: every test this repo runs lives under tests/.

Neither fix serialises anything -- fileParallelism stays on. Isolation comes
from scoping the path each run owns.

Verification:
  - before, 4 copies in one run: Test Files 3 failed | 1 passed (4)
                                 Tests 14 failed | 14 passed (28)
  - after, same sibling worktrees present: Test Files 1 passed (1)
                                           Tests 7 passed (7)
  - fix (1) controlled independently by forcing all 4 on-disk copies into one
    run via a temporary config: the fixed copy never appears in a FAIL line,
    and all 6 fixture-path errors cite the old shared __config_file_tls_test_tmp__,
    never the new per-run swts_config_file_tls_* path.
  - collected files 955 -> 139: 0 added, 816 dropped, and 0 of the 816 lie
    outside .claude/worktrees/. No legitimate test stopped running.
  - full suite: Test Files 139 passed (139), Tests 2932 passed (2932)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbNcuqH8o9WEoEnT24q4wB
… engaged

verbTopLevelPropertyNames tested `body['type'] !== 'object'` on a verb's config
node and returned null otherwise. A union node — `{ anyOf: [...] }` — carries no
`type` of its own, so that test failed and the resolver bailed. The caller reads
null as "no key-set to enforce" and answers valid. The check therefore did not
report a problem; it stopped checking and reported success, which is strictly
worse than failing.

This was live against the shipped schema.json, not contingent on any future
re-vendor. Five verbs are union-shaped there:

  connect   oneOf of 4 $refs (ConnectDeviceSingle/Serial/Parallel/SerialParallel)
  play      oneOf of 2 $refs (PlayWithURL / PlayWithURLS)
  send_sms  anyOf of 2 $refs (SMSWithBody / SMSWithMedia)
  sleep     anyOf of object-with-duration / integer / SWMLVar
  unset     anyOf of string / array-of-string

Four of the five have object branches with perfectly enumerable keys, and the
resolver was returning null for all four.

The resolver is now a shared recursive closedKeySet handling three node shapes:
a $ref (followed into $defs), a union (resolved branch by branch and UNIONED),
and a plain closed object. The union semantic is the correct one for this
schema shape: a config satisfying an anyOf/oneOf satisfies SOME branch, so a
key belonging to no branch belongs to no valid document. Non-object branches
(sleep's bare integer, SWMLVar) contribute no keys — they constrain the config
to not be an object at all, a different question from which keys an object
config may carry. A depth bound keeps a self-referential $ref from spinning.

Shapes with genuinely no closed key-set stay disengaged, and are pinned by test
so this is not read as "always enforce something": `set` is an OPEN object
(unevaluatedProperties:{} with no `not`, zero declared properties — a free-form
variable bag by design), `unset` is a union with no object branch, and
cond/label/return are array/string/untyped.

Engaged verbs go 30 -> 34.

SEPARATE DEFECT FOUND, NOT FIXED HERE — connect is unvalidated on the Ajv path.
Ajv cannot compile the connect verb at all: connect's `result` reaches
$defs/SWMLAction, whose `SWML` property is `{"$ref": "SWMLObject.json"}`, an
external file absent from the bundled schema's $defs. Ajv resolves refs eagerly
and throws "can't resolve reference SWMLObject.json from id #";
getVerbValidator swallows that in its catch and returns null, and validateVerb
falls back to validateVerbLightweight — which finds no top-level `required` on a
union node and passes ANY config. So `connect` accepts arbitrary keys end to end
for a reason unrelated to the union resolution fixed here. Fixing it means
resolving or vendoring SWMLObject.json (a schema-artifact change) or changing
Ajv's ref-resolution policy, neither of which belongs in this commit. Go does
NOT share the symptom: it compiles the whole document with santhosh-tekuri,
which tolerates the unresolved ref, and rejects the same forbidden key. The
test file documents this in place of asserting a behaviour that is broken for
an unrelated reason.

Negative control, both directions, on this branch:
  pre-fix  12 of 21 tests FAIL (all four union verbs, all three directions)
  post-fix 21/21 PASS, including the legitimate-config direction that would
           catch an intersection computed in place of a union.

The same defect is present in the go and dotnet ports, which hand-maintain an
equivalent resolver with the identical bail.
…tion

`validateVerb('connect', {to, zzz_not_a_real_key})` returned
`{"valid":true,"errors":[]}` — a config nobody validated came back clean.

The bundled schema.json holds exactly one non-local `$ref`:
`$defs/SWMLAction.SWML -> "SWMLObject.json"`, a sibling spec file that is not
bundled. Ajv resolves refs EAGERLY, so compiling any verb whose `$defs` subtree
reaches `SWMLAction` threw `can't resolve reference SWMLObject.json from id #`.
`getVerbValidator`'s bare `catch` swallowed that and returned null, and
`validateVerb` then fell through to `validateVerbLightweight`, which only checks
required props — so a COMPILE FAILURE read as a PASS.

The blast radius was never just `connect`: 8 of 39 verbs degraded this way, all
via `... -> Action -> SWMLAction -> SWMLObject.json` —
ai, ai_sidecar, amazon_bedrock, cond, connect, execute, join_conference, switch.

Two independent fixes:

1. Ref policy (the root). Register a permissive placeholder for the schema's
   unbundled external ref on our own Ajv instance, so the ref RESOLVES and
   compilation succeeds. Every other constraint — crucially the
   `unevaluatedProperties` closure that rejects unknown keys — is then enforced
   normally; only the nested `SWML` payload goes unchecked. This is exactly how
   go's santhosh-tekuri validator already behaves, which is why go never showed
   the symptom. schema.json is NOT modified, vendored, or reinterpreted.

2. Loud degradation (the backstop). A compile failure is now recorded and
   reported as an error instead of falling through to the permissive lightweight
   check, so validation that did not happen can never again read as `valid:true`.
   New `compileFailedVerbs` / `precompileVerbValidators()` make the condition
   observable rather than silent.

Both directions covered by 9 regression tests: a forbidden key is rejected and a
legitimate config still passes on each of the 8 verbs, and the guard is proven by
reinstating the pre-fix Ajv instance and asserting a refusal rather than a pass.
Negative-controlled — with the ref policy disabled, 9 of them fail.

Full suite: 133 files / 2835 tests passed. SURFACE's DRIFT/SURFACE-FRESH/
SURFACE-DIFF failures reproduce identically on clean main and are unrelated.

Supplying the real SWMLObject.json remains an owner-held schema-artifact change
(held under #223); afterwards the placeholder in UNBUNDLED_EXTERNAL_REFS can be
dropped and the nested SWML payload becomes fully validated too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbNcuqH8o9WEoEnT24q4wB
GEN-FRESH-SWML could exit 0 having compared nothing. generate-swml-verbs.ts
guarded both porting-sdk spec reads with fs.existsSync and fell through to a
friendly "skipped ... using committed <file>" log. Under --check that is a false
green: the skip means emitFile is never called for that output, so nothing lands
in staleFiles, so finalizeCheck's `if (CHECK && staleFiles.length)` is false and
the process exits 0. The committed file is never compared against anything.

Measured on this repo with PORTING_SDK pointed at a copy whose only difference is
a removed schema.json:

  before  rc=0
          checked src/SwmlVerbMethods.generated.ts (39 verb methods)
          checked src/PlatformContracts.generated.ts (9 types)
          skipped SWML verb contracts (no schema.json at .../schema.json; using
          committed src/swml_verbs_generated.ts).

All 192 committed SWML verb config types went uncompared while the gate reported
success. This matters now: porting-sdk/schema.json is the legacy hand-maintained
SWML spec slated for deletion (porting-sdk task #199). On the day it is removed,
the other nine ports' generators die loudly and this one would have gone green.

Both spec sources are TRACKED files in porting-sdk, so once psdk itself resolves
they are present in every legitimate configuration — there is no caller that
genuinely needs the skip. The one real soft-fail case (a published consumer with
no adjacent porting-sdk) is the separate `!psdk` branch above, which already
treats an unverifiable --check as a hard failure (exit 2). These two guards were
the inconsistent ones; they now match that precedent. No opt-out flag is added,
because no legitimate caller needs one.

Non-check generate runs fail hard too: emitting a partial tree while exiting 0
would silently leave a committed file at a stale revision.

  after   rc=1
          generate-swml-verbs: SWML verb contracts: spec source not found at
          .../schema.json. Refusing to skip — under --check a skip would leave
          the committed src/swml_verbs_generated.ts compared against nothing and
          still exit 0 (a false green).

The happy path is unchanged (rc=0, all three files checked: 39 verb methods /
9 platform types / 192 verb config types) and the committed generated files are
NOT stale — no generated output is modified by this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
… not drift

Measurement-only lane. No generated file, generator, or oracle is modified.

The row recorded signalwire-typescript as "GEN-FRESH stale: 192 exported symbols
vs the generator's 78" and invited four readings: port drift, generator
regression, a units error, or a stale pin. Re-deriving both numbers:

  COMMITTED exports: 192  {'interface': 158, 'type': 34}
  FRESH     exports:  78  {'interface': 74,  'type': 4}

Both are src/swml_verbs_generated.ts, one file, measured by one regex — the
generator's own "(78 types)" stdout is decls.length, and swmlDeclaration() emits
exactly one top-level export per decl, so the fresh file measures 78 both ways.
Like-for-like: YES. The numbers survive; the LABEL on them did not. They were
never "port surface vs generator surface".

Root cause is upstream and mechanical. porting-sdk/schema.json went 169 $defs ->
63 at psdk fb6defc (2026-08-07, "drop the hand-edit and re-vendor swml; the
extractor derives 11"), then -> 60. This port's file was last written 2026-07-26
by d30dcd4, from the 169-def era. The generator input path never moved
(generate-swml-verbs.ts:637 has read psdk/schema.json since f41d446).

Three hypotheses tested and falsified, each by running the generator:
  - today's re-vendor 203eb90 — regen against 203eb90^:schema.json also yields 78
  - HEAD 0c7f923 — its generator AND its parent's both yield 78; that commit
    REVEALED the staleness by making an fs.existsSync skip fail loudly instead of
    exiting 0 having compared nothing
  - port drift — zero references to the module anywhere outside src/ and two
    oracle JSONs, so no hand-written code could have grown it

Also corrected: the rule literally named GEN-FRESH is GREEN. Two other rules in
the five-rule family are red — GEN-FRESH-SWML (this) and GEN-FRESH-SWAIG (two
added optional fields, purely additive).

INVENTED count: 0. All 139 lost symbols are STALE-GENERATED — 126 are $defs keys
in the 169-def schema the port still vendors at src/schema.json, and the other 13
are <Verb>Config names the generator synthesizes at generate-swml-verbs.ts:188.
Classifying on $defs membership alone would have produced 13 false INVENTED
findings, the most serious class.

Blast radius of a regen, if it is ever run: it deletes 139 exported type names
and adds 25. Not a package-API break — nothing under src/ imports the module and
src/index.ts does not export it — but 59 of the 139 are cited as declared types
in port_signatures.json, so the surface oracle moves. Export-reachability and
gate-reachability are independent questions here.

Not a stale pin: no pin exists. nightly-multi-os.yml:102 floats to psdk main.

Fleet: go carries the identical 192 decls from the identical 2026-07-26 date, so
this is a ten-port spec-fanout event, not a typescript defect — exactly what
porting-sdk/scripts/spec_fanout_gate.py:9 predicts.

Left unapplied deliberately. Whether fb6defc's shrink is INTENDED to remove 139
SDK type names, or the new extractor under-derives, is an upstream spec question;
answering it in one port answers it for all ten. No allow-list entry is
appropriate — there is no divergence, only a not-yet-run regen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXK6fU1ASoT14yniFHhWRh
…op the widen relaxation

The bundled schema listed three hangup reasons (hangup|busy|decline) and marked
the node `x-sdk-widen: true`; SchemaUtils read that marker and stripped the
value constraint before compiling the Ajv validator. So every string validated,
including ones the server refuses.

The engine's contract is stated once, in C, at
mod_infrastructure/relay_apis.c:1105:

    JSON_CHECK_STRING_MATCHES_OPTIONAL(reason, "hangup,cancel,busy,noAnswer,decline,error")

and a non-match is a hard reject (libks ks_json_check.h sets *error_msg and
returns 0). The SWML layer types the field as a bare string
(swml_schema.c:1571) and swml.c forwards it verbatim into the `end` RPC on the
same call, so the contract a document must actually satisfy is the COMPOSITION
of the two layers — exactly these six values.

Two ordered changes, because either alone is a regression:

1. src/schema.json's hangup.reason node now publishes the six engine values as
   a real enum and no longer carries x-sdk-widen. Three of the six (cancel,
   noAnswer, error) were absent from the old three-const union, so they
   validated ONLY because widen removed the constraint — deleting the reader
   without this would have turned three engine-valid reasons into client-side
   rejections. The edit touches only that node.

2. applyWiden and its call site in SchemaUtils are deleted. The
   `x-sdk-widen` suite in tests/SchemaUtils.verb.test.ts asserted that
   'user_hangup', 'no_answer' and 'anything-at-all' must validate, and the
   SwmlBuilder block asserted an arbitrary off-enum reason is accepted;
   relay_apis.c:1105 refuses all of them, so those rows pinned a bug. Both are
   replaced with suites that pin the engine contract in both directions. Note
   the engine spells it camelCase "noAnswer"; "no_answer" is not an engine
   value in any spelling.

The behaviour change is INTENDED and STRICTER: a caller who passed "no_answer"
previously got an opaque server-side rejection at call time and now gets a
clear client-side validation error. A guard on the artifact is included so a
re-vendor that reintroduces the three-value union or the marker is caught.

scripts/_gen-common.ts's x-sdk-widen branch is untouched: it reads the spec
YAML (where the marker remains as an upstream audit record) and affects the
emitted TS type, not accepted values. The generated
`swml_verbs_generated.ts` already declares the six-value union, so the type
layer and the validator now agree.
…protocol/

Ninth and last of the nine relay-protocol generators to move off the legacy
`porting-sdk/relay-protocol/` tree (130 standalone JSON-Schema files) and onto
the single combined document `porting-sdk/combined-specs/relay.yaml`.

APPROACH: the reader is PORTED to TypeScript (`scripts/_relay-shapes.ts`), the
way Go ported it, rather than shelling out to the Python reader the way the six
interpreted ports do. This generator is one of five siblings that all read their
spec in-process with `js-yaml` — already a direct dependency of `_gen-common.ts`.
Spawning `python3` would add a cross-language runtime dependency (interpreter +
PyYAML) that no other TS generator carries, and that `resolvePortingSdk()`'s
deliberately fail-soft contract could not degrade around. The Python module is
the spec this file implements; the two are kept honest by GEN-FRESH-RELAY
byte-comparing the emitted output. Verified equivalent: both readers return the
same 128 nodes, in the same order, byte-identical.

OUTPUT IS NOT BYTE-IDENTICAL, in exactly three understood ways. All 128
declarations are present, name sets identical, and ZERO have any body or type
difference:

1. The header line names the new source (intended).

2. Two declarations REORDER: CallingPlayResult/CallingPlayPauseResult and
   CallingRecordResult/CallingRecordPauseResult. The legacy generator sorted
   FILENAMES, where `calling.play.pause.result.json` precedes
   `calling.play.result.json` because '.' (0x2E) < 'r'. The adapter sorts METHOD
   NAMES, where `calling.play` precedes `calling.play.pause`. The old order was
   an artifact of the filename encoding; the new one is the real method order.

3. Six declarations lose their TSDoc summary. The legacy per-file tree carried a
   `description` on each schema; the combined document does not, because that was
   a per-file ENVELOPE key that existed only so a standalone schema could
   re-declare its own identity. But that prose was never hand-written — the
   extractor GENERATED it from a template over method, phase and the source class
   name, and `x-source-file` still carries the class name. So the summary is
   DERIVED from the surviving metadata rather than lost or frozen into a
   port-local prose table: 122 of 128 reproduce their legacy description EXACTLY,
   zero mismatches. The remaining 6 (calling.call, calling.conference and
   messaging.send, both phases each) carried genuinely hand-written prose that
   the combined document does not carry in any form. Those emit with no TSDoc
   rather than having a port invent spec text; the document does carry different
   prose for 4 of them under `unknown_note`, which is a spec-side question, not
   a port-side one.

The camel-case fix at psdk 2e5be42 does unblock ts as briefed — 128 declarations,
0 empty bodies, 124 index signatures, `tsc` clean. But the brief's claim that the
missing-`type`/`description` diagnosis was "false" does not survive: those keys
are absent from ALL 128 nodes the generator consumes. The 2419/11/1 counts were
over the whole document, not these nodes. Camel was necessary, not sufficient.

`relay-protocol/` is left in place — other consumers may still read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
…rried in

Local FMT autocorrect from run-ci; committed so the CI --check agrees.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4oecKxqh9QZoLSvdUQwZ7
…orBody

RestError.body (and HttpClient's parse of a non-2xx body) was typed
`string | SignalWireErrorBody` -- a SWML-webhook contract type unrelated to
REST errors -- via an unchecked `JSON.parse(text) as SignalWireErrorBody`.

Type it as what the code actually holds: the raw text, or the parsed JSON
value, naming the error shapes the REST specs declare --
Types_StatusCodes_StatusCode422 ({errors: RestApiErrorItem[]}), relay-rest's
Types_StatusCodes_ValidationError ({errors: SpaceApiErrorItem[]}), and
{error: '<reason phrase>'} (400/401/403/404/500) -- plus any other JSON value,
which is kept as-is like the python reference (body: Any). Every JSON value is
a member of the type, so the parse cast is now sound. No runtime change, no
new public symbol.

Tests use the real wire shapes (both envelopes + an off-spec JSON body the
mock server emits) instead of casting {errors: ['invalid field']}.
port_signatures.json: only the 3 SignalWireRestError body entries change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
… contract; drop SignalWireErrorBody

Coordinated with porting-sdk a8b7eaa, where rest-apis/swml-webhooks/openapi.yaml became a
rendering of mod_infrastructure's engine-derived `webhook_request` contract.
- src/PlatformContracts.generated.ts regenerated (scripts/generate-swml-verbs.ts): closed
  SwmlRequestData (vars required), SwmlRequestCall = union of the Phone/Sip/Webrtc/Other
  variants (call_id/node_id/call_state/direction required, closed enums), Parent/Peer,
  header items, SipSipData. The superseded SwaigRequestData/PostPrompt* and
  SignalWireErrorBody leave the generated file with the spec.
- SignalWireErrorBody re-exports removed (src/PlatformContracts.ts, src/index.ts); no
  users since 9b9ba52 (owner-approved removal).
- untyped-JSON boundaries (AgentBase, SWMLService, InfoGathererAgent) that hand a parsed
  body to SwmlRequestData-typed hooks now say so with an explicit cast: the type is
  static-only and the body is not validated; runtime behaviour is unchanged.
- port_surface.json / docs_audit_surface.json: only the swml_webhooks_types entries
  patched, from a fresh enumeration against porting-sdk a8b7eaa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
…ix jargon + example to the call union

Follow-up to f388483, whose closed/required emission broke LINT (examples + tests
typecheck) and PUBLIC-JARGON on TS Test run 36405130242:
- scripts/generate-swml-verbs.ts: the platform contracts are a READ-SIDE payload, so they
  are declared with swmlDeclaration (all fields optional + `[key: string]: unknown`), the
  same convention as the SWML verb read-side types and Python's total=False TypedDicts.
  Field TYPES stay engine-derived (closed enums, SwmlRequestCall = the per-device-type
  union). Existing handler signatures and partial bodies type-check unchanged, so the
  explicit casts f388483 added in AgentBase / SWMLService / InfoGathererAgent are
  reverted (those files are back to 9b9ba52).
- examples/advanced-dynamic-config.ts: `call.from` is read through the union (the
  `other` variant carries no `from`).
- src/PlatformContracts.ts: doc comment no longer names an internal repo (PUBLIC-JARGON).
port_surface.json / docs_audit_surface.json entries re-verified against a fresh
enumeration (unchanged from f388483).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
…po variable

The coordinated-pass pin was the repo variable PORTING_SDK_REF, which every
branch's CI reads -- main and everyone else's PRs were checked out against our
in-progress porting-sdk branch. The variable has been deleted; the pin now lives
in a file committed only on the coordinated branch:

- .porting-sdk-ref (this branch: wave6/ctor-dunder-fold). No file -> main, so
  main and every other branch are unaffected. DELETE it in the PR that merges
  the coordinated set (porting-sdk/COORDINATED_PASS.md).
- .github/coordinated-ref.sh: byte-identical copy of porting-sdk's canonical
  resolver. A `coord` step after the repo's own checkout validates the pin
  (branch-name characters only) and emits ref / ref_<name> outputs.
- every porting-sdk / signalwire-python checkout takes
  ${{ steps.coord.outputs.ref }} / ref_python instead of the variable; publish
  and release workflows stay hard-pinned to main.
- run-ci.sh: COORDINATED-REFS description + comments name the pin file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
The branch-local pin (.porting-sdk-ref, optional .signalwire-<name>-ref) moves from the
repo root into .github/ so signalwire-python's ROOT-HYGIENE stays strict with no
allowlist entry (owner, 2026-09-29). Re-copies the canonical .github/coordinated-ref.sh,
which now reads .github/porting-sdk-ref and fails on a pin at the retired root spelling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
Brings PR #179 current with main: 253 commits, the shipped 3.5.0 baseline
(Python 3.5.1 sync, security fix pass, AI Chat gateway/handoff router,
swaig-test DataMap simulator, sw-tsdocs, tutorials). 40 files conflicted.

Resolutions, by area:

- package.json / package-lock.json / CHANGELOG.md: main's. 3.5.0 is published
  (tag v3.5.0 on main), so the branch's 3.0.0 consolidation of the 3.0.2/3.1.0/
  3.2.0 entries no longer describes history; the branch changed nothing else
  in these files.
- DataMap.body() + createSimpleApiTool({ body }): main's. The pinned oracle
  (porting-sdk wave6/ctor-dunder-fold 2fc3bf2) has DataMap.body and
  create_simple_api_tool(body=) again; main's body() writes `params`, the key
  the platform reads. Branch's removal tests and docs dropped with it.
- addLanguage / LanguageConfig: combined. Branch: voice REQUIRED (schema
  `LanguagesWithFillers.required`, reference required positional) and the
  "engine.voice:model" split. Main: flat speechFillers/functionFillers with the
  older keyed forms and `fillers` still accepted (flattened, warned). Emission
  order keeps the branch's (fillers after speech_model). The tutorial's
  AgentConfig.languages now types voice as required to match.
- AgentBase: secure-tool token check takes main's swaigTokenRefusal() (same
  absent-token refusal and reference wording as the branch); contexts take
  main's raw-dict path (ai.prompt.contexts, both sides agree); onSummary keeps
  the branch's optional rawData with main's widened return type;
  getBasicAuthCredentials keeps the `= false` default and adds main's
  'config file' source. debug_webhook_url/_level stay inside `params` (branch,
  matches agent_base.py); docs updated to say so.
- defineContexts: a supplied dict is still stored in the prompt manager; a
  supplied ContextBuilder is no longer snapshotted there, since snapshotting an
  empty builder throws and main's callers fill the builder after passing it.
- SWMLService config file: the branch's snake_case `security` section reader,
  with main's semantics folded in (empty values set nothing, basicAuthSource =
  'config file', legacy `security.basicAuth` still read).
- SchemaUtils: main's document-schema compile for SWML-embedding verbs (real
  validation) replaces the branch's permissive SWMLObject.json placeholder;
  the branch's loud compile-failure record is kept and shared through main's
  COMPILED cache.
- ServerlessAdapter, swaig-test, BedrockAgent: main's (branch changes there were
  doc-only, or superseded: main's fromEvent reads rawQueryString).
- WebService.start: main's credentials refusal + the branch's `host = '0.0.0.0'`
  parameter default.
- InfoGathererAgent: main's request-derived query/headers with the branch's
  optional rawData. Prefab/skill/ai-chat/livewire doc conflicts: main's facts,
  without the comparisons to other SDKs the branch removed.
- scripts/enumerate-signatures.ts: both module mappings; the branch's
  srcRelStem() (main's equivalent srcSubpath() dropped).
- PORT_OMISSIONS.md / PORT_SIGNATURE_OMISSIONS.md: the branch's deletions plus
  the entries main added for symbols main introduced (no new entries authored).
- Tests: both sides' coverage kept; three branch tests adjusted for main's
  behaviour (WebService needs credentials; mcp_gateway's TLS flag is
  _insecureTls and setup() runs an SSRF check first), one main test adjusted for
  debug events riding in params (no longer a Bedrock left-out feature).

Regenerated against porting-sdk 2fc3bf2 (the pinned wave6/ctor-dunder-fold):
port_signatures.json, port_surface.json, docs_audit_surface.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…at 434/435

main's src/cli/simulation.ts exports ServerlessSimulator without a TSDoc block;
main's DOC-SURFACE floor is 77.8%, this branch's is 100.0%, so the merge
combination dropped coverage to 434/435 and DOC-SURFACE went red (run
36813922191). Locally: 100.0% (435/435).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…held until wave6 lands

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…pec markup

Generators (scripts/):
- _gen-common: an empty CLOSED object (`properties: {}` + additionalProperties:
  false, verto.attach/verto.pong results) emitted `export interface X
  Record<string, unknown>`, a syntax error that broke the package build. A
  memberless top-level object is now a type alias (`Record<string, never>` when
  closed).
- generate-rest-types reads rest-apis/<ns>/openapi.enriched.yaml (owner ruling
  2026-09-28, the reference generator's input); discovery stays on openapi.yaml.
- x-sdk-autofill: uuid4 (control_id is generated when omitted) and
  x-sdk-compat-kwargs (calling.record `audio` -> params.record.audio), mirroring
  generate_python_rest_types.py.
- Embedded path params (`{id}.mp3`), redirect-answer GETs (recordings/room
  recordings download, billing_statement.pdf -> the Location URL, never
  followed), text successes (billing_statement.csv), and `in: header` params
  (space top-up Idempotency-Key).
- PAT-authenticated specs (rest-apis/space) are wired to a separate
  Personal-Access-Token HttpClient (`_wireResources(http, patHttp)`).
- POSITIONAL_COMPAT: the spec relaxing/reordering required fields would have
  moved published positionals (dial(from, to), createGuestToken, Messages.update,
  ShortCodes.update, VideoStreams.update, aiStop, tap order, createOrder's
  options) -- pinned to the v3.5.0 shapes so published calls keep working.

SDK: RestClient `personalAccessToken` (SIGNALWIRE_PERSONAL_ACCESS_TOKEN) for
client.space, a refusing stand-in for a missing credential; HttpClient
getText / getRedirectLocation / per-call headers. relay Call.record audio type
follows params.record.audio.

Tests/examples/docs: createInviteToken (never worked; hidden) removed; tap /
play / refer use the shapes the gateway accepts (relay_apis.c JSON_CHECK);
fabric call_flows/conference_rooms plural paths; tests for autofill, compat
kwarg, published positionals, redirect download, and PAT auth.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…the regen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
- src/schema.json re-bundled from porting-sdk (port_schema_bundle.py sync) with its
  schema.json.sha256 record; new SCHEMA-BUNDLE gate in run-ci (cheap tier).
- generate-swml-verbs: deprecated verbs (dial/eval/if) are dropped and inline objects
  are hoisted to named types exactly as the reference generator names them
  (AiConfig, AiSWAIGInternalFillers, ...); the SWAIG envelope types are imported from
  SwaigActions.generated.ts instead of declared twice. Verb methods take the named
  config type plus the scalar/positional forms the body accepts, always optional
  (never narrower than before).
- _relay-shapes: serve only the switchblade-carried shapes (extracted_by stamp), as
  relay_protocol_shapes.py does; the 9 engine verto.* results are not surface.
- SchemaUtils: deprecated verbs stay known to validation but are not installed as
  methods; getVerbProperties returns the verb's object form when its body is a
  union of forms; the closed-key resolver follows the #223 contract (exactly one
  closed object arm, else disengage -- identical to union on this artifact, 47/53
  verbs engaged); Ajv gets an equivalent additionalProperties form where it
  mis-tracks unevaluatedProperties beside a multi-entry dependentSchemas (cond).
- AgentBase: the ai verb's `multilingual` validation bypass is removed (the schema
  declares it).
- FunctionResult.changeVoice (reference change_voice); SchemaUtils' unpublished
  test hooks renamed _compileFailedVerbs / _precompileVerbValidators.
- Regenerated port_signatures / port_surface / docs_audit_surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
Generators:
- _gen-common: a tuple-form array (prefixItems, no items) is the union of its
  prefix item types; the empty schema `{}` is `unknown` (py_type `Any`); in the SWML
  generator an allOf of scalar constraints is their intersection
  (_scalar_allof_intersection), so a verb's bare-scalar shorthand types as the
  scalar rather than `string & (...)`.
- generate-rest-types: an array response returns `T[]` (list_voices); a pinned
  method's non-pinned REQUIRED field is a required options member (createOrder's
  phone_numbers -- `createOrder(id, { phone_numbers })` keeps working); the `_fields`
  literal lists positional fields in the reference's keyword order; header args are
  assembled first; redirect GETs carry no per-call headers (as the reference).

Enumerator:
- Record<K, V> in generated payload interfaces keeps its value type
  (`dict<string,class:Context>`); the written-node path reads Record, unknown and
  parenthesized members; quoted wire keys (`nomatch-output`) are fields.
- A command param literally named `params` (ai_sidecar) / a body field named `body`
  (Messages.update) are keywords, not the query tail / single-body param; header
  args are keywords; positional exploded fields are recorded in reference order; a
  request_options already followed by a real param is not hoisted past it; a rest
  parameter is not required.

SDK: HttpClient get/post/getText take `options?: { headers }` (the reference's
keyword-only headers); stripControlChars also accepts the processor-call form
(last argument is the record, as the reference); filterSensitiveHeaders is generic
over the header value type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…Aliases never are)

The python reference emits every non-object schema as a module-level TypeAlias --
456 of them across its generated type modules -- and griffe records none of them
(0 in python_surface.json). The TS enumerator surfaced the port's matching
`type X =` aliases, which only manufactured port-side additions; it now skips them
in generated type files, and the 82 PORT_ADDITIONS entries that existed only to
excuse those aliases are deleted (SURFACE-DIFF reported each as DEAD).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…te removed

- rest/docs/client-reference.md + the RestClient TSDoc document the Personal Access
  Token credential (SIGNALWIRE_PERSONAL_ACCESS_TOKEN) and client.space (DOC-ENV).
- docs/DISC-g-tsfresh.md deleted: an internal diagnosis note (machine paths, python
  fences, phantom names) about the stale SWML artifact this regen replaces
  (ROOT-HYGIENE / DOC-AUDIT / DOC-LANG-PURITY / ACCESSOR-TRUTH).
- Public doc comments carry no internal tracking references (PUBLIC-JARGON); the
  stripControlChars implementation signature is documented (DOC-SURFACE).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
… doc snippets compile

The empty schema `{}` -> `unknown` rendering (the reference's `Any`) is now scoped to
the SWML generator, like the scalar allOf intersection; applied globally it narrowed
published types such as SwmlRequestData.params/envs from Record<string, unknown>.
Doc snippets updated to the spec's shapes (no brand_id on campaign create -- the brand
is the path; cXML applications take call_request_url) and the PAT example imports
RestClient (SNIPPET-COMPILE).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…PET-COMPILE)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…ck ignores an unlisted voice_id

The mock's strict wire check rejects brand_id on relay-rest.create_campaign, and the
engine-derived schema marks an unlisted Bedrock voice_id as ignored, not rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
…ce555f6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JK98iDuVZQ6gQBo3Zj3RAb
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