Skip to content

wave6: the reference half of the coordinated pass (supersedes #77) - #78

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

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

Conversation

@mjerris

@mjerris mjerris commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

The reference SDK's half of the wave-6 coordinated pass. 49 commits on top of main.

This supersedes #77 — that PR's head (fix/win-test-portability-tail) is a direct
ancestor of this branch, so all seven of its commits (Windows portability, the multi-OS
interpreter fix, and the build declaration PACKAGE-SMOKE needed) are already included
here. #77 can be closed in favour of this, or left open and closed on merge; it has
nothing this branch lacks.

Why the oracle had to move

Two of these commits were the root cause of a fleet-wide CI red today. Every port had been
brought to the enforced SWAIG token contract, so each correctly REFUSES an untokened
serverless call — but CI built its oracle from a reference that still executed it, and
seven ports failed BEHAVIORAL-HTTP for being right:

  • e4f66b6 — enforce secure=True on all four serverless transports. The negative-half
    corpus fixture http_serverless_lambda_swaig deliberately carries no __token; the
    reference now refuses it, matching the ports.
  • c0183cd — keep SDK logs off stdout. structlog debug lines were interleaving with the
    JSON that Layer-D dumps parse.

Both sat unpushed on a local branch, which is exactly the staleness the coordinated-pass
mechanism exists to prevent. Confirmed fixed: ruby's re-run went from
CI FAIL (gates: COORDINATED-PASS BEHAVIORAL) to a clean pass.

Also in here

  • 71eed0c — remove DataMap.body() (breaking): the builder wrote a key nothing reads.
    The fleet-wide ripple is done; all ten ports have dropped it.
  • a23c85b + the lint burn — REPO-LINT/REPO-FMT wired over tests/, scripts/ and eng/,
    which were gated by nothing. Burned 1291 -> 0 before the gate landed, so it never lands red.
  • 4371610 — validate post_prompt shape; the validator was passing configs that abort the call.
  • ee1911c — mypy 85 errors -> 11.
  • 2850adf — DOC-SURFACE floor pinned at 100%.

Verification

Local run-ci.sh is green on this branch. The gates that matter for the coordinated set —
SIGNATURES, DRIFT, SURFACE, EMISSION — all pass, and the two oracle artifacts regenerate
byte-identically.

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

anthmFS added a commit that referenced this pull request Aug 10, 2026
* fix(ci): green the gates the ai_chat/ChatGateway commits reddened

The three ChatGateway commits (8b790ea, cac3118, 20663a6) landed on main
without a full run-ci, reddening LINT, FMT, TYPECHECK and NO-CHEAT. This
fixes each at its source.

LINT (ruff 0.15.21, the pinned version)
  - ai_chat/__init__.py: RUF022 __all__ sorted.
  - search/document_processor.py: SIM102 nested if collapsed.

FMT
  - ruff format over ai_chat/gateway.py + core/function_result.py.

TYPECHECK (mypy --strict; the config puts tests in scope on purpose:
"a new untyped test fails the gate")
  - tests/unit/ai_chat/test_gateway.py shipped fully unannotated: 44
    no-untyped-def + 19 no-untyped-call. Annotated throughout.
  - FunctionResult.response widened to `str | dict[str, Any]`, so 37
    call sites doing `.response.lower()` stopped type-checking. Added
    `assert isinstance(<r>.response, str)` next to the existing
    `assert isinstance(<r>, FunctionResult)` — a real assertion that
    narrows the union, not a cast.
  - FunctionResult.hold: `bool` subclasses `int`, so excluding bools from
    the back-compat int-swap left `str | bool`. Handle bool explicitly;
    the remaining type is `str | None`. hold(120) still means
    hold(timeout=120).
  - ChatGateway.visible_messages / last_activity accept None and
    non-dict items by design (`for msg in messages or []`,
    `if not isinstance(msg, dict): continue`) and are tested for it, but
    were typed `list[dict[str, Any]]`. Widened to match the real,
    documented contract. Free to change: ChatGateway is new surface no
    port has implemented yet.

NO-CHEAT
  - Three origin tests asserted nothing ("does not raise"), so they
    passed regardless of the code. Each now pairs the allowed case with
    the refusal that proves it is an exemption and not open-by-default:
    localhost vs an unlisted origin, a listed origin vs a lookalike
    domain, absent vs present-but-unlisted.

Verified: LINT clean, FMT clean, NO-CHEAT clean, mypy clean over every
file CI reports, 5940 unit tests pass. The 6 remaining mcp_gateway
failures are pre-existing (they fail identically on unmodified main) and
env-dependent — CI passes them.

Not addressed here (deliberately): GEN-FRESH and DRIFT/SEMVER-DIFF are
coordinated-pin artifacts. PORTING_SDK_REF is set to
wave6/ctor-dunder-fold, so CI builds against that branch; the matching
regen is PR #78's half of the wave, not this branch's.

* fix(gen-fresh): regenerate generated types against the pinned wave6 specs

CI resolves porting-sdk via PORTING_SDK_REF, currently
wave6/ctor-dunder-fold, so GEN-FRESH regenerates from THAT branch's specs
and compares. The committed files were generated from main's specs, so
six reproduced differently and the gate failed.

Regenerated with the pinned ref's specs; `--check` is now clean.

Note these are NEWER than the same files on the wave6 branch itself: the
swaig specs gained `| str` on several action fields after that branch last
regenerated (e.g. `consolidate: bool` -> `bool | str`, `wait: bool` ->
`bool | str`). So this is the output current wave6 specs actually produce,
which is what CI checks against.

Full unit suite still 5940 passed; the 6 mcp_gateway failures are
pre-existing and env-dependent (they fail identically on unmodified main).
@anthmFS
anthmFS force-pushed the wave6/ctor-dunder-fold branch from 96d60a2 to 13760c3 Compare August 12, 2026 19:12
@mjerris
mjerris force-pushed the wave6/ctor-dunder-fold branch from 3931666 to 554af5f Compare September 23, 2026 23:03
mjerris and others added 25 commits September 24, 2026 21:15
…gnal handlers, skills paths

Closes the 15 Windows TEST failures left by PR #76 (which took the count
36 -> 15), measured from Multi-OS run 30261304144 / windows-latest.

One of the three defect classes is a PRODUCT bug, not a test bug.

1. PRODUCT — RelayClient.run() was broken for every Windows user (7 tests)

   _run_forever() called loop.add_signal_handler(SIGINT, ...) unguarded on its
   FIRST statement. Loop-level signal handling is a Unix-only asyncio
   capability: both Windows event loops raise NotImplementedError
   unconditionally, so the exception escaped before connect() was ever reached
   — RelayClient.run() could not establish a RELAY connection on Windows at
   all. Guarded with a NotImplementedError fallback that degrades to
   KeyboardInterrupt-driven shutdown (Ctrl+C still stops the client; only the
   graceful _shutdown() handshake is lost). Also replaced the
   __import__("signal") inline with a module-level import.

   Covered by a new platform-independent regression test that forces the exact
   Windows condition (patching the loop method to raise what Windows raises)
   rather than skipping on win32, so the contract is exercised on every OS.
   Verified to FAIL against the unfixed product on macOS with the same
   NotImplementedError at client.py:684 as the Windows traceback.

   The 7th relay failure was a separate mechanism: the ping-loop test patched
   _EXECUTE_TIMEOUT=0.01 around client.connect(), putting the auth round-trip
   under a 10ms deadline. The handshake needs several event-loop turns, and
   10ms is at/below the Windows asyncio timer granularity (~15.6ms clock tick),
   so connect() could time out before the loop ran the recv task. Scoped the
   patch to the pings the test is actually about. A deadline sweep on POSIX
   reproduces the identical "Request timeout for signalwire.connect" error
   deterministically (20/20) once the deadline drops below the turns required.

2. TESTS — POSIX-separator expectations (3 tests)

   test_schema_utils (2) and test_registry (1) compared product output to
   hardcoded POSIX literals. The product is separator-correct in all three
   cases: it returns str(Path(...)), composes with os.path.join(), and splits
   on os.pathsep. Build the expectation the same way the product builds the
   value (compare Path objects / computed joins) instead of a POSIX-only
   spelling. Confirmed under Windows path semantics (ntpath/PureWindowsPath) on
   POSIX: the old literals match only on POSIX, the new expectations match on
   both — so Windows keeps real coverage rather than losing it to a skip.

3. TESTS — claude_skills paths (5 tests)

   - WinError 267 ("directory name is invalid"): the test passed Path("/tmp") as
     the subprocess cwd. /tmp does not exist on Windows, so the spawn failed
     before the timeout could fire. Switched to pytest tmp_path. The command was
     also `sleep 10`, which is not a Windows shell builtin and exits
     immediately; replaced with a portable python -c sleep so the timeout path
     is genuinely exercised (test now takes ~1.01s, i.e. it really blocks).
   - 8.3 short-path mismatches (3 tests): setup() canonicalizes skills_path via
     .resolve(), which expands the Windows 8.3 temp dir to its long form
     (RUNNER~1 -> runneradmin). The tests compared an unresolved tempfile path
     to resolved product output. Resolve both sides.
   - Removed all 15 hardcoded /tmp uses from this file (a standing project rule
     bans /tmp outright); they were inert placeholders except where noted above.

No test was blanket-skipped: every disposition is either a product fix or an
expectation corrected to match platform-correct product behavior.

Verification: bash scripts/run-ci.sh exit 0, all gates PASS (TEST, FMT, LINT,
TYPECHECK, DRIFT, SPEC-PARITY, REST-COVERAGE, ...); tests/unit 5710 passed,
3 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
`strip_control_chars` never used its `logger` or `method_name` parameters —
they existed only to satisfy structlog's `(logger, method_name, event_dict)`
processor calling convention. That made the recorded public contract describe
structlog plumbing rather than what the function does, and asked every port to
carry two permanently-dead parameters.

The public function is now `strip_control_chars(event_dict)` — one parameter,
the thing it actually uses. A private `_as_processor` adapter supplies the
structlog protocol at the two registration sites (the structlog chain and the
ProcessorFormatter chain). The adapter is private, so it adds no port surface.
Adapters wrapping the same transform compare equal, which lets a test assert
chain membership.

Behaviour is unchanged: control characters are still stripped from log event
values at both registration sites.

Tests (tests/unit/core/test_logging_config.py::TestStripControlChars) exercise
the REAL configured chain rather than the function in isolation, and were
verified to fail against two deliberately broken adapters — a no-op adapter
body, and dropping the adapter from only the ProcessorFormatter site (which the
end-to-end output assertion alone cannot see, since the structlog chain strips
first).

`_drop_internal_keys` has the same 3-parameter shape but is private and
therefore not port surface; it is deliberately left unchanged.
The multi-OS PACKAGE-SMOKE step invoked bare `python3`, which on the macOS runner
does NOT resolve to the interpreter actions/setup-python provisioned. Nightly run
30238061313, job macos-latest:

    [FAIL] build: exit 1
        /Library/Frameworks/Python.framework/Versions/3.12/bin/python3:
        No module named build

Mechanism. `setup-python@v6` with python-version "3.12" provisions
/Users/runner/hostedtoolcache/Python/3.12.10/arm64 (the step's own log confirms
pythonLocation), and the earlier `pip install` step installed `build` THERE. But
the macOS image ships a pre-installed framework Python whose bin dir sits ahead of
the toolcache for the name `python3`, so bare `python3` selected
/Library/Frameworks/Python.framework/Versions/3.12/bin/python3 — a DIFFERENT
interpreter, without `build`.

The same run is the proof: the TEST step immediately above uses `python -m pytest`
(the setup-python shim) and passed, as did every `pip install`. Only the one step
spelled `python3` missed.

package_smoke.py is NOT at fault — it correctly uses sys.executable, so it
faithfully used whichever interpreter this line handed it, and it reported that
path verbatim in the error. Fixed by invoking `python` (setup-python's shim).

NOT fixed by `pip install build`: installing into the wrong interpreter would mask
the resolution bug rather than repair it, and would leave the step running an
interpreter nobody selected.

Sweep of the sibling workflows that call setup-python: doc-audit.yml had the only
other bare-`python3` `run:` lines (2). They are ubuntu-only, where setup-python
front-loads its dir so both names currently resolve to the toolcache — latent, not
live — but switched to the shim for consistency so the trap cannot activate if that
job ever gains a macOS runner. nightly.yml and live-smoke.yml call setup-python but
contain no bare `python3`, and are ubuntu-only.

Verified: `grep -n '\bpython3\b' .github/workflows/*.yml` now matches only
explanatory comments, no executable line. actionlint on both changed files reports
the same 6 pre-existing shellcheck info/style findings as before the change (all at
doc-audit.yml's untouched Summary step) — no new findings.

Known-latent, deliberately NOT changed here (needs an owner call): scripts/run-ci.sh
uses bare `python3` in 51 places. Both workflows that invoke it are ubuntu-only, and
that script also runs on developer machines where `python3` is the correct name and
`python` may not exist — so a blanket rename is a behavior change beyond this fix.
Corrects my own previous commit on this branch, which mis-attributed the failure to
interpreter resolution. Dispatching the workflow disproved that fix: with `python`
instead of `python3` the macOS job failed IDENTICALLY, one word different —

    /Library/Frameworks/Python.framework/Versions/3.12/bin/python: No module named build

i.e. the SAME framework interpreter, reached via the other name. So on the
macOS/arm64 runner BOTH names resolve to the pre-installed framework Python;
setup-python does not win PATH there at all.

The real root cause, from the same log: `pip` is that framework Python's pip —
every dependency reports installing to
`/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages`.
The job is therefore CONSISTENTLY one interpreter, and there was never a mismatch
to fix. `build` is simply not installed, because **it is not a declared dependency
anywhere** — not in requirements-dev.txt, not in requirements.txt, not in
pyproject.toml. The gate relied on the runner image happening to ship it.

AGENT_RULES §7: a tool a gate needs is DECLARED, not assumed present. So:
requirements-dev.txt gains `build>=1.0.0`, and the step above already installs that
file. This is not "pip install build into the wrong interpreter" (which the brief
rightly forbids as masking a resolution bug) — there is no wrong interpreter here,
and declaring a real, undeclared dev dependency is the fix rather than the mask.

Note this gate has never passed for python on any OS: multi-os.yml is the ONLY
place PACKAGE-SMOKE runs for this port (nightly.yml and scripts/run-ci.sh do not
invoke it), so the "ubuntu ships build" path was never exercised either.

`python` (not `python3`) is KEPT, on its own merits: it is the same name `pip`
above pairs with, so the step provably runs the interpreter the deps went into, and
it stays correct if the image's PATH precedence ever changes. The workflow comment
is rewritten to state this true mechanism instead of the interpreter-mismatch story.

The doc-audit.yml python3→python change from the previous commit also stands — that
job is ubuntu-only and green either way; the shim is the consistent choice.

Windows remains red on this workflow for an unrelated, separately-assigned reason
(RED 3: four test-portability defects in the TEST step — WinError 32 on an unclosed
sqlite temp file, a hardcoded "/tmp/custom" assertion, a POSIX 0o755 mode check, and
cp1252 UnicodeEncodeErrors). That step fails before PACKAGE-SMOKE runs, so the
Windows job cannot confirm this fix either way.
…rmission bits, UTF-8 encoding

Four distinct cross-platform defects, all measured from nightly Multi-OS run
30238061313 (job windows-latest, step TEST: 36 failed / 2 errors / 5671 passed).
Two turned out to be product bugs, not test bugs.

1. sqlite handle outlives the temp file (PermissionError [WinError 32])
   PRODUCT BUG. search/index_builder.py validate_index() closed its connection
   only on the success path; the "Missing tables" early return and the except
   both leaked it. Windows refuses to delete a file with a live handle, so the
   fixture teardown's os.remove() raised. Fixed with contextlib.closing (NOT
   `with sqlite3.connect(...)` — a Connection context manager commits but does
   not close). Audited all 14 sqlite3.connect sites in search/ and found two
   more unguarded on their error paths: migration.get_index_info() and
   search_service._get_model_name(). Also fixed the test-side leak in
   test_search_engine.py, where NamedTemporaryFile(delete=False) held its own
   handle open while os.unlink ran inside the `with` block.

2. Hardcoded POSIX path assertion
   str(Path) renders with the platform separator, so `== "/tmp/custom"` can
   never hold on Windows (it yields "\tmp\custom"). Now compares Path objects.
   Also removes the /tmp usage itself per the project rule (tmp_path instead).
   Same fix in test_init_project.py::test_main_custom_dir, which failed the
   same way via `'/tmp/custom' in 'D:\tmp\custom\testproject'`.

3. POSIX permission bits
   Windows has no execute bit, so st_mode never carries 0o755. The two
   observable-mode assertions are skipif(win32) with the reason recorded, and
   the contract they were checking is now ALSO asserted platform-independently
   (that chmod 0o755 is requested, and only for deploy.sh). POSIX coverage is
   unchanged — nothing was deleted or weakened.

4. UnicodeEncodeError: 'charmap'
   PRODUCT BUG. cli/dokku.py:1964 wrote generated files with write_text() and
   no encoding, i.e. the platform default (cp1252 on Windows). The templates
   embed box-drawing rules (U+2500/U+2550, 59-char runs — matching the log's
   "position 130-188") and arrows, which cp1252 cannot represent. This caused 8
   direct failures plus 4 more surfacing as generate() returning False
   ("Failed to generate project: 'charmap' codec can't encode..."). Fixed at
   _write_file plus the two app.json reads. cli/init_project.py had the same
   latent defect — 98 cp1252-hostile characters across 29 unguarded write_text
   sites — fixed there too before it bites.

Proven on POSIX (not merely "looks right"):
- Defect 1: 3 new tests assert the platform-independent invariant (every
  connection opened is closed, via a connect spy). Verified they FAIL against
  the unfixed product code and pass with it.
- Defect 4: reproduced the Windows failure class on macOS with
  `LC_ALL=C python -X utf8=0`, which yields
  "'ascii' codec can't encode characters in position 2-80" — the same position
  range as CI's charmap error. The 3 new UTF-8 round-trip tests fail without
  the fix; with it, all 210 cli tests pass even under that hostile locale.
- Defects 2 and 3 are structural (Path comparison / platform skip) and remain
  pending real confirmation on the Windows runner.

Full local gate run: `bash scripts/run-ci.sh` → CI PASS, exit 0 (37 gates).
Unit suite 5621 passed / 100 skipped on macOS.

Note: the Windows TEST step failing is what kept PACKAGE-SMOKE from running at
all on that job (TEST: failure -> PACKAGE-SMOKE: skipped). If TEST now passes,
PACKAGE-SMOKE will execute on Windows for the first time and may surface
unrelated failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
The new test_deploy_script_round_trips_as_utf8 asserted
read_bytes().decode('utf-8') == read_text(encoding='utf-8'). Those two views
legitimately differ on Windows: text-mode writes translate \n -> \r\n and
read_text translates it back (universal newlines), so the assertion compared
raw CRLF against normalized LF and failed on the runner -- while the encoding
fix it was guarding was working correctly (every U+2550/U+2192/U+2705/U+1F310
round-tripped intact).

Line endings are not what the test is about. It now compares against the source
DEPLOY_SCRIPT_TEMPLATE line-by-line and asserts the non-ASCII character set
survives byte-for-byte, which is the actual regression being guarded. Still
verified to fail against the unfixed product code (all 3 tests in the class fail
under LC_ALL=C -X utf8=0 without the encoding fix).

Caught by Multi-OS run 30260346853 (windows-latest), which this branch
dispatched -- Windows TEST went 36 failed -> 16 failed.

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

Four checks porting-sdk DEFINES for python and this script never scheduled, so they had never run
against the reference. TLS-VERIFY and CA-VAR are SECURITY properties; the reference should not be
the one port where they go unchecked.

BURNED TO ZERO BEFORE WIRING, per the standing rule. Each run standalone against the current tree
first — all four already PASS, so this adds coverage without adding a red:
    python CA-VAR       PASS
    python TLS-VERIFY   PASS
    python SECRET-SCRUB PASS
    python LEDGER       PASS (SUPPRESSION-LEDGER + IGNORE-LEDGER-VERIFY, 2 rules)

TIER: BLOCKING (per-PR), measured not assumed — 0s, 0s, 0s and 2s respectively. Nothing here
approaches the cost that would justify the nightly tier, where a regression sits unseen until the
next scheduled run.

A CORRECTION TO THE AUDIT THAT FOUND THIS: the fleet gate-wiring audit listed SUPPRESSION-LEDGER
among python's missing BEHAVIOURAL rules. It is not one — `behavioral.py --rules SUPPRESSION-LEDGER`
errors with "unknown rule id(s)" and lists the 14 rules python actually knows. SUPPRESSION-LEDGER
belongs to the LEDGER suite (suites/ledger.py), which python was not scheduling at all. Every other
port already schedules it; python was the only gap. Wired as the suite rather than as a lone rule,
matching how the other nine do it.

python schedules gates individually rather than through a BEHAVIORAL suite line, so these are four
new sched_gate entries rather than additions to a --rules list.

Verification: each gate run exactly as run-ci.sh now invokes it (same script, same --port/--repo,
same --rules), all PASS. `bash -n scripts/run-ci.sh` clean.

Found by the fleet-wide gate-wiring audit (task #55); completes that item's tier-1 across
perl 0db0b55, cpp 54abe7f, ruby dde0516, java 63acebd and this commit.

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

OWNER-RULED TWICE (2026-07-27 as task #56 option (b), re-confirmed 2026-07-29): a tool registered
`secure=True` MUST REQUIRE a `__token`. Accepting the call when the token is ABSENT is a security
defect, not the contract. A flag named `secure` that permits unauthenticated calls is a trap.

THE DEFECT. core/agent_base.py:1413-1415 read:
    # Validate security token if present.
    token = request.query_params.get("__token") or request.query_params.get("token")
    if token:
The ENTIRE validation block sat inside that conditional, so omitting the parameter skipped the
check and the tool ran. Refusal only ever happened on a present-but-wrong token — an attacker did
not need to forge anything, only to leave the parameter off.

WHAT THAT MEANT IN PRACTICE, from the pre-fix test output: a `secure=True` tool invoked with NO
token returned `{"response":"SECRET-BALANCE-9999"}` — the guarded payload, straight back over the
wire.

THE FIX. Entry into the validation block is now "a session manager exists and the function is
registered"; an absent token is simply one way to FAIL validation rather than a way to SKIP it.
Two things deliberately left alone:
  * THE `secure` PREDICATE IS THE EXISTING EXPRESSION, REUSED, NOT DUPLICATED
    (`func_entry.secure if hasattr(...) else func_entry.get("secure", True)`). A non-secure tool
    still runs with no token — there are two tests pinning exactly that, because getting this
    wrong would break every insecure tool in the fleet.
  * THE REFUSAL SHAPE IS UNCHANGED: the same FunctionResult dict at HTTP 200 as the invalid-token
    path. No status code was invented. mod_openai has NO handling for a SWAIG refusal status
    (grep for "invalid or expired" / "security token" across its .c files returns nothing), so the
    tool reports it cannot execute and the model relays that. A test asserts BYTE-EQUALITY of the
    absent and invalid refusals so no port can later conclude one of them may be an HTTP error.

Verification — real HTTP through FastAPI TestClient against a real AgentBase with a real
SessionManager minting genuine HMAC tokens. Nothing on the token path is stubbed.
  BEFORE (source stashed, tests in place): 4 failed, 5 passed
      test_absent_token_is_refused                     AssertionError: {"response":"SECRET-BALANCE-9999"}
      test_absent_token_does_not_leak_the_secure_payload
      test_empty_token_is_refused
      test_absent_and_invalid_refusals_are_the_same_shape
    The 5 that already passed are cases (i) valid-accepted, (ii) invalid-refused and both
    non-secure cases — which is what proves case (iii) was not bought by breaking the others.
  AFTER: 9 passed.
  FULL SUITE: 5730 passed / 3 skipped -> 5734 passed / 3 skipped, ZERO failures. The +4 is exactly
    the four assertions that were red.
  ruff format --check signalwire · ruff check signalwire · mypy (359 files) — all clean.

I CORRECTED THE LANE'S BASELINE CLAIM. It reported "9 failed / 5774 passed" at 81d412e and
attributed the failures to a pre-existing structlog-on-stdout defect in tests/test_examples.py.
That is a measurement artifact: `git stash` does not remove an UNTRACKED file, so its own new test
file stayed on disk and ran against the stashed-out source. Measured properly, the clean-tree
baseline is 5730 passed / 3 skipped with no failures, and the after-state is 5734 with none.

ONE FURTHER BEHAVIOURAL CHANGE, DELIBERATE: a PRESENT token with `call_id is None` previously
skipped validation entirely; it now refuses for secure tools, because a token that cannot be
validated is not a validated token. No existing test depended on the old behaviour.

NOT CHANGED, FLAGGED INSTEAD: tests/unit/.../test_web_mixin.py:1718
test_valid_function_name_passes registers a dict with no `secure` key (so `.get("secure", True)`
makes it secure) and posts with no token. Its behaviour genuinely changed — it now receives the
refusal — but it still passes, because its assertion is
`if hasattr(result, 'status_code'): assert result.status_code != 400` and a refusal dict has no
status_code. That vacuity is pre-existing and the test's real subject is function-name validation,
so rewriting it would be scope creep.

SEPARATE DEFECT FOUND, NOT FIXED HERE — WORTH ITS OWN LANE: the SERVERLESS SWAIG path has NO token
validation at all. core/mixins/serverless_mixin.py:224 _execute_swaig_function checks the function
name and registry membership, then dispatches; no token is ever read. So `secure=True` is
unenforced under lambda / cloud-function / azure. It is a different code path from
_swaig_pre_dispatch and was outside this change's scope.

CONSEQUENCES FOR THE COORDINATED PASS: this is REFERENCE SOURCE, so both oracles need regenerating
and all nine ports re-drifting. porting-sdk 04e24f2's `token_absent` corpus golden is derived LIVE
from the reference and will move with this change automatically — it must never be hand-edited.
Note cpp already fails CLOSED (403) and is therefore now closer to the contract than the reference
was; six ports (go, java, php, ruby, perl, dotnet) still never validate at all.

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

OWNER-RULED 2026-07-29: "if the server doesn't read them, remove them."

THREE INDEPENDENT SOURCES AGREE THAT `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.
  * THE ENGINE NEVER READS IT. mod_openai/actions.c:735-739 and bedrock.c:4920-4926 each read
    url, method, form_param, `params`, `headers` and nothing else. Stronger than that:
    `grep -n '"body"'` across BOTH files returns ZERO matches, so `body` appears nowhere in
    mod_openai at all.
  * THE HELPER SILENTLY DISCARDED THE CALLER'S DATA. create_simple_api_tool accepted `body=` and
    forwarded it to DataMap.body(), which wrote a key nothing consumes.

REPRODUCED BEFORE FIXING, not inferred:
    create_simple_api_tool(..., method='POST', body={'query':'Q'})
    -> webhook KEYS: ['body', 'method', 'output', 'url']
An invalid key on the wire carrying the caller's payload into a void.
AFTER: KEYS: ['method', 'output', 'url']; `body` is absent from the signature; passing `body=`
raises TypeError.

ALSO CORRECTED — A FALSE DOCSTRING THAT HAS ALREADY PROPAGATED. params() described itself as an
"alias for body". It is not: the two write DIFFERENT KEYS and only `params` is ever read. That
sentence has been copied verbatim into signalwire-cpp/include/signalwire/datamap/datamap.hpp:90,
which is plausibly how cpp's datasphere skill picked the wrong method (fixed earlier today in
cpp 07054db). The docstring now states the distinction and cites the schema and the engine readers.

The module docstring example at the top of data_map.py taught `.body(...)`; it now teaches
`.params(...)`. docs/api_reference.md drops `body` from the signature line and the parameter list.

SCOPE HELD DELIBERATELY: `DataMap.body()` — the public BUILDER METHOD — is NOT removed here. The
ruling was about "that call", i.e. the helper's parameter. Removing the builder is a larger,
separately-breaking change and belongs to its own decision. Measured cost if it goes: it is genuine
port surface recorded in python_signatures.json and implemented by ALL NINE PORTS (`Body` in
go/dotnet, `body` elsewhere) — roughly 24 test call sites, 7 example call sites, 18 doc references
and 6 internal factory forwards across 10 repos, plus an oracle regen and 9 port PRs. After this
commit, NOTHING in the reference's production code calls DataMap.body(): the discard site deleted
here was its only internal caller. Remaining reference references are 3 tests and 3 doc examples.

NOTE FOR THE BATCHED PORT PASS: each port's own create_simple_api_tool equivalent carries the SAME
body-forwarding bug, so the ports must be touched regardless of how the builder-method question is
ruled.

Verification:
  BEFORE 5734 passed / 3 skipped / 0 failures (verified independently on the clean tree)
  AFTER  5736 passed / 3 skipped / 0 failures — the +2 is exactly the two new tests.
  ruff format --check signalwire -> exit 0, 215 files already formatted
  ruff check signalwire          -> exit 0, All checks passed
  mypy --config-file pyproject.toml -> Success: no issues found in 359 source files
No oracle regenerated and no port touched — both are deliberately batched into the single
coordinated pass alongside 7c2f253 (secure=True requires a token).

A SECOND RULED ITEM TURNED OUT TO BE ALREADY DONE: task #140 (strip_control_chars becomes a real
1-param function with a structlog adapter) was landed by the owner on 2026-07-28 as b1ab620.
logging_config.py:33 is already `def strip_control_chars(event_dict)`, the private `_as_processor`
adapter supplies the protocol at both registration sites (:205, :233), `_drop_internal_keys` was
correctly left alone, and tests exist at tests/unit/core/test_logging_config.py:416-510. No change
was made for it here.

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

porting-sdk 481b435 corrected DOC-SURFACE's python decl regex, which matched a LEADING
UNDERSCORE and so counted `_helper`, `__init__` and every dunder as public surface — 511
of 1539 symbols, a third of the denominator. Excluding them the way go/typescript/java/cpp
already do, coverage is 70.6% (726/1028), not 64.5% (993/1539).

This RAISES the bar. The gate had been RED since before today (64.5% vs a 64.7% floor) and
report-only, so nobody saw it; the drop traced to cdd0b17 (ai_chat) and b1ab620
(strip_control_chars), and 9 of the 12 symbols that caused it were private. The floor now
describes the corrected measurement — leaving 64.7 would have pinned a number the gate no
longer produces.

Gate exit 0 at the new floor. Full rationale and the per-port before/after in 481b435.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Every typed RELAY event wrapper's `from_payload` classmethod now documents which wire
event it parses and which fields it lifts out of `params`. These are what nine porting
teams read to know the RELAY event contract, and the non-obvious mappings are exactly what
was undocumented:

  CallReceiveEvent  `context` falls back to the payload's `protocol` field — older RELAY
                    servers name the same value `protocol`
  RecordEvent       url/duration/size read from the nested `record` object when present
                    and from the top of `params` otherwise, because RELAY reports them in
                    either position depending on event stage
  CollectEvent      `final` stays None when the payload omits it — absent is not False
  DialEvent         correlated by `tag`, not `control_id` like the other operations
  ConferenceEvent   conference-scoped, so the inherited `call_id` may be empty
  MessageStateEvent carries `reason`, which is what explains a failed `message_state`
  TranscribeEvent   reads its artifact fields only from the top level — unlike RecordEvent
                    there is no nested object

DOCS ONLY — no code, signature or behaviour changed.

Verified: 490 relay tests pass; ruff format --check and ruff check clean; file parses.
DOC-SURFACE for this file goes from 24 undocumented to 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Adds real docstrings to the 12 undocumented public symbols in the REST
client's shared base, the layer every generated resource inherits and the
nine porting teams read as the contract.

HttpClient raw HTTP verbs (get/post/put/patch/delete): each documents that
`path` is an absolute API path appended to the client's scheme://host base,
what is serialised where (params -> query string, body -> JSON), the {}
return for 204/empty bodies, and the SignalWireRestError /
SignalWireRestTransportError split. Records the idempotency asymmetry read
from status_is_retryable: GET/PUT/DELETE retry on the full retry_on_status
set, while POST/PATCH retry only on a transport error or a 429/503 throttle.
Notes which verbs accept no query params (put/patch/delete).

ReadResource/CrudResource/CrudWithAddresses resource CRUD (list/get/create/
update/delete/list_addresses): documents path composition from the
resource's own base_path (vs the caller-built absolute path the HttpClient
verbs take), so the two `get`s and the two `delete`s are no longer
conflatable. Records that `list` returns ONE raw page and does not follow
pagination links (paginate() does); that create/update send kwargs as the
JSON BODY where list sends them as the QUERY STRING; that update dispatches
on _update_method (PATCH default, PUT under FabricResourcePUT); that
resource delete is typed TItem but SignalWire delete endpoints answer 204,
arriving as {}; and that list_addresses hits the nested sibling collection
<base_path>/<id>/addresses and is untyped Any, unlike list's TList.

_AbortSignal.is_set: documents that cancellation is cooperative and polled
only BETWEEN attempts, so a request already on the wire is not interrupted.

Measured with the AST gap checker (porting-sdk 62d0e64): the two files in
scope went 12 gaps -> 0. No code, signature or behaviour changed; docs only.
No defects found while reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Closes every DOC-SURFACE gap in the `search` lane. Measured with the AST
gap tool (public = no leading underscore, documented = ast.get_docstring):
18 gaps before (search_service.py 11, __init__.py 6, document_processor.py 1),
0 after.

The bulk of this lane is the OPTIONAL-DEPENDENCY structure, which was
entirely undocumented and is easy to misread:

- search/__init__.py 117-146: preprocess_query, preprocess_document_content,
  DocumentProcessor, IndexBuilder, SearchEngine and SearchService in the
  `else` branch are FALLBACK STUBS, bound only when numpy / scikit-learn /
  sentence-transformers / nltk are missing. Importing them always succeeds;
  calling or constructing one always raises ImportError naming the missing
  packages. Each stub now says so and points at the real symbol it shadows.

- search_service.py: SearchRequest / SearchResult / SearchResponse are
  defined TWICE — pydantic BaseModel subclasses when pydantic imported, and
  plain __init__-assignment classes when it did not. Both sets are now
  documented per-field, and each fallback says it performs no validation and
  is only reachable via search_direct (no FastAPI means no HTTP route).

- add_security_headers / validate_host: the two `@app.middleware("http")`
  handlers — header stamping (https-conditional) and Host-header allowlisting
  returning a bare 400.
- get_authenticated / search / health: the auth-scheme provider and the two
  route handlers, including that /health is registered WITHOUT the security
  dependency and masks the pgvector connection string to "***".

- document_processor.py flush(): the closure inside _chunk_markdown_ast that
  emits an accumulated chunk with hierarchy/section/line-range metadata and
  resets the accumulator, dropping whitespace-only accumulations.

Docs only — no code, signature or behaviour change.

Verified: pytest 5785 passed / 9 failed (the pre-existing test_examples
JSON failures) / 9 skipped; ruff format --check 0; ruff check 0;
mypy Success (359 source files).
Closes the entire `cli` DOC-SURFACE lane. Docstrings written from reading each
body; no code, signature or behaviour changed.

cli/core/agent_loader.py (2)
  mock_serve, mock_run — the monkeypatches installed over SWMLService.serve()/
  run() (and AgentBase's) while a module's main() is called, so loading an agent
  file for inspection configures its service without starting a web server; the
  receiver is captured for the caller and all arguments are ignored.

cli/output/swml_dump.py (1)
  suppressed_print — installed as builtins.print so loaded agent code cannot
  contaminate stdout during a SWML dump; calls naming an explicit non-stdout
  file are forwarded to the saved original, the rest are discarded.

cli/simulation/mock_env.py (8)
  MockQueryParams.get/items/keys/values and MockHeaders.get/items/keys/values —
  two distinct stand-ins for FastAPI request objects in serverless simulation.
  Documented the behavioural split: MockQueryParams matches keys exactly and
  case-sensitively, while MockHeaders lowercases on both construction and
  lookup, so its items()/keys() yield lowercased names and the caller's original
  casing is not recoverable. All four view methods return live dict views.

cli/dokku.py (10)
  Colors, print_step/success/warning/error/header, prompt, prompt_yes_no,
  generate_password, main. Noted that print_error writes to stdout and neither
  raises nor exits; that prompt_yes_no treats any unrecognized input (a typo)
  as NO rather than re-prompting; that generate_password draws from
  secrets.token_urlsafe (OS CSPRNG) over the base64url alphabet and TRUNCATES
  to `length`, retaining ~6*length bits rather than 8*length. main documents
  the five subcommands (init/deploy/logs/config/scale) and app-name inference.

cli/init_project.py (5)
  Colors, print_step/success/warning/error — sw-agent-init's own copies. Each
  Colors docstring states which CLI it belongs to and that the dokku.py copy is
  a separate class (dokku's additionally has MAGENTA), so neither is mistaken
  for a shared module.

Measured (porting-sdk AST gap counter, run from this worktree):
  before 114 real gaps / 30 files -> after 88 / 25 files (-26, all in scope).

Verification: pytest 5785 passed, 9 skipped, 9 failed (the pre-existing
tests/test_examples.py "Invalid JSON" failures, unrelated to this change);
ruff format --check and ruff check exit 0; mypy Success (359 source files).

Nothing in scope left undone. No defect found in the code read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Adds real docstrings to every remaining DOC-SURFACE gap in
signalwire/livewire/__init__.py, the LiveKit-compatible API layer. Each
explains the LiveKit-side concept AND what it maps to on SignalWire, which
is what a migrating user and the porting teams need.

Documented (repeated names sit on different classes):
  _NoopTracker.was_logged / .reset       (:116, :120)
  ChatContext.append                     (:164)
  RunContext.userdata      getter        (:271)
  Agent.session            getter/setter (:346, :350)
  AgentSession.userdata    getter/setter (:505, :509)
  AgentSession.history     getter        (:513)
  handler closure in _register_function_tool (:626)

Measured with the AST checker (porting-sdk 62d0e64):
fleet gaps 114 -> 104; livewire/__init__.py 10 -> 0.

Behaviour worth recording, found while reading:
- AgentSession.history is initialized empty and never appended to anywhere
  in the SDK; the platform owns the transcript. Documented as such rather
  than implying it fills.
- RunContext.userdata reads through to the session, but the handler built by
  _register_function_tool always constructs RunContext(session=None), so
  tool handlers get the empty-dict fallback.
- ChatContext.append stores its `text` kwarg under the `content` key.
- The tool handler calls fn synchronously, so an async tool function would be
  stringified as an un-awaited coroutine.

Docs only -- no code, signature or behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The shared mock_relay fixture probed the HTTP health endpoint and, on a 200, returned
immediately — treating that as proof the server was usable. But HTTP and WS are two
DIFFERENT ports (HTTP defaults to WS+1000, 9773 vs 8773). A foreign process owning the
HTTP port makes the probe green, so the fixture skips spawning its own server, and every
test then dies with ConnectionRefusedError on a WS port nothing is serving.

OBSERVED LIVE, not theorised: during the parallel docs burn a sibling lane's mock_relay
held the fixed ports and exited between one lane's probe and its WS connect. That lane saw
2 failures + 6 errors in tests/unit/relay/test_connect_mock.py, all ConnectionRefusedError
on 8773, from a diff that touched only livewire/__init__.py. It correctly refused to call
that a flake and traced it to conftest.py:349.

THE FIX: _probe_health now takes the ws_port and additionally opens a TCP connection to it.
A mock is only 'already up' when BOTH the endpoint the health check uses AND the socket the
tests actually connect over are alive.

PROVEN WITH A NEGATIVE CONTROL rather than asserted — an HTTP-only squatter answering 200
on 9773 with nothing on 8773:
    probe(http only)      -> True    <- the old logic: 'reuse it', then every test refuses
    probe(http + ws 8773) -> False   <- the new logic: spawn our own
and tests/unit/relay/test_connect_mock.py -> 11 passed.

This is the fixed-mock-port hazard CLAUDE.md warns about. The deeper fix — bind an ephemeral
port instead of hardcoding 8773 — is a larger change to a shared fixture and is NOT done
here; this stops the silent misdetection, which is the part that turns a port collision into
a mystery test failure in an unrelated lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
f601032 added `import socket` at module scope while _ws_port_accepting already imports it
locally, matching the file's existing style (_probe_health imports requests the same way).
Ruff flagged it F401 unused.

Verified the file is back to its exact pre-change lint baseline: 24 errors both at 83a9833
and now — these are pre-existing findings in this conftest, none introduced by the probe
fix. tests/unit/relay/test_connect_mock.py -> 11 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Documents every AST-measured DOC-SURFACE gap under signalwire/signalwire/skills/,
written from reading each body:

claude_skills/skill.py (5)
  replace_command      - shell-injection substitution; returns stdout, or a
                         bracketed [command timed out/error: ...] placeholder
  replace_indexed      - expands $ARGUMENTS[N]; out-of-range erases to ""
  replace_shorthand    - expands $N; same lookup, applied after the bracketed
                         pass so an expanded $ARGUMENTS[N] tail can't re-match
  make_handler         - per-skill closure factory (why a factory: else every
                         handler in the loop sees the last skill)
  handler              - section-vs-body selection then shell/variable/argument
                         substitution and prefix/postfix wrapping

info_gatherer/skill.py (5)
  get_parameter_schema - the three params added on top of SkillBase's
  get_instance_key     - overrides the base tool_name keying to key on `prefix`,
                         which is what actually differentiates two instances
  setup                - what it validates; returning False = skill not loaded
  get_global_data      - initial namespaced questionnaire state
  register_tools       - the two prefixed tools and the toggle_functions on
                         completion

google_maps/skill.py (1)   GoogleMapsClient - Places/Routes wrapper; both methods
                           log-and-return-None rather than raising
mcp_gateway/skill.py (1)   handler - per-(service, tool) closure forwarding to
                           _call_mcp_tool
registry.py (1)            add_skill_to_schema - attributes read, AttributeError
                           treated as empty schema, other exceptions swallowed so
                           one bad skill can't abort the scan

MEASURED (AST gap script, from worktree root):
  before 114 gaps / 13 under skills/
  after  101 gaps /  0 under skills/

Docs only - no code, signature or behaviour changed. Nothing left undone in
scope; no defects found in the code read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Closes every DOC-SURFACE gap under signalwire/signalwire/core/. All 17 are
nested/inner functions (route handlers, middleware, decorator inner layers,
closures) that the AST measure counts as public surface. Measured with the AST
gap script: 114 real gaps fleet-wide -> 97; core/ 17 -> 0.

Documented, with the wire/security behaviour read out of each body:

- auth_handler.get_fastapi_dependency.auth_dependency — bearer-then-basic,
  first match wins, secrets.compare_digest; the api_key parameter is accepted
  but NEVER consulted on this path; failure = HTTPException(401,
  "Invalid authentication credentials") + WWW-Authenticate: Basic even when
  bearer is the configured scheme; optional=True returns
  authenticated=False instead of raising.
- auth_handler.flask_decorator.decorated — bearer -> API-key header
  (X-API-Key or security_config.api_key_header) -> Basic; failure RETURNS a
  401 Response with body "Authentication required" and
  WWW-Authenticate: Basic realm="SignalWire Service" (returns, not raises).
- security/webhook_middleware.make_webhook_validation_dependency.dependency —
  raw body captured before any parser and stashed on request.state.raw_body;
  every rejection path (non-UTF-8 body, missing header, bad signature) raises
  the same bare HTTPException(403) with no body detail; the injected response
  arg is unused because a returned Response does not short-circuit a
  dependencies=[] entry.
- swml_service.as_router.handle_swaig — the /swaig endpoint available on ANY
  SWMLService; documents the handler's status codes (401/415/413/400).
- swml_service.serve.handle_all_routes and mixins/web_mixin's two
  handle_all_routes — the with/without-trailing-slash recovery and the
  serve()-vs-get_app() split (get_app's variant only classifies and returns
  204; serve's variant is what actually dispatches /swaig, /post_prompt,
  /check_for_input, /debug_events and the routing callbacks).
- swml_service.make_verb_method / swml_builder.make_verb_method — per-verb
  closure, None-valued kwargs dropped before the wire, sleep excluded
  (bare integer, not an object); service returns add_verb's bool (False on
  schema-validation failure), builder returns self for chaining.
- agent/tools/decorator's inner_decorator and decorator — how a python
  signature becomes a SWAIG tool: kwarg pop-list, name/description fallback
  order, type-inference handoff and the typed-handler wrapper; the class
  decorator only stamps _is_tool/_tool_name/_tool_params for deferred
  registration, so inference does not run at class-definition time.
- agent/tools/type_inference.create_typed_handler_wrapper.wrapper — the
  (args, raw_data) calling convention adapter; no validation or coercion.
- agent_base.enable_sip_routing.sip_routing_callback — always returns None on
  every branch, so it never emits the 307 the routing contract allows; the
  username match is observational logging only.
- config_loader.substitute_vars.replacer — ${VAR} / ${VAR|default} expansion;
  missing var with no default yields empty string, never an error.
- mixins/web_mixin's two add_security_headers — nosniff / DENY /
  strict-origin-when-cross-origin always; HSTS only when SSL is on.
- mixins/web_mixin.setup_graceful_shutdown.signal_handler — SIGTERM+SIGINT,
  cleanup body is a no-op placeholder, always sys.exit(0), no request drain.

Notable behaviour found while reading (documented, NOT changed — docs-only
lane): the three "path not found" / "invalid route" catch-all responses in
swml_service.serve and web_mixin return FastAPI's default 200 status with an
error JSON body rather than a 404, so a caller must inspect the body to
detect a miss. Ports mirroring these handlers should be aware this is the
current reference behaviour.

Verification: pytest 5785 passed / 9 failed (only the pre-existing
test_examples "Invalid JSON" cases) / 9 skipped; ruff format --check exit 0;
ruff check exit 0; mypy Success (359 source files).
…ecurity middleware)

Docs-only. No code, signature or behaviour changed.

ai_chat/client.py (4): the three public response models ConversationInfo /
ChatResponse / ChatLog now document what each field carries and where it comes
from (notably that `id`/`conversation_id` are echoed from the request argument,
not read from the response), and `close` says it only closes a client-owned
session and that the client stays usable — the next request lazily rebuilds one.

relay/call.py (6): is_done/stop/pause/resume/volume are on the Action handle
hierarchy (Action -> StoppableAction -> PausableAction -> VolumeAction), NOT on
Call; each now names the `calling.<prefix>.<cmd>` command it posts and the state
it requires. Two facts taken from the engine (mod_infrastructure/relay_apis.c):
`pause(behavior=)` is a closed enum "skip,silence" accepted only on record.pause
(play.pause has no behavior field), and `volume` is gain in DECIBELS validated to
[-40, +40] and required — not a 0-to-1 multiplier. `rank` is the lifecycle-order
helper inside _wait_for_state; documented incl. the -1 unknown-state sentinel.

pom/pom.py (2): build_section and recurse are nested closures, not rendering
internals — build_section is the from_json/from_yaml validator that builds the
Section tree (documented incl. every ValueError it raises), recurse is
find_section's depth-first exact-title search.

web/web_service.py (3) + mcp_gateway/gateway_service.py (3): the security
middleware now states the exact headers set. web_service uses
SecurityConfig.get_security_headers (nosniff / DENY / XSS / Referrer-Policy,
HSTS only on https AND when enabled); mcp_gateway hardcodes its own set which
adds a `default-src 'none'` CSP, omits Referrer-Policy, and has a
non-configurable HSTS. validate_host rejects with 400 unless the host is in
allowed_hosts — with two documented pass-throughs: a missing Host header is not
checked at all, and "*" in allowed_hosts permits everything. `decorated` is
_check_auth's wrapper (Bearer then Basic, both hmac.compare_digest, 401 +
WWW-Authenticate on failure); `handle_error` is the catch-all that returns a
fixed opaque 500 and never leaks the exception.

Measured coverage (AST: public = no leading underscore, documented =
ast.get_docstring): 114 -> 96 real gaps fleet-wide; all 18 in scope closed, zero
left in these 6 files.

Verified: pytest 5785 passed / 9 failed (only the pre-existing
tests/test_examples.py "Invalid JSON" set) / 9 skipped; ruff format --check,
ruff check, and mypy all exit 0.

Brief corrections: pom's build_section/recurse were briefed as "rendering
internals" — they parse/validate and search respectively, nothing renders.

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

BURN COMPLETE. 70.6% -> 100.0% (1102/1102), zero remaining gaps.

The move splits into two halves, and only the second half is documentation:

  MEASUREMENT (70.6 -> 89.7, no docstrings written). Three independent defects in the
  gate, all found by trying to burn against its output:
    porting-sdk 481b435  counted _private + dunders as public surface (511 of 1539)
    porting-sdk f8f58a2  could not see a docstring behind a WRAPPED SIGNATURE (161 false
                         negatives — every one already carrying full Args:/Returns:)
    porting-sdk 62d0e64  matched def/class INSIDE STRING LITERALS — scaffolding templates
                         and docstring examples (24 phantoms). Superseded the other two by
                         measuring python with the AST instead of regex.
  The first file opened to start writing (core/function_result.py, nominally 16 missing)
  turned out to be fully documented already. That is what stopped the burn and sent the
  work at the gate instead.

  DOCUMENTATION (89.7 -> 100.0, 114 real symbols). One commit here + seven lanes run in
  parallel git worktrees:
    relay/event.py         24  83a9833  every from_payload event parser
    cli                    26  3e69e8d
    search                 18  508d75e
    misc                   18  d460ff4
    core                   17  aed6e78
    skills                 13  c28213e
    rest/_base             12  efbf3cf
    livewire               10  4f43f76

WHAT THE LANES FOUND WHILE READING — the reason to write these by hand rather than
generate them. Each was read out of the source, not inferred:
  * REST retry is ASYMMETRIC: GET/PUT/DELETE retry on the full retry_on_status set, but
    POST/PATCH retry ONLY on transport errors and 429/503, never 500/502/504. The code
    calls it "part of the pinned contract"; it was documented on none of the verbs.
  * Resource delete is typed TItem but the endpoints answer 204, so _request returns an
    empty dict — the type signature actively misleads.
  * relay volume is GAIN IN DECIBELS, engine-validated to [-40,+40], not a 0..1
    multiplier (from mod_infrastructure/relay_apis.c). A port modelling it as 0..1 is
    wrong.
  * The pause behavior argument is a closed enum accepted ONLY on record.pause;
    call_play_pause declares no such field, yet it is shared via PausableAction.
  * Catch-all "not found" responses return HTTP 200, not 404 — a client cannot detect a
    routing miss from the status line.
  * get_app() and serve() build DIFFERENT catch-alls; the serverless entry point
    dispatches nothing and answers 204.
  * AuthHandler.get_fastapi_dependency accepts an api_key parameter and never reads it,
    while the Flask decorator does honour it.
  * sip_routing_callback returns None on every branch, so the 307 redirect it appears to
    offer can never fire.
  * ai_chat ConversationInfo.id and ChatResponse.conversation_id are echoed from the
    request argument, never read off the wire.
  * The two security-header implementations diverge: mcp_gateway adds a CSP and omits
    Referrer-Policy relative to web_service.
  * validate_host never checks a MISSING Host header.
These are documented, not fixed — several deserve an owner ruling before nine ports
reproduce them.

TWO BRIEFS OF MINE WERE CORRECTED BY THE LANES, both verified at source afterwards:
  * I said search's duplicate class sets key on the four search dependencies. They key on
    BaseModel is not None (fastapi+pydantic) — an independent axis. There is also a third
    path I did not know about: _SEARCH_AVAILABLE true but a submodule import failing
    partially populates __all__, so names are ABSENT rather than stubbed.
  * I called pom's build_section/recurse "rendering internals". Neither renders — one is
    the from_json/from_yaml validator/constructor, the other a depth-first title search.

Verification of the merged tree, run here rather than delegated:
  doc_surface --port python           -> 100.0% (1102/1102), gate exit 0
  ast gap count                       -> 0 across 0 files
  pytest tests/ -q                    -> 5785 passed, 9 skipped, 9 failed
                                         (the 9 are the known test_examples "Invalid JSON"
                                          local-checkout defect, task #66 — unchanged)
  ruff format --check signalwire      -> 215 files already formatted
  ruff check signalwire               -> All checks passed
  mypy --config-file pyproject.toml   -> Success, 359 source files

A 100% floor means the next undocumented public symbol reds the gate. That is the point.

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

The two spellings of the SAME type disagreed. A SWAIG tool written in modern PEP 604 syntax
emitted a schema demanding a parameter its author had explicitly made nullable.

    def h(city: Optional[str]) -> None: ...   ->  required=[]        correct
    def h(city: str | None)    -> None: ...   ->  required=['city']  WRONG

ROOT CAUSE, core/agent/tools/type_inference.py:41. `_resolve_type` detected optionality with

    if origin is typing.Union:

`Optional[str]` and `Union[str, None]` do carry `__origin__ is typing.Union`. But `str | None`
is a `types.UnionType`, a DIFFERENT object whose origin is not typing.Union — so the branch
never matched, the annotation fell through to the scalar path, `is_optional` came back False,
and the parameter was marked required. The fix accepts both:

    if origin is typing.Union or isinstance(annotation, types.UnionType):

Verified across all three shapes, not just the failing one:
    Optional[str]  -> required=[]       {"type": "string"}
    str | None     -> required=[]       {"type": "string"}    <- was ['city']
    int | str      -> required=['x']    {"type": "string"}    <- genuine non-optional union,
                                                                 still required, still the
                                                                 documented string fallback

HOW IT WAS FOUND, because the mechanism is worth recording. tests/ has never been linted
(FMT/LINT target only signalwire/; EXAMPLES-* cover the example dirs; nothing covered tests/).
Bringing tests/ under the SDK ruleset ran `ruff --fix`, whose pyupgrade rules rewrote the
test file's `Optional[X]` annotations to `X | None`. Those annotations are not incidental
style in that file — they are the INPUT DATA to the function under test. The rewrite turned
`test_optional_type_is_not_required` red with `assert 'city' not in ['city']`, which is the
SDK bug stated exactly.

So the autofix did not break the test; it removed the only reason the bug was invisible. The
test suite had 5785 passing tests and none of them exercised PEP 604 optionality.

CONSEQUENCE FOR THE FLEET: this is reference behaviour, so every port's type-inference
equivalent needs the same question asked of it — does it recognise the language's modern
nullable spelling as optional, or only the legacy one? Filed rather than swept here, since
each port's answer is different (several have no equivalent syntax at all).

Verification:
  pytest tests/ -q  ->  5785 passed, 9 skipped, 9 failed
                        (the 9 are the known test_examples "Invalid JSON" local-checkout
                         defect, task #66 — the type-inference failure is gone)
  ruff check signalwire  -> All checks passed
  mypy --config-file pyproject.toml -> Success, 359 source files

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

Owner-ruled 2026-07-29: cover the whole repo, at the strictness the SDK already gets.

BEFORE THIS, tests/ WAS NOT LINTED OR FORMATTED AT ALL. FMT/LINT target only `signalwire/`;
EXAMPLES-FMT/EXAMPLES-LINT cover the three example dirs; nothing covered tests/, scripts/ or
eng/. That is not theoretical: earlier today I added an unused `import socket` to
tests/unit/relay/conftest.py and no gate caught it — I found it by running ruff by hand.

MEASURED START, under the SDK's own select list
(E4,E7,E9,F,B,S,C4,PERF,SIM,PTH,RET,RUF,UP):
    tests/    1291 findings, 95 of 144 files would reformat
    eng/         9 findings
    scripts/     1 finding

CARVE-OUTS FOR tests/**, owner-ruled to match what examples/** already gets, plus the
pre-existing S101. Each is a test IDIOM, not a defect:
    S101       a test's whole job is `assert`.
    E402       tests/conftest.py MUST `sys.path.insert(0, project_root)` BEFORE importing
               signalwire.*, so imports-not-at-top is structurally forced. Verified at
               source (conftest.py:25-27 then :30+), not assumed. 302 of the 1291.
    S104       binding 0.0.0.0 is how a server test says "listen on all interfaces".
    S105/S106  literal "password"/"token" values ARE the fixture, not leaked secrets.
EVERYTHING ELSE STAYS ENFORCED — F401 unused imports, F841 unused variables, SIM117,
RUF015/RUF012/RUF059, UP035/UP045, E702, and every other bandit rule still red the gate. The
carve-outs took 1291 -> 785; they did not make tests/ green.

THIS COMMIT IS THE MECHANICAL HALF: `ruff check --fix` (safe fixes only, never
--unsafe-fixes) plus `ruff format`, run to convergence — autofix, format, autofix again,
format again, until both are stable. 308 findings fixed, 106 files reformatted, 147 files now
format-clean.

    785 -> 474 remaining, all requiring human judgment. They are NOT suppressed and NOT
    allow-listed; they are the next commit's work. The gate is deliberately NOT wired yet —
    burn to zero before wire, per the standing rule.

A REAL SDK BUG FELL OUT OF THIS AND IS FIXED SEPARATELY IN 8066297. The pyupgrade rules
rewrote the type-inference tests' `Optional[X]` annotations to `X | None` — annotations that
are the INPUT DATA to the function under test, not incidental style — and
test_optional_type_is_not_required went red. That was not the autofix breaking a test; it was
the autofix removing the only reason a real bug was invisible. `str | None` was reported
REQUIRED while `Optional[str]` was not, because `_resolve_type` matched only
`typing.Union` and not `types.UnionType`. 5785 tests had never exercised PEP 604 optionality.

Verification, run at each step rather than once at the end:
    pytest tests/ -q  ->  5785 passed, 9 skipped, 9 failed
                          The 9 are the known tests/test_examples.py "Invalid JSON"
                          local-checkout defect (task #66), unchanged from the pre-work
                          baseline. Nothing new is red.
    ruff check tests scripts eng   -> 474 (from 1291), no fixable remainder
    ruff format --check tests scripts eng -> 147 files already formatted
    ruff check signalwire          -> All checks passed (unaffected)
    mypy --config-file pyproject.toml -> Success, 359 source files

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
35 findings, all in tests/:

* RET504 (12) — factory/fixture helpers that assigned then immediately
  returned. Returned directly; every one is a short helper where the
  intermediate name carried no meaning.
* SIM105 (12) — try/except/pass -> contextlib.suppress(...). Each site is a
  deliberate ignore: async task-cancellation cleanup, fixture teardown, and
  control cases that assert on the request journal rather than the exception.
* SIM115 (4) — tests/unit/search/test_search_engine.py setup_method used
  NamedTemporaryFile(delete=False) purely to mint a temp PATH, then closed the
  handle on the next line and never touched it again. Replaced with
  tempfile.mkstemp + os.close(fd), which drops the lingering handle and a dead
  self.tmp_file attribute. This file already documents the same reasoning at
  TestSearchEngineInit.
* SIM102 (6) — collapsible nested `if` in tests/test_examples.py; all six are
  `if returncode != 0:` wrapping a single message-sniffing `if`, no else and no
  intervening statements.
* SIM103 (1) — if/return True/return False folded to the condition.

SIM117 (88) is not in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
99 collapses closing the 88 findings ruff reported: collapsing an outer/inner
pair exposes the next level of a 3- and 4-deep chain, so it took three passes.
Every site is now the parenthesised multi-context form `with (\n a,\n b,\n):`,
which is what `ruff format` wants, so FMT stays a no-op.

Safety: `with A(), B():` evaluates A(), enters it, THEN evaluates B() — the
same ordering as nesting — so a collapse is behaviour-preserving unless the
inner header depends on a name the outer binds. Audited all 88 mechanically
(AST: intersect the outer's `as` targets with the names in the inner header):
zero dependencies. The context expressions are exclusively patch / patch.object
/ patch.multiple / patch.dict / pytest.raises, no exotic shapes.

8 comments sat BETWEEN an outer and inner `with` and had no home after the
collapse; each was re-attached to the with-item it annotated rather than
dropped. Verified by diffing the per-file multiset of comment lines against
HEAD: zero drift.

Verification (all from the worktree root):
  ruff check tests scripts eng --select SIM,RET  -> 0
  ruff format --check tests scripts eng          -> 147 unchanged
  ruff check signalwire                          -> All checks passed
  mypy --config-file pyproject.toml              -> error set byte-identical
                                                    to the base commit (85)

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 September 28, 2026 03:59
… (PUBLIC-JARGON)

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

generate_comprehensive_post_data now emits exactly the field set of porting-sdk
swaig-specs/swaig-request.yaml (mod_openai actions.c execute_user_function): every
always-sent key (ai_session_id, app_name, argument{parsed,raw}, argument_desc,
call_id, channel_*, content_type, content_disposition, description, function,
version) plus the conditional keys a fully-configured call carries (caller_id_*,
project_id/space_id, global_data, meta_data_token+meta_data, call_log/raw_call_log).
Gone: the invented call / vars / params / prompt_vars / swml_env / request_headers /
http_method / webhook_url / user_agent / swaig_* flags / meta_data envelope. The CLI
passes the function's description and parameter schema (argument_desc).

tests/unit/cli/test_swaig_request_simulator.py validates the body (and the body after
the CLI mappings) against the vendored spec, closed at the root; negative controls:
invented keys and missing required keys are rejected (14 of 16 fail on the old code).
docs/cli_guide.md: the full-data section describes the engine shape.

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

Diagnostic only, for the one-off CI 3.13 timeout of
TestPerRequestConfiguration::test_a_blocked_callback_doesnt_hold_up_other_calls seen in
run 36384280432: on TimeoutError the test now pytest.fails with _dump_stacks() (every
thread's stack and every asyncio task's awaits), so the next occurrence says where the
fast request was waiting. The assertion is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
A class-form TypedDict cannot declare a key that is a Python keyword or
not an identifier. The generator used to drop those keys to a
"# non-identifier field" comment, so ConnectConfig["from"],
CondItem["else"], Expression["nomatch-output"] and 60 more were
untyped. porting-sdk/scripts/generate_python_rest_types.py now emits
each such declaration in the functional form,
X = TypedDict("X", {...}, total=False), with the annotations quoted and
the docstring assigned to X.__doc__.

"non-identifier field" comments: 63 before, 0 after.

The change only widens the types. Every key that was declared before
is still declared with the same annotation, totality and docstring, and
the only additions are the keys that had been comments. No entry point
signature changes.

tests/unit/core/test_swml_keyword_keys.py covers:
- the new keys type-check;
- a wrong "from" type is rejected (a negative control held by
  warn_unused_ignores);
- the runtime shape of the functional form;
- old-style calls still type-check: plain dicts to set_params and
  set_param, arbitrary ai() kwargs, and connect's existing keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
`SWMLBuilder(service).label("start")` produced no verb and no error: verb
methods now take a positional body (`verb(config)`), `_verb_body` passes a
non-mapping body through, and `add_verb` rejects it with only an
`invalid_config_type` log line. On main the same call raised `TypeError`
(verb methods took keyword arguments only).

When `add_verb` does not add a positional non-mapping body, the generated
verb methods (SWMLBuilder and both SWMLService paths) now raise `TypeError`.
Bodies add_verb accepts are unchanged: object/keyword bodies and sleep's
integer form. Non-breaking vs main: the call that raised there raises again.

Tests (real SWMLService, no mocks): builder + service refuse `label("start")`
(RED before this change: DID NOT RAISE); keyword, mapping and sleep(1000)
bodies render as before; an invalid keyword body still raises
SchemaValidationError, as on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
Owner ruling 2026-09-28: fsvars is never available in production and must
not appear in public specs. mod_infrastructure 694f6094 tags every fsvars
declaration @api-hidden (the engine ignores it unless
FREESWITCH_DANGER_ALLOW_FS_VARS is set, which production never sets).

Reverts the fsvars half of 7ec985c; username/password stay. Non-breaking
vs main f870cc1, whose answer() never had fsvars.

Depends on the vendor chain: the bundled schema.json still publishes
answer.fsvars, so test_hand_written_verb_accepts_every_public_spec_param
[answer] fails until schema.json is re-bundled without it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
fsvars is hidden from every public spec (owner ruling 2026-09-28: a
staging-only FreeSWITCH-variable hack, gated off in production by
danger_do_allow_customer_to_set_fs_vars). Re-bundled with
port_schema_bundle.py sync; regenerated types (swml_verbs_generated drops
fsvars from AnswerConfig/ConnectConfig/ConnectDevice). The union-key pin for
connect goes 32 -> 31: the only key the spec removed is fsvars.

Non-breaking vs main (f870cc1): main's bundled schema.json has no fsvars, so
`answer/connect(fsvars=...)` raised SchemaValidationError there and still does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKNEp6YffXdC6RyDjtU4tk
…+ 32 ops in fabric/whatsapp/relay-rest

Regenerated from porting-sdk rest-apis (hand-added, verified against prime-rails@29d6dce4b550).
All additive: new resources, new methods, one new optional constructor argument; no existing
signature changes.

* RestClient(personal_access_token=... / SIGNALWIRE_PERSONAL_ACCESS_TOKEN): client.space is
  wired to a PAT HttpClient (HTTP Basic, empty username); a client may hold either
  credential or both, and a resource whose credential is missing raises ValueError.
* HttpClient: per-call headers on get/post; get_text (non-JSON success) and
  get_redirect_location (a success that IS a redirect) for billing_statement.csv / .pdf.
* client.space.{settings,geographic_permissions,billing_profile,billing_statements,usage,
  payment_history,members,balance,low_balance_setting,payment_methods}.
* client.fabric.{alias,sip,phone_number}_addresses, fabric.addresses.delete,
  fabric.resources.assign_sip_endpoint / assign_whatsapp_number.
* client.whatsapp.{numbers,businesses,templates}.
* addresses.update, registry.brands.update, phone_numbers E911 + CNAM methods.
* Tests: generated wire tests (success + error per route) over the shared mock, now with
  a per-test PAT; test_space_mock.py for the PAT credential, text/redirect reads and the
  Idempotency-Key header.

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

porting-sdk corrected fabric and relay-rest base facts from prime-rails@29d6dce4b5. The
python effect is additions and widenings: new optional params and response fields, and
required params the server does not require made optional. No method, param or field is
removed or renamed.

Tests: test_code_verified_rest_fields.py sends each newly declared field over the shared
mock and asserts no STRICT-MOCKS wire violation (9 of 10 fail against the previous base).

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

Composed specs now carry the DevEx prose harvest (api-reference-specs 95829db, all draft).
The generator reads schema descriptions only, so 4 generated modules gain TypedDict
docstrings; no type, field or signature changes (python_signatures.json and
python_surface.json regenerate byte-identical).

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

porting-sdk e79dec7 corrected datasphere, fabric, messages, projects, relay-rest, space and
video base facts from prime-rails@61268748c7. The python effect is additions, widenings and
required -> optional; every changed param is keyword-only. No method, param or field is
removed or renamed:
* messages.create(): message_type, template_id, header/body/button_template_parameters;
  body also accepts dict | list.
* fabric.tokens.create_subscriber_token(): scope, fingerprint.
* fabric.conference_rooms.create(enable_room_previews=), video.streams.update(url=),
  space.settings.update(name=) are optional.
* video date fields (join_from / join_until / remove_at) accept str | float.
* response types: AvailablePhoneNumber +e164/national/international_number_formatted/
  country_code, AssignedNumber +status_callback_url, CXMLScriptResponse +display_name,
  ActiveSession +sync_audio_video, Recording union +RelayConferenceRecording; ProjectUpdate
  is its own TypedDict (same keys as before).

Tests: test_code_verified_rest_fields_round2.py sends the new/loosened fields over the
shared mock and asserts no STRICT-MOCKS wire violation (the 3 new-field tests fail against
the previous base).

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

Owner ruling 2026-09-29: the Compatibility API is not in the SDKs and the rest of the specs must
not reference it. The only change is the RelayVoiceLog docstring ("Voice log for cXML and
Relay call types" instead of "... for Compatibility and Relay ..."). python_signatures.json and
python_surface.json regenerate identical (ORACLE-FRESH: both FRESH against this tree).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
…-bundle (psdk b81e637 / ARS 0fd0e3b)

Owner ruling 2026-09-29: "we will not be including compat api in sdks at all.. we will make a
completely seperate public spec that is kept out of the main index, so its not private, but the
rest of the specs shouldnt reference. customers will not be able to get to any compat api
without approval".

- docs/security.md: drop "migrating from the old `@signalwire/compatibility-api` shape";
  "cXML and other compatibility endpoints" -> "cXML webhooks"; the X-Twilio-Signature alias
  described as applying on cXML requests.
- core/security/webhook_validator.py validate_request / webhook_middleware.py module
  docstrings: same rewording (docstrings only).
- mcp/swml-schema-search/README.md sample output: join_conference line.
- schema.json re-bundled (port_schema_bundle.py sync); swml_verbs_generated.py
  JoinConferenceConfig docstring regenerated. REST regen: no change.

Docstring/doc-only: no signature or surface change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
… not breaking vs 3.x main)

Regenerated from porting-sdk 3d1defd (REST base: held set + audit BASE-BUG fixes, embedded SWML
generated from schema.json with dial/eval/if withheld; schema.json from api-reference-specs
6610c1f: enter_queue.wait_time default 180). A/B against f870cc1 (1071 baseline-working calls):
1049 identical, 6 wire changes (the plural call_flows/conference_rooms URLs the server routes;
calling.record audio sent as params.record.audio), and 16 calls that omit a field the server
rejects without (422 today: cxml_scripts name, sip_endpoints password, queues name, campaign
order phone_numbers) -- every one of them still works when the field is passed via extras.

- rest/_base.py: _required_via_extras -- a required argument supplied through extras={...}
  satisfies the requirement (generated methods are decorated where it applies).
- calling.record(audio=) -> params.record.audio; new optional record=.
- core.logging_config.strip_control_chars(*args): the 1-arg and the structlog 3-arg forms.
- Deprecated aliases for the 1:1 renamed generated types (24 swml_verbs_generated, 3
  swml_webhooks_types_generated, cli.types.PostData); ConnectDevice*/Contexts stay callable;
  CHANGELOG lists the names with no single replacement and SwmlRequestCall.
- New: AiAgents.list_voices() / list_conversation_logs().
- Tests: plural fabric sub-paths, record wire shape, required-via-extras unit tests.

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

Regenerated from porting-sdk's composed REST specs:
- fabric AI agent: prompt/post_prompt/languages/SWAIG/pronounce/hints/params typed from the
  SWML `ai` verb plus prime-rails' transforms (AIAgentPrompt, AIAgentPostPrompt,
  AIAgentLanguage, AIAgentSWAIG, AIAgentSWAIGFunction, AIAgentSWAIGInclude, AIAgentPronounce).
  The 157 fabric type names no fabric operation uses keep importing as deprecated aliases
  (155; CHANGELOG lists the two with no replacement).
- calling.* command params from the RELAY calling methods (relay_apis.c): new optional kwargs
  where the engine accepts more; Relay* sub-schema types. control_id is generated when
  omitted on play/record/collect/detect/tap/stream/transcribe (x-sdk-autofill) -- the
  engine rejects those commands without one.

A/B vs f870cc1 (every generated method, the ways a 3.x caller can call it): 1071 working
cases, 1009 same wire, 16 break (unchanged: the server-required fields ruled before), 46
wire diffs = 40 with a generated control_id added + the 6 earlier intended changes.

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
…dden

Regenerated from porting-sdk wave6/ctor-dunder-fold (ARS 381b8b1 round trip):
- Calling gains ai_sidecar, ai_sidecar_ask, ai_sidecar_poke, ai_sidecar_stop and
  ai_sidecar_status -- the calling.ai_sidecar / .ask / .poke / .stop / .status commands
  POST /api/calling/calls accepts (mod_infrastructure relay_apis.c JSON_CHECK
  call_ai_sidecar*, whose key lists include async). Params composed from the engine's
  allowlists; ai_sidecar takes a `params` kwarg, so the generator names its local
  `command_params` there.
- Project (projects_types_generated) drops region_preference: feature-flag gated, owner
  2026-09-30: hidden until the region_preference feature is released (flag GA).
- Mock-backed wire tests for the five commands in tests/unit/rest/test_calling_mock.py.

A/B (every generated REST method of f870cc1, called every way it can be): the 1122
baseline-working cases produce the same wire on this tree as on b32c3dd (autofilled
control_id uuids normalised); method set vs b32c3dd: +5, -0, 0 signatures changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
Brings PR #78 current with main 6bdf2eb (release 3.5.1). Conflicts, resolved by
reading both sides:

- signalwire/core/data_map.py, docs/api_reference.md, tests/unit/core/test_data_map.py:
  this branch removed DataMap.body() and create_simple_api_tool(body=) (b6f5b30,
  dafa0f1) because body() wrote a `body` key nothing reads. main (555dcbb, released
  in 3.5.1) instead made both send `params`, the key the platform reads. Taken
  main's side: body() and body= now work in a released version, so removing them
  would break working 3.5.1 code, and the removal's premise (the payload is
  discarded) no longer holds. Dropped the tests asserting their absence
  (TestBodyBuilderRemoved, test_create_simple_api_tool_rejects_body); kept
  test_create_simple_api_tool_emits_no_body_key. Needs the owner's confirmation.
- CHANGELOG.md: [Unreleased] (this branch) above [3.5.1] (main).
- pyproject.toml: both added the same mypy override for deprecated.*; main's text.
- ai_chat/handoff.py, cli/simulation/mock_env.py: docstrings; main's, which
  describe its locking and the new `omit` parameter.
- skills/native_vector_search/skill.py: main's TYPE_CHECKING import of
  _PublicSession; AgentBase dropped, as this branch had (unused).
- tests (test_datamap_templates, test_bedrock_agent, test_function_result,
  test_mcp_gateway_skill, test_native_vector_search_skill): main's behavior with
  this branch's formatting and imports; test_bedrock_agent keeps this branch's
  "a voice Bedrock does not offer".

Follow-ups for main's code under this branch's gates:
- ruff format over the FMT/REPO-FMT scopes (12 files from main).
- ruff check: import pytest in test_execution_mode.py (the merge kept this
  branch's removal of the import and main's new parametrize), a ClassVar in
  test_unicode_digits.py, one combined `with` in test_web_service.py, an
  unused noqa in test_documented_limits.py, and an explicit timeout= in the
  conftest session stub.
- test_skip_prompt_guard.py stubs _PublicSession.get: main sends
  mcp_gateway's health check through the session, not requests.get.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
main's DataMap simulator (3.5.1) added _Run.log() and _Run.expand() without docstrings;
this branch's DOC-SURFACE floor is 100%, and the merge left it at 1180/1182.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
…nal, relay-rest fixes

Regenerated from porting-sdk wave6/ctor-dunder-fold, whose REST base was
checked against prime-rails' code-derived documents (#10337):

- calling.dial: `codecs` is accepted with a URL as well as inline SWML,
  and `to` may be omitted when `to_script` is given (the Create contract
  needs one of the two).
- messages.update: `body` is optional; the server defaults it to "" and
  an empty body is what redacts.
- relay-rest: recordings.get returns the Recording union (conference
  recordings included); SIP endpoint call_handler accepts laml_webhook;
  SipEndpoint encryption in responses is required|optional; domain
  application call_flow_version drops current_deployed, which the server
  rejects for the call_flow handler and ignores otherwise.

Every change widens or corrects types; an A/B of every generated method
against f870cc1 shows no new break and no wire change.

Tests: a dial with to_script and no `to`, and recordings.get sends
Accept: application/json (prime-rails' recordings#show redirects to the
audio file unless JSON is asked for).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
…n removed; soft AI entry-point typing

Regenerated from porting-sdk wave6/ctor-dunder-fold (owner rulings 2026-09-30):

- `client.recordings.download(id)` and
  `client.video.room_recordings.download(id, media_ttl=...)` return the URL
  the server redirects to (a presigned MP3 URL; the recording's signed MP4
  URI), without following it. Mock-backed tests pin the path, the 302 and
  media_ttl. Every generated redirect method now documents that contract.
- `verified_callers.create` gains optional `country_code` and
  `number_type`, which the server stores when sent.
- `fabric.tokens.create_invite_token` and its types are removed: the route
  takes only a subscriber's Bearer token, so it never worked with the
  project credentials this client sends. Use `create_guest_token`.
- AI entry points (option B, the soft form of the held 9d60694):
  `SWMLBuilder.ai(**kwargs)` gains an overload typed by the generated
  `_AiConfigKwargs` plus the old `**kwargs: Any` one; `set_param` is typed
  by the generated `_AiParamsSetters` overloads, which end in a
  `(key: str, value: Any)` fallback; `set_params` takes
  `AiParams | dict[str, Any]`. Every call that type-checked before still
  does (test_swml_keyword_keys unchanged, no new ignores); runtime and
  rendered SWML are unchanged (test_ai_entry_point_typing).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtqaxuBHY5jMZUQqk7mP5F
Regenerated from porting-sdk wave6/ctor-dunder-fold (local e783401+):
POST /api/relay/rest/verified_caller_ids `number_type` and `country_code` are
overlay-hidden (owner 2026-09-30, option A; cloud-product#21368 --
client-settable trust inputs until the server derives them). They leave
`verified_callers.create`, `CreateVerifiedCallerIDRequest`, and the unreleased
CHANGELOG entry that announced them. Not breaking: both were added on this
branch after f870cc1; the A/B over f870cc1's 1071 working calls matches
51896c2 exactly (the same 20 pre-existing breaks, 0 wire differences).

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

swaig_actions_generated.py: docstring/source-line moves only (no surface change).
schema.json re-bundled from porting-sdk's ARS e892e84 copy (prose line moves).

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

porting-sdk 7af4651 (owner ruling 2026-09-30, R-CHANNELS option C): the POST
/api/fabric/resources/{id}/whatsapp_numbers response channels is oneOf {audio} | {messaging}.
The generator emits that union as dict[str, Any] (was dict[str, str]). Non-breaking:
WhatsappNumberAddressResponse and assign_whatsapp_number do not exist at f870cc1, and the
change only widens a response field's static type.

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

schema.json (and its .sha256 record) re-copied from porting-sdk: native truncated-valueint
floors (`> 0` on a truncated value -> minimum 1, `>= 0` -> exclusiveMinimum -1) and one
shared x-boolean-strings fact replacing x-boolean-string-accepts. Non-breaking: the tightened
bounds are what the engine already accepted (a fractional value below the floor was rejected
server-side by the truncating validator), and no python code reads the renamed extension.
fabric PhoneRouteResponse.channels regenerates as dict[str, Any] (oneOf {audio} |
{messaging}, prime-rails phone_routes handler); a static widening from the f870cc1 shape.

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

Regenerated from porting-sdk 4192fc0 (relay-protocol re-extracted from switchblade
feat/relay-contract-extractor 7b7dfdf). Additive optional keys on total=False TypedDicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ1TTfYuK2bGybf2Cf9KQv
sentence-transformers (requirements-dev.txt) pulls torch, and PyPI's Linux torch brings
~3 GB of nvidia CUDA wheels the tests never use: python Test 36823015697 spent 06:06->06:59
in 'Install signalwire-python (editable + dev deps)' on 3.10/3.11 (gates themselves passed).
Pre-installing torch from the PyTorch CPU index satisfies the requirement so pip keeps the
CPU build. Applied to test, nightly, publish-dev, publish-release, and multi-os (Linux only;
macOS/Windows PyPI wheels are CPU already). test.yml also caches pip keyed on pyproject.toml
+ requirements-dev.txt.

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

porting-sdk schema.json moved 5b91fcf -> fce555f when the stringified @example
values were fixed at source (api-reference-specs tsp_reader parse_value). Data only:
the REST type generator does not read examples; GEN-FRESH stays fresh (2203 types).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JK98iDuVZQ6gQBo3Zj3RAb
…st[str]]

Every return is (bool, list[str]) and the docstring says so; the annotation was the
loose tuple[Any, ...]. Since porting-sdk b457b0d records a variadic tuple as list<any>,
that loose annotation made every port that types the real (bool, list[str]) return
drift. The three sibling validators (SWMLVerbHandler.validate_config,
SchemaUtils.validate_document / validate_verb) already declare tuple[bool, list[str]].
Typing-only; no runtime change.

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

This branch has not been deployed

No deployments
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