Skip to content

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

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

wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold)#55
mjerris wants to merge 26 commits into
mainfrom
wave6/ctor-dunder-fold

Conversation

@mjerris

@mjerris mjerris commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Retires the PORT_SIGNATURE_OMISSIONS.md ctor entries that the shared-diff
ctor/dunder fold makes unreachable. Ledger + nothing else — no port source changed.

The rule is ALLOWLIST_DISCIPLINE.md:495: __init__-as-a-member is EMISSION
(exclude)
, never a surface capability difference. The fold lives in
diff_port_signatures.py::_is_folded_dunder_member (porting-sdk #125) and excludes an
__init__ finding only while the class is in the reference's construction node
a property of the reference, so it cannot drift.

Counts

file before after delta
PORT_SIGNATURE_OMISSIONS.md entries 331 272 −59
PORT_SIGNATURE_OMISSIONS.md lines 632 554 −78
PORT_OMISSIONS.md untouched untouched 0
PORT_ADDITIONS.md untouched untouched 0

All 59 removed entries are <class>.__init__ where <class> is in the oracle's
construction node. cpp had no non-__init__ dunder entries.

Beyond the 59 entry lines, the diff also drops 19 lines of now-dead prose: the three
rationale-tag definitions that documented only deleted entries
(cpp_constructor_default_only, cpp_questions_string, cpp_rest_error_field_layout
each now cited by zero entries) and the ### __init__ default-only / config-struct construction section header, whose entire contents were dead. No surviving entry
references any of them.

Excused-divergence delta — the fold moves it, the prune does not

Two separate measurements, per the campaign's corrected expectation
(_is_folded_dunder_member continues before the excusal branch, so a folded ctor
is not excused — it is not compared at all):

excused divergences
pre-fold differ + unpruned ledger 1118
folded differ + unpruned ledger 1059 (fold delta: −59)
folded differ + pruned ledger 1059 (prune delta: 0)

Flat excused across the prune is the correct signature of dead-weight removal.

Mutual dependency, proven live

The pre-fold differ run against this PR's pruned ledger:

✗ 55 signature drift(s) (and not in PORT_SIGNATURE_OMISSIONS.md):
  signalwire.core.contexts.Context.__init__:
    - param-count-mismatch: reference has 1 param(s), port has 0: reference=['name'] port=[]
  ...
exit=1

That is the evidence for the merge-order line below, not an assertion.

Construction node unchanged

__init__-as-a-member and the §10 construction-param contract are different contracts;
this prune touches only the former.

port construction classes BEFORE : 146
port construction classes AFTER  : 146

port_signatures.json was re-enumerated (python3 scripts/enumerate_signatures.py --out port_signatures.json, exit 0, mtime changed, "wrote port_signatures.json (91 modules,
1928 methods)") and came back byte-identical, so it is not in this diff.

__init__ entries the rule does NOT cover — 3 kept

The guard keeps these LIVE, and they are load-bearing:

  • signalwire.rest._base.CrudResource.__init__
  • signalwire.rest._base.CrudWithAddresses.__init__
  • signalwire.rest._base.ReadResource.__init__

None of the three is in the oracle's construction node, and the reference records no
__init__ member on them either — but the C++ port does emit one, so each is a real
extra-port finding. Removing them reds the already-folded differ:

folded differ vs live-ctors-STRIPPED ledger: exit=1
✗ 3 signature drift(s) (and not in PORT_SIGNATURE_OMISSIONS.md):
  signalwire.rest._base.CrudResource.__init__:
  signalwire.rest._base.CrudWithAddresses.__init__:
  signalwire.rest._base.ReadResource.__init__:

This is the fold's guard doing exactly its job — it cannot trade a visible ledger entry
for a blind spot.

Gate output

bash scripts/run-ci.sh (full, not --rules) — exit 0, ==> CI PASS: 18 gates PASS,
0 FAIL, 6 SKIP (all tier=nightly, deferred to nightly CI by design).

[TEST] build run_tests + run_tests (host) ... PASS
[SURFACE] surface parity suite (SIGNATURES/DRIFT/SURFACE-FRESH/SURFACE-DIFF/GEN-TYPE-DEGENERACY/ROUTE-COLLISION/GEN-IDIOM/SEMVER-DIFF) ... PASS
[TYPE-EROSION] port did not erase a reference-declared param type (ratchet 122) ... PASS
[GEN] generated-code freshness suite (GEN-FRESH/-SWML/-RELAY/-SWAIG/-TESTS) ... PASS
[BEHAVIORAL] behavioral suite, per-PR rules (BEHAVIORAL-*/EMISSION/SKILL-CONTRACT/SWAIG-*/ERROR-ENVELOPE/PAGINATION-WIRED/DOC-WIRE/REST-COVERAGE/SPEC-PARITY/SECURE-DEFAULT) ... PASS
[DOC-TRUTH] doc-truth suite (DOC-AUDIT/DOC-LINKS/DOC-LANG-PURITY/DOC-ENV/COUNT-CLAIM/ACCESSOR-TRUTH/STATUS-CLAIM/README-INCLUDE) ... PASS
[LEDGER] ledger governance suite (SUPPRESSION-LEDGER/IGNORE-LEDGER-VERIFY) ... PASS
[PACKAGE] package suite, per-PR rules (ARTIFACT-DENY/RELEASE-FRESH) ... PASS
[NO-CHEAT] audit_no_cheat_tests ... PASS
[FMT] clang-format (.clang-format; local: apply, CI: check) ... PASS
[LINT] clang-tidy curated set, zero findings ... PASS
[DEAD-PUBLIC-ERROR] exported error types are raised/caught/user-signalled (no dead error surface) ... PASS
[ROOT-HYGIENE] no audit/scratch clutter tracked at repo root ... PASS
[PUBLIC-JARGON] no internal porting jargon leaked into public doc comments ... PASS
[AI-CHAT] AIChatClient speaks the AI Chat protocol per the vendored spec ... PASS
[WIRED-MODES] load-bearing run-ci modes present ... PASS
[DOC-SURFACE] public doc-comment coverage floor ... PASS
[DOC-CLI] documented swaig-test invocations parse against the real CLI ... PASS
==> CI PASS

BEHAVIORAL (which carries BEHAVIORAL-WIRE-RELAY) passed on the first run — no
stdout-corruption re-run was needed despite several heavy lanes competing for CPU.

The _restore_tree trap is a non-issue here: the post-prune re-enumeration produced a
byte-identical port_signatures.json, and the working tree after run-ci.sh still shows
only the one intended markdown modification.

Pre-flight, both clean:

bash scripts/run-format.sh --check   exit=0
bash scripts/run-lint.sh             exit=0

DRIFT via the real gate path (scripts/drift.sh, which is what _surface_commands.py
invokes; cpp has no .drift-numeric-monotype marker):

✓ signatures match (1528 reference symbols, 1947 port symbols, 1059 excused divergences).
==> DRIFT exit=0

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

mjerris and others added 26 commits July 27, 2026 09:09
`signalwire::core::logging_config::get_logger(name)` — the entry point the
reference oracle records as `signalwire.core.logging_config.get_logger`, and
which the 2026-07-24 logger ruling makes MANDATORY surface — returned `bool`
(the internal configured-once flag) and discarded `name` entirely:

    bool get_logger(const std::string& /*name*/) {
      if (!g_configured.load()) configure_logging();
      return g_configured.load();
    }

So a caller could not obtain a logger from the canonical entry point at all.
They had to already know to reach for a DIFFERENT header. Every other port
returns a logger object here (ts Logger, go *logging.Logger, java/php/rust/
dotnet Logger, ruby Logging::Logger), so `bool` was a functional gap, not a C++
idiom.

Now returns `signalwire::logging::Logger` by delegating to the existing
`signalwire::logging::get_logger(name)`, which already built the named-logger
form. This entry point adds only the configure-first guarantee — exactly the
reference's single-entry-point contract. Zero callers depended on the bool
(grepped), so nothing breaks.

WHY IT WENT UNNOTICED: the reference records this function's return as `any`
(structlog's BoundLogger is not an SDK class), and diff_port_signatures.py:153
returns True when EITHER side is `any`. So `bool` compared clean. The gate is
blind here by construction — the concrete cost of an `any` return.

Note C++ has THREE get_logger overloads across two namespaces, with two
different Logger classes and two different LogLevel enums (`Debug`/`Info`/… vs
`DEBUG`/`INFO`/…): `signalwire::get_logger()` → process singleton by reference;
`signalwire::logging::get_logger(name)` → named Logger by value; and this one,
the oracle contract point, which now delegates to the named form. Easy to
conflate — I did, first wiring this to the singleton.

TESTS: 10/10 in the logging suite, and the output proves the contract holds —
`[ERROR][ContractCheck] contract smoke` shows a named, usable logger where the
old signature could only yield a bool. The regression guard is a static_assert
on the return type: it fails to COMPILE if this ever reverts to bool, which
needs no global state to check.

Also fixed `logging_named_get_logger`, which was `auto logger = ...` plus a
"should not crash" comment and NO assertion — it would have passed with the name
dropped entirely.

Deliberately NOT asserting on captured log output: `logging::Logger` streams to
std::cerr with no injection point and keeps name_ private, so observing it means
swapping the PROCESS-WIDE cerr buffer — and test_main.cpp runs tests on multiple
threads, so that steals concurrent tests' output including their ASSERT text.
RULES.md §4: isolation comes from scoping, never from mutating shared state.

PRE-EXISTING, NOT FROM THIS CHANGE: cpp's signature gate exits 1 with 192 drifts
(AgentServer.enable_sip_routing, ToolDecorator.*, …) on clean main as well — both
from the committed artifact and from a fresh regen of an unmodified tree. This
commit's regen moves exactly ONE line, `"returns": "class:signalwire.logging.
logger.Logger"`, and adds zero drifts. The 192 are a separate stale-artifact
backlog.

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

`include/signalwire/logging/logger.hpp` had been ORPHANED from the lint graph:
nothing in the linted set included it. The previous commit adds
`#include "signalwire/logging/logger.hpp"` to `core/logging_config.hpp`, which
pulled it in and exposed 20 pre-existing violations. So they are pre-existing
code, but MY change is why they now fail the gate — LINT went red on this branch,
and leaving it red is not an option.

Fixed all 20, no suppressions and no allowlist entry:
  - performance-avoid-endl x4: `<< std::endl` -> `<< "\n"`. Behaviour preserved:
    endl also flushes, but std::cerr is unit-buffered, so each write already
    flushes regardless.
  - readability-braces-around-statements x18 (10 sites): braced every
    single-statement `if` in get_log_level() and the four log methods.
  - readability-redundant-string-init x2: `std::string level = "";` -> declare.

VERIFIED: run-lint.sh exit 0 (was 20 errors), run-format.sh --check exit 0,
run-tests.sh logging 10/10.

ALSO RETRACTING A FALSE ALARM FROM THE PREVIOUS COMMIT MESSAGE. It claimed cpp's
signature gate "exits 1 with 192 drifts on clean main". That was MY invocation
error, not a real red:

    with --surface-omissions + --surface-additions:  exit 0
    without them (what I ran):                       exit 1

CLAUDE.md §5b states the bar explicitly — "Exit code 0 from
diff_port_signatures.py (with all 3 surface flags)". I omitted two of the three
and read the resulting phantom as a backlog. The real gate agrees:
`run-ci.sh --rules SIGNATURES,DRIFT` => `==> CI PASS`.

The 192 phantoms were dominated by the known REST-shape family (25 on
RestClient) — and cpp is not even an outlier there: every port emits the same 6
Namespace classes while flattening a varying number of accessors onto RestClient
(rust 32, php 28, cpp 27, dotnet 6, ruby 6, java 5, go 1, ts 1). That is task
#38's crud_bases/REST-shape item, and the surface ledgers already account for it.

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

The shared-diff ctor/dunder fold (_is_folded_dunder_member, porting-sdk #125)
excludes __init__-as-a-member findings while the class is in the reference's
construction node, so the ledger entries that excused them can no longer be
reached. Remove the 59 dead entries.

  PORT_SIGNATURE_OMISSIONS.md entries: 331 -> 272 (-59)

All 59 are <class>.__init__ where <class> is in the oracle's construction node;
cpp had no non-__init__ dunder entries. Also drops three rationale-tag
definitions now cited by zero entries (cpp_constructor_default_only,
cpp_questions_string, cpp_rest_error_field_layout) and the emptied
"### __init__ default-only / config-struct construction" header.

Three __init__ entries are NOT covered and stay:
signalwire.rest._base.{CrudResource,CrudWithAddresses,ReadResource}.__init__ --
absent from the construction node, and the C++ port emits an __init__ the
reference does not record, so each is a real extra-port finding. Stripping them
reds the already-folded differ (exit=1, 3 drifts).

Excused divergences: the FOLD moves them 1118 -> 1059 (measured with the
pre-fold differ at 66d351a^); the PRUNE leaves them flat at 1059, because the
fold continues before the excusal branch. The section-10 construction contract
is untouched: port construction classes 146 -> 146.

PORT_OMISSIONS.md and PORT_ADDITIONS.md are deliberately untouched -- different
tool, hard dead-entry gate.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
The signature oracle now records DERIVED public __init__ attributes that are
caller-observable VALUES (porting-sdk d7c859d + 387667e, ALLOWLIST_DISCIPLINE
class B2). Six of the seven landed on cpp as drift; this closes all of them.

Already implemented, needed no port change:
  SignalWireRestError.request_id  — HttpClient already extracts it from the
                                    response headers and exposes request_id().

Folded at the enumerator (the accessor existed; the projection did not list it):
  Action.completed  — the unified C++ relay::Action already has completed()
                      (is_done() delegates to it). Added to the base-Action
                      projection in BOTH enumerators, alongside control_id.

Implemented:
  SWMLService.ssl_enabled / .ssl_cert_path / .ssl_key_path / .domain
      The reference copies these four off self.security in __init__ and lets
      run() override them. cpp resolved TLS straight from the environment at
      serve() time and stored nothing, so the values were not observable at
      all. Now the ctor builds a SecurityConfig from config_file + the service
      name and seeds the four fields from it (the reference's exact wiring), an
      accessor+setter pair reads/overrides each, and BOTH serve() paths
      (swml::Service and AgentBase) drive TLS off those fields instead of
      re-reading the env. SWML_SSL_* still applies — SecurityConfig reads it —
      but an explicit set_ssl_*() now wins, matching run(ssl_enabled=…).

  SpiderSkill.remove_xpaths
      The reference PREFILLS seven selectors in __init__ and drop_tree()s each
      match before extracting text. cpp had no equivalent: its naive tag-strip
      replaced tags with a space, so <script> source and <style> CSS leaked
      into the "scraped content" it handed the LLM. Now a prefilled field with
      a reader/setter drives an element-drop pass (tag AND body) ahead of the
      strip.

Defect found while wiring the above: "spider" is registered TWICE — by
src/skills/builtin/spider.cpp AND by SpiderSkillR in src/skills/skill_registry.cpp.
SkillRegistry::register_skill overwrites, so which implementation a caller gets
is static-init order; SpiderSkillR is the one that currently wins (proved via
skill_description() == "Web scraping"). remove_xpaths is implemented on BOTH so
the surface is honest either way, and the signature projection requires the
accessor in BOTH sources. The duplicate itself is pre-existing and is NOT
resolved here — it needs its own change.

Tests: 3 new SWMLService TLS cases (default-off, seeded-from-SecurityConfig,
settable-after-construction) and 1 new spider case proving every default
selector's content is dropped while the real body survives. Action.completed
was already covered by test_relay_action.cpp.

DRIFT 6 -> 0; run_tests 2043/2043.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Two curated-set errors from the previous commit, both in the spider
xpath-drop pass:

  performance-inefficient-string-concatenation — the element-drop regex was
  assembled with chained operator+ on std::string, allocating a temporary per
  link. Build it with reserve() + append().

  bugprone-exception-escape — the scrape/crawl tool handlers captured the
  xpath list BY VALUE, so constructing the lambda's closure could throw
  (vector copy allocates) inside a handler the checker requires to be
  nothrow. Capture a shared_ptr<const vector<string>> instead: the copy is
  nothrow, the list is still shared by value rather than through `this`
  (a ToolDefinition outlives the skill instance that registered it, so a
  `this` capture would dangle), and it is made once at registration.

LINT exit 0 (0 errors); run_tests 2043/2043.

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

Filed as "spider is registered twice". It was every built-in skill.

Each src/skills/builtin/<name>.cpp class had a twin (<Name>SkillR) in
skill_registry.cpp claiming the same name. register_skill OVERWROTE silently, so the
registry copy won EVERY time — while both surface enumerators read only builtin/.
The port's entire enumerated built-in-skill surface was being measured against code
that never ran. Parity could pass against dead code.

`datetime` was worse: both classes were literally signalwire::skills::DateTimeSkill in
two translation units — an ODR violation. The linker merged them and the BUILTIN won,
which is why datetime resolved the OPPOSITE way from spider in the same binary.

THE LIVE COPIES WERE THE BROKEN ONES. swml_transfer had no expressions so no transfer
ever fired; play_background_file did nothing; info_gatherer never advanced its state
machine; datasphere_serverless sent no auth; custom_skills gave every tool an empty
schema; datetime ignored the timezone argument.

TWO REAL DEFECTS UNDERNEATH:

1. WIRE BUG (datasphere) — the one case where the twin was right. The reference POSTs
   the collection path with document_id in the BODY, sends `distance`, and parses
   `chunks[]`, commenting "DataSphere API returns 'chunks', not 'results'"
   (signalwire-python skills/datasphere/skill.py:244-245 — verified directly). The
   builtin used a per-document path and parsed `results[]`, so it returned EMPTY
   against the real upstream. Correct shape ported across.

2. INVENTED SURFACE (swml_transfer) — making the builtin live turned SKILL-CONTRACT
   red: it emitted an `enum` the reference does not have. Removed, per "invented ->
   delete it". SKILL-CONTRACT 12+1-diff -> 13/13 matching.

MADE UNREPRESENTABLE: register_skill now THROWS on a duplicate name (a second
registration of an existing name is always a bug). Re-introducing the original defect
now SIGABRTs at static init naming `spider`, rather than silently picking a winner.

PROVED NO SKILL WAS LOST, mechanically rather than by inspection: rebuilt the
pre-change tree from stash and enumerated the LIVE registry on both builds —
18 before, 18 after, `diff` exit 0. 16 of 18 now report the builtin's
reference-matching description.

All three mutations RED: guard removed -> guard test fails; remove_xpaths emptied ->
both strip tests fail; original defect re-introduced -> SIGABRT.

Tests 2046/2046. FMT/LINT/DRIFT exit 0. SURFACE all 8 rules pass. port_signatures.json
byte-identical (derived-attr work intact). No omission/addition/allow-list entry.

BEHAVIOUR CHANGE worth a release note: 16 skills change behaviour, all TOWARD the
reference, but three go from non-functional to functional.

Pre-existing and untouched: BEHAVIORAL:SECURE-DEFAULT (the legacy self-classifying
dump in tools/secure_default_dump.cpp, upstream gate redesign).

Refs #91
…eb_hook_url

TWO defects, shipped together because either alone leaves the port broken.

1. THE BRIEFED ONE. AgentBase::build_swaig_functions (agent_base.cpp:1489) passed the
   local webhook URL to to_swaig_json(url) UNCONDITIONALLY, so an insecure tool got its
   own tokenless web_hook_url — an unauthenticated function-specific callback. C++ has no
   per-tool external webhook, so the reference's three-way branch reduces to the elif:
       const bool wants_own_webhook = !token.empty() || !swaig_query_params_.empty();

2. cpp emitted SWAIG.defaults ONLY when the constructor's default_webhook_url_ was set
   (pre-fix agent_base.cpp:1616-1622 was the sole emitter). Verified with a live render
   probe BEFORE fixing: the guard alone left the insecure tool with no reachable callback.
   Now emitted whenever functions exist per agent_base.py:1108-1113, with an explicit
   default_webhook_url still winning.

cpp is the SIXTH and final affected port, and all six had BOTH defects — the defaults gap
was universal, not incidental. The gate structurally cannot see defect 2 (php proved it
stays GREEN with the block removed), so every one would have shipped green with
unreachable insecure tools.

VERIFIED INDEPENDENTLY (orchestrator): diff_port_secure_default.py --port cpp -> EXIT 0,
"✓ PASS — cpp".

Lane verification: full suite 2048/2048 exit 0. Mutation 1 (guard removed) -> 3 tests fail,
exit 1. Mutation 2 (defaults removed) -> 1 test fails, exit 1. Both restored byte-identical
(diff exit 0) and re-run green. drift.sh exit 0 (1557 ref / 1953 port symbols); SURFACE
PASS; FMT and LINT exit 0. Existing tests that asserted the tokenless key was PRESENT
encoded the defect and were corrected; 4 URL-composition tests retargeted by rendering with
a call_id — intent preserved, none weakened.

FOUND, NOT CHASED: core::SWAIGFunction::to_swaig is DEAD CODE carrying the same
unconditional-webhook shape — the same defect if it is ever wired. And cpp conflates the
agent-level webhook override (reference: defaults-only) into the per-tool entries;
pre-existing and separate.

Not in this commit: CMakeLists.txt, include/signalwire/security/session_manager.hpp,
src/security/session_manager.cpp, scripts/run-ci.sh and untracked tools/token_interop_mint.cpp
are a concurrent TOKEN-INTEROP lane's work in this shared checkout.

Refs #95
…mit made illegal

MY REGRESSION, caught by the webhook-cpp lane, which correctly refused to absorb it into
its own work.

f0b5df5 (mine, earlier today) made SkillRegistry::register_skill THROW on a duplicate
name — deliberately, because silent overwriting is exactly what let two different classes
both claim "spider" and hid which one was actually live. But tools/state_dump.cpp:192 was
re-registering "custom_alpha" a second time to assert that registration was IDEMPOTENT,
with a comment saying "a duplicate name is a no-op".

That is no longer true, so state_dump aborted (exit 134), the differ saw empty stdout, and
BEHAVIORAL-STATE went red. The lane proved it was pre-existing relative to its own change
by stashing all 5 of its files and rebuilding at the untouched tip — identical abort.

FIXED BY DELETING THE ASSERTION, not by weakening the guard. The duplicate call bought
nothing: the delta computed immediately below (names added over the pre-existing set)
already proves the registry does not grow spuriously. The stale comment is replaced with
the reason it went away, so nobody re-adds it.

VERIFIED: state_dump exit 0 emitting real JSON (was exit 134, empty);
diff_port_state.py --port cpp -> EXIT 0, "✓ PASS — cpp".

Kept separate from the webhook security commit (3f8fe97) because it is a different defect
with a different cause — mine, not the port's.
…aders

The enumerator recorded `"default": null` for every one of the 810 parameters it
knew had a default — it detected only that an `=` existed, never what followed.
The planned defaults-comparison gate would therefore have been VACUOUS for cpp:
passing on silence, the same failure mode that let a 4x-too-long SWAIG token
replay window (token_expiry_secs 3600 vs the reference's 900) sit unnoticed in
four ports until a human happened to look.

C++ declares real default arguments, so the value is right there in the header.
libclang's Python binding has no clang_getParmDeclDefaultArgument, but the
PARM_DECL cursor's token extent covers `<type> <name> = <expr>`, so the default
expression is recoverable as the tokens after the parameter's top-level `=`.

_parse_cpp_literal reduces that token list to a JSON-comparable value: integers,
floats, bools, string literals (including `""`), `nullptr`/`std::nullopt` -> null,
and `= {}` resolved against the parameter's canonical type. A default that is NOT
a static literal — an enum value (`Color::Red`), a constructor call
(`std::string("x")`), an arithmetic expression (`60 * 60`), a named constant
(`kDefaultMaxFileSize`) — is deliberately NOT evaluated and stays null. A guessed
value would be worse than a missing one: it makes a correct port look defective,
and "fixing" the port to match an invented default is how an insecure default
gets introduced for real.

Depth for the top-level `=` is counted PER CHARACTER, not per token: libclang
emits a nested template close as the SINGLE token `>>`. Decrementing once per
token left depth permanently positive for every
`std::optional<std::vector<std::string>>` parameter, so its `=` was never seen
and a real default read as NO default — flipping `required` false -> true on 32
params. Caught by an additive-only differ that asserts every field except
`default` is byte-identical before and after.

Comparable-to-reference params with a recovered default: 5 -> 128.
Reference-has-a-default-but-port-records-null: 131 -> 8.
The 8 remaining are honest and each explained: 5 are on record_call/tap, whose
PREFER_TYPED_OVERLOAD-selected enum overload genuinely declares those params
required; max_file_size is the named constant kDefaultMaxFileSize (the reference
does not record a value for it either, storing the string '100 * 1024 * 1024');
add_subsection's `numbered` is `std::nullopt` against the reference's False; and
replace_in_history's `text` has no default in C++ at all.

Recovering the values immediately surfaced 9 real port-vs-reference divergences
the null-filled output had been hiding. They are divergences in the C++ SOURCE,
verified by reading the headers, not enumerator errors — reported here, not
"fixed" by tampering with the enumerator:

  FunctionResult::pay          postal_code  = "true" (string)  ref True (bool)
  SWMLService/WebMixin         path         = "/"              ref "/sip"
  InfoGathererAgent            route        = "/"              ref "/info_gatherer"
  SurveyAgent                  route        = "/"              ref "/survey"
  ReceptionistAgent            route        = "/"              ref "/receptionist"
  FAQBotAgent                  route        = "/"              ref "/faq"
  ConciergeAgent               route        = "/"              ref "/concierge"

The 9th, `RestClient(*args)`, is not a defect: the reference records the Python
repr sentinel '()' for the oracle's sole var_positional param, against the port's
[]. That is a comparison-form question for the future gate to fold, not a value
error.

Additive by construction: parameter set, order, names, types, kinds, `required`
flags and return types are byte-identical to the previous artifact; only the
`default` field moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…lted cpp agent answered the wrong URL

These were invisible until cpp's enumerator started emitting real default
VALUES (3376969); while every default read as null they compared equal.

BEHAVIOURAL — five prefabs served the WRONG route. Each prefab's `route`
defaulted to "/" instead of its own reference path, so a defaulted cpp agent
answered a different URL than every other port:

  SurveyAgent        "/" -> "/survey"          prefabs/survey.py:64
  ConciergeAgent     "/" -> "/concierge"       prefabs/concierge.py:54
  FAQBotAgent        "/" -> "/faq"             prefabs/faq_bot.py:54
  ReceptionistAgent  "/" -> "/receptionist"    prefabs/receptionist.py:42
  InfoGathererAgent  "/" -> "/info_gatherer"   prefabs/info_gatherer.py:45

BEHAVIOURAL — SIP routing registered at the wrong path. Both
`register_routing_callback(path)` defaults were "/" instead of "/sip"
(core/mixins/web_mixin.py:1284, core/swml_service.py:921), so a defaulted
callback was registered where the base route already sits and a POST to /sip
404'd.

TYPE FOLD — `pay(postal_code)` was `std::string` only, excused by the omission
`cpp_postal_code_string`. It is now `std::variant<bool, std::string>` with the
reference's default `true`, matching the oracle's `union<bool,string>` exactly.
The emission mirrors the reference's `isinstance(postal_code, bool)` branch:
a bool becomes the lowercase string "true"/"false", a string passes through.

The omission entry documented behaviour the code did not have — it claimed
"the empty string acts as the sentinel" while the source read `= "true"`, so
the empty string emitted an empty postal_code rather than any sentinel. The
fold makes the entry unnecessary; it is REMOVED, not corrected.

Measured, contrary to the brief that prompted this: the reference emits
postal_code as a JSON STRING, not a boolean — `str(postal_code).lower()` at
core/function_result.py:881, verified by running the reference. cpp's wire
value was already correct; only the accepted parameter TYPE diverged.

tests/test_default_fold.cpp asserts all of this behaviourally, never by
construction. The prefab routes are proven by SERVING each default-constructed
agent and probing all six candidate paths — the expected one must answer 200
with a rendered SWML document and the other five must not, so a
stored-but-unmounted route fails. The routing-callback default is proven by a
POST to /sip producing the callback's real 307 + Location. pay(postal_code) is
asserted on the rendered wire payload, pinning `is_string()` alongside the
value so a regression to a raw JSON boolean fails.

Each change was mutation-tested: reverting a prefab route to "/" turns
survey's served-route + full-URL assertions RED (9/11); reverting both
routing-callback paths turns both /sip assertions RED (9/11); emitting a raw
JSON boolean from pay turns both bool-arm assertions RED (9/11). Restored,
11/11 pass.

port_signatures.json regenerated: the 8 fold deltas land, and postal_code now
reads `union<bool,string>` / `true`. Against porting-sdk's COMMITTED
diff_port_signatures.py, DRIFT exits 0 with the omission entry removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ead as drift for spelling "absent" differently

The unified drift checker now compares `default` and `required` on ALL params,
and C++ failed 78 of them for a reason that is not a behaviour difference:
Python spells "the caller omitted this" as `x: T | None = None` + `if x is not
None:`, and C++ — which has no nullable scalar and no keyword arguments — spells
the SAME contract as a zero-value sentinel default + an absence guard:

    python   def user_event(self, event: str | None = None)     if event is not None: p["event"] = event
    cpp      Action user_event(const std::string& event = "")   if (!event.empty()) { p["event"] = event; }

A caller who omits the argument produces the identical wire frame either way.
Recording `default: ""` against the reference's `default: null` manufactured
drift out of two spellings of "absent", so the enumerator now folds the sentinel
at the emitter — the comparison keeps running, which an allow-list entry would
have stopped.

THE FOLD IS EVIDENCE-GATED, and deliberately not "empty string always means
null". A port that defaults `prompt=""` and then SENDS `prompt: ""` ships a
different request body than a reference that omits the key. The fold requires
BOTH a type-appropriate sentinel AND a guard in the definition body that tests
it and suppresses the value's use. No guard, no fold — the sentinel is then a
value the port really ships and the finding stands.

Strings get a stricter rule than containers, measured against the oracle: `[]`
and `{}` never appear as a reference default (0 of 1,505), so a guarded empty
container is unambiguously "absent"; `""` is a REAL reference default 111 times.
The port models that distinction itself — `pom::Section` declares
`std::optional<std::string> title` (ref `str | None`) beside `std::string body`
(ref `str = ""`) — so a string folds only on a DIRECT guard in the method's own
body, never on the store-then-guard-at-serialization hop that `body` uses. Without
that split the fold turned the 5 POM `body` params into nulls, inventing a
`"" vs null` mismatch pointing the other way.

Also unions `required`/`default` across equal-arity overloads. C++ cannot repeat
a default on the typed `enum class` sibling of a flat `std::string` overload —
two equal-arity overloads both callable with fewer arguments are ambiguous — so
the typed `record_call`/`tap` forms declare `control_id`/`stereo`/`format`/
`direction`/`codec` bare, and dedup (which prefers the typed form for the
closed-set contract) reported `required: true` for seven parameters a caller can
plainly omit. Optionality is a property of the METHOD NAME, not of one overload.

Guards are read by a brace-matched text scan of src/ + the headers rather than by
re-parsing with libclang: the enumerator parses headers with
PARSE_SKIP_FUNCTION_BODIES for a 3-10x SIGNATURES speedup, and re-parsing all 67
TUs to read bodies would give that entire saving back.

DRIFT, my classes: 78 -> 18 (62 default-mismatch -> 9, 15 required-flip -> 8,
1 default-invented unchanged). 97 params folded; 93 verified param-by-param
against the reference as correct, 0 wrong. Gate-visible drift 45 -> 8 with zero
regressions. Failing set unchanged at {SURFACE, PUBLIC-JARGON}.

The 18 that remain are REAL port divergences, left for the behaviour phase:
  * RelayClient::dial declares (devices, tag, dial_timeout_ms, max_duration)
    where the reference is (devices, tag, max_duration, dial_timeout) — a
    parameter ORDER divergence, so `dial_timeout_ms = 120000` lands on the
    reference's `max_duration` slot.
  * enable_debug_events(bool enable = true) vs the reference's `level: int = 1`.
  * Section::add_subsection(std::optional<bool> numbered) makes a binary
    reference flag (`numbered: bool = False`) tri-state.
  * Step::set_gather_info's three params and add_gather_question(prompt) store
    into plain `std::string` members and are only guarded at serialization —
    indistinguishable from the real-empty-string `body` shape. Closing them means
    modelling absence in the PORT (`std::optional<std::string>`, as
    `pom::Section::title` already does), not loosening this rule.
  * load_skill / on_swml_request / replace_in_history / on_function_call take
    genuinely different or non-omittable parameters than the reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…default/required divergences, two of them real wire bugs

Phase 1 (281ca7f) folded the null<->sentinel vocabulary at the ENUMERATOR. These
18 are the cases that rule deliberately refused to fold, because folding them
without changing the port would have been a confident wrong value. Each is
closed by changing the PORT to model absence, or by following the reference's
shape.

default-mismatch (9)

* Step::set_gather_info(output_key, completion_action, prompt) and
  Step::add_gather_question(prompt) were plain `std::string = ""`, which the
  fold rule cannot distinguish from a real empty-string default (`""` is a real
  reference default 111 times). They are now `std::optional<std::string>`, as
  the reference's `str | None = None` declares. GatherInfo/GatherQuestion store
  them as optionals too. Serialization keeps the reference's TRUTHY guards
  (`if self._prompt:`), so an explicitly-empty string is still omitted.

  ContextBuilder::validate now skips completion_action only when it is ABSENT,
  matching the reference's `if action is not None:` — an explicitly-supplied
  value is validated even when empty. Error messages updated accordingly.

* AgentBase::prompt_add_subsection(bullets) was `std::vector<...> = {}` with no
  guard. Now `std::optional<std::vector<std::string>>`, applied as
  `value_or({})` — the reference's `bullets or []`. (PomBuilder::add_subsection
  already used this shape and already compared equal; it is the proven pattern.)

* pom::Section::add_subsection(numbered) made a BINARY reference flag
  tri-state. The reference's `Section.__init__` and
  `PromptObjectModel.add_section` really are `bool | None` — but
  `Section.add_subsection` declares `numbered: bool = False` and passes it
  through, so a subsection built that way is never None. The distinction is
  load-bearing in render_markdown (`subsection.numbered is not False` lets an
  unset sibling inherit numbering; an explicit false opts out), so cpp was
  inheriting numbering where the reference does not.

* RelayClient::dial(dial_timeout) defaulted to a concrete 120000. The reference
  defaults to None and substitutes 120.0 in the body. Now
  `std::optional<double> = std::nullopt` with `value_or(120.0)`.

required-flip (8) + default-invented (1)

* Call::leave_conference(conference_id) defaulted to `""` and GUARDED it out of
  the frame. The reference declares it required and always sends it — so a cpp
  caller who omitted it silently emitted a leave_conference naming no
  conference. Now required, always on the wire, with a mock-backed wire test.

* FunctionResult::replace_in_history(text) — added the reference's `= true`
  default (true = drop the tool_call+result pair entirely).

* Service/AgentBase::on_function_call(raw_data) — added `= nullptr`, the
  reference's `raw_data: dict | None = None`.

* SkillManager::load_skill was `(skill_name, params, agent)`; the reference is
  `(skill_name, skill_class=None, params=None)` on an agent-BOUND manager.
  Reshaped to match: both trailing params optional, `skill_class` spelled as a
  SkillFactory (C++'s class object) that short-circuits the registry lookup
  exactly as the reference's does, `params` normalised to `{}`. A manager with
  no bound agent now fails LOUD instead of loading into an argument.

* InfoGathererAgent::on_swml_request was `(request_data, query_params,
  headers)` — three REQUIRED params under names the reference does not have.
  Now `(request_data, callback_path, request)`, all optional, reading
  query_params/headers OFF the request object the way the reference does.

Parameter ORDER — RelayClient::dial

Not a defaults problem. cpp had `(devices, tag, dial_timeout_ms, max_duration)`;
the reference has `(devices, tag, max_duration, dial_timeout)`. A positional
third argument therefore meant the OPPOSITE thing in this port. Reordered to
the reference, and the unit follows it too: dial_timeout is SECONDS, not
milliseconds. This is a breaking change for positional callers; all 18 call
sites are updated. The max_duration wire test now documents the order it guards,
and a new test pins the seconds unit and proves the timeout never leaks onto the
wire as a max_duration.

Real wire bug — debug events

AIConfigMixin::enable_debug_events took `bool enable = true`; the reference
takes `level: int = 1`, a verbosity LEVEL a bool cannot express. Following the
type through found the emission was wrong as well: cpp emitted
`ai.debug_events = true`, a key that appears neither in the reference nor in
swml/schema.json. The reference wires the debug webhook into `ai.params` as
`debug_webhook_url` + `debug_webhook_level` (agent_base.py:1248-1261). Fixed
both, and mounted the `/debug_events` endpoint the advertised URL points at so
it resolves. Both fields are copied onto the ephemeral agent, as the reference
does.

Verification

  bash scripts/run-tests.sh          -> 2067 passed, 0 failed
  bash scripts/run-ci.sh             -> exit 1, sole failing gate PUBLIC-JARGON
                                        (pre-existing skill_registry leak from
                                        f0b5df5/#106, not touched here).
                                        SURFACE now PASSES.
  diff_port_signatures.py            -> default-mismatch 9->0, required-flip
                                        8->0, default-invented 1->0; total
                                        drift 500 -> 479.

Mutation-tested each kind (revert -> RED -> restore): the default level, the
dial parameter order, the load_skill params default, and the leave_conference
wire key each fail a specific test when reverted. Tests exercise the OMITTED
path, not just the supplied one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…d one opaque param, and two shipped the wrong wire key

Python spells "these N knobs are individually optional" as N keyword params.
C++ has no keyword arguments, so the same contract is carried by ONE object
parameter — a typed options struct (RenderOptions) or an untyped `const json&`
bag. The construction contract already unfolded the struct form; ordinary
METHODS carried the identical idiom and were excused instead, by nine
PORT_SIGNATURE_OMISSIONS entries that stopped the differ comparing those
methods at all. Measured: they suppressed 16 real findings.

_project_options_carrier folds it at the emitter, so comparison keeps running.
Two carrier forms, both evidence-gated — never on the parameter's type alone,
because the reference has genuine DOMAIN parameters whose value simply IS a
dict (execute_swml(swml), refer(device)) and unfolding one would invent params
the port never had:

  * TYPED-STRUCT — the param's type resolves to a known options struct, and
    only the reference keywords the struct DECLARES a field for are unfolded.
  * UNTYPED-BAG — the param is `const json&` AND the method body is proven to
    SPREAD it onto the outgoing frame (`json p = params.is_object() ? params
    : json::object()`), so every key the caller supplies reaches the wire and
    the reference's names are genuinely reachable through it. A body that
    reads fixed keys out of a dict is consuming a domain value and is skipped.

Three over-fires were caught by measuring rather than assuming, and each is now
a gate with its measurement recorded in the code:

  * a REQUIRED carrier is not an optional-knob bag. define_tool(ToolDefinition)
    and add_language(LanguageConfig) carry the reference's REQUIRED params too;
    an optional-only unfold consumed the carrier and destroyed that surface,
    turning define_tool(tool) into define_tool(secure).
  * __init__ belongs to build_construction, which unfolds the struct's WHOLE
    field set on purpose. Folding it here first silently dropped RelayConfig's
    port / max_connections / request_timeout_ms from RelayClient.
  * a JSON bag cannot hold a std::function. Unfolding a reference CALLABLE out
    of one would claim a callback the port does not accept — it invented
    on_completed on detect_answering_machine and transcribe. Those stay
    missing, which is the honest result.

THE REAL DIVERGENCE UNDERNEATH — two methods shipped the wrong wire key.

Retiring the omissions exposed what they were hiding. The reference remaps two
API names onto the wire key `params`: bind_digit's `bind_params`
(relay/call.py:1359) and amazon_bedrock's `ai_params` (:1502). Every OTHER knob
these two offer is spelled the same on the API and the wire, so the bag carried
them correctly — but these two cannot ride in a verbatim-spread bag at all. A
caller had no way to set them: putting the value under its API name shipped
`bind_params` / `ai_params` as the wire key, which the server does not accept
(relay_apis.c:1479 and :1982 both list `params`). Both now take an explicit
std::optional<json> parameter that does the remap exactly as the reference
does, omitted entirely when unset (reference guard: `is not None`). The bag
stays in its existing position so no current call shape changes meaning.

Four mock-relay wire tests pin the remap and the omit-when-unset behaviour;
negating the two assignments fails exactly those two and nothing else.

The seven relay.call.Call.* entries folded as ONE mechanism, as expected. Their
`dict` vs `Action` return divergence is a SEPARATE, pre-existing mechanism
already carried by eleven sibling entries, so those seven are narrowed to the
established cpp_typed_return tag rather than deleted. The two non-Call entries
(AIVerbHandler.build_config, SwmlRenderer.render_swml) are fully dead and
deleted — build_config had a typed overload with the exact reference names all
along, and render_swml's RenderOptions struct declares every one of them.

  drift    170 -> 170  (0 new findings, param-property drift stays 0)
  excused  862 -> 851

Verified: run-ci failing set is {PUBLIC-JARGON} only — unchanged from baseline
(that leak is another lane's f0b5df5). SURFACE, TYPE-EROSION, GEN, LINT, FMT
and 2074/2074 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
An AST audit of every relay module for `<wire key>` <- `<differently-named API
parameter>` found SEVEN sites in call.py, not the two that 50cdd9c fixed:

  :567  play()             media        -> "play"
  :844  play_and_collect() media        -> "play"
  :1024 pay()              input_method -> "input"
  :1260 join_conference()  stream_obj   -> "stream"
  :1359 bind_digit()       bind_params  -> "params"   (already pinned)
  :1479 ai()               ai_params    -> "params"
  :1502 amazon_bedrock()   ai_params    -> "params"   (already pinned)

All seven are confirmed against the server: relay_apis.c lists "input" for pay
(:1595, values dtmf,voice), "stream" for join_conference (:1758), "params" for
bind_digit (:1479) and amazon_bedrock (:1982).

The five newly-pinned sites are NOT unreachable in C++, and the reason is
structural: where the reference exposes a named keyword whose value it re-keys,
C++ either already names the parameter and re-keys it identically at the emitter
(play / play_and_collect), or takes an untyped options bag spread verbatim, so
the caller writes the wire key directly and it lands there unchanged (pay /
join_conference / ai). That is only safe when the wire key is a legal bag key --
which is exactly why bind_digit/amazon_bedrock needed their own parameter: their
wire key "params" collides with the bag's own name and could never be expressed.

Mutation-proved, all seven:
  - flipping "params" back to "bind_params"/"ai_params" -> exactly the 2 remap
    tests red (126/128);
  - always-emitting the key instead of guarding -> exactly the 2 omit tests red;
  - renaming play/input/stream/params on emit -> the 5 new tests red (the mock
    relay itself rejects the wrong play key: "'play' is a required property").
Restore was byte-clean each time.

Tests only; no emitter or header change, so port_signatures.json is untouched.
133/133 relay_mock tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ield, and fields-only PODs never reached the inventory

porting-sdk dcff742 resolved BasicCredentials/BearerCredentials into both oracles.
cpp compared as 4 HARD drifts against them (not 0 — that reading came from the
committed blob, where absence reads as agreement):

  BasicCredentials.{username,password}   missing-port
  BearerCredentials.{scheme,credentials} missing-port

Two independent causes, both real.

1. BearerCredentials genuinely LACKED `scheme`. The reference's FastAPI
   HTTPAuthorizationCredentials carries both halves of the Authorization header;
   cpp carried only the credential string. Adding the field changes aggregate
   init, so bearer_ok() and its test are fixed in the same commit — otherwise
   BearerCredentials{auth.substr(7)} would bind the TOKEN to `scheme`.

2. enumerate_signatures gated inventory admission on `if methods:`. Both carriers
   are fields-only PODs (two std::string members, zero methods), so they went to
   options_structs and never to entries — absent from the artifact entirely. The
   class:signalwire.core.basic_credentials.BasicCredentials string in the old
   artifact was only a param-type translation of a class that was never emitted.

Admission is now gated on the reference ORACLE recording the class, so it cannot
invent surface. Generated DTOs are excluded BY PATH and that exclusion is
load-bearing, not cosmetic: CLASS_MODULE_MAP is keyed by bare class NAME, so the
generated REST DTO ...::messages::Message and SWML verb ...::DataMap resolve to
the SAME canonical key as the hand-written relay.message.Message /
core.data_map.DataMap. Admitting them does not add a class — it MERGES their wire
fields into the hand-written classes' construction contracts (measured: +13 bogus
params on Message; DataMap LOST its real function_name).

The surface half reuses the existing oracle-gated _emit_oracle_gated_fields, the
same fold already used for the AI-chat DTOs and typed relay events.

PORT_SIGNATURE_OMISSIONS.md: the 2 verify_* entries are DELETED, not rewritten.
Both verify_* signatures now compare byte-equal to the oracle, so the entries
excused nothing — the differ reports zero findings on either symbol. No
omission/addition/allow-list entry was created anywhere.

Measured with a PINNED oracle copy (python_signatures.json md5
7cb4b078b5b7da349ae686e24d084813), fresh regen on both arms:

  hard drift   4 -> 0, none introduced
  excused   1006 -> 1008: the 2 construction-missing-class retire; 4
            construction-required-flip take their place on the same fields
            (report-only — C++ aggregate init makes every field optional by
            construction, a genuine language property, not a gap)
  surface   unexcused_missing 6 -> 0; unexcused_extra 38 -> 38 byte-identical
            (pre-existing SWMLService backlog, untouched)

Nothing outside the credential surface moves. SURFACE-DIFF passes; SURFACE-FRESH
needed these two regenerated artifacts, which are committed here by content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…30 classes, not just the 2 credential ones

porting-sdk 8828dd2 taught the surface oracle to record a SYNTHESIZED __init__,
closing a disagreement where python_signatures.json listed a constructor and
python_surface.json did not. The surface enumerator's enrichment pass required a
zero-arg member, so __init__ could never come from it; a class whose constructor
is never spelled `def` — a @DataClass, or a structural filler with no source file
— therefore lost it. griffe saw it, the AST walker could not.

That lands on this port as 30 SURFACE-DIFF missing symbols. Measured against
2c6cf6d (i.e. BEFORE any of my work) with the CURRENT oracle, to separate what I
own from what I inherited:

  2c6cf6d  : unexcused_missing 36, unexcused_extra 38
  this HEAD: unexcused_missing  0, unexcused_extra 38  (extra set byte-identical)

So all 36 resolve and none are introduced. Two of the 30 are the credential
carriers from the previous commit; the other 28 — 19 typed relay events, 3
ai_chat carriers, RequestOptions, … — were mismatched in exactly the same way for
exactly the same reason, and are fixed by the same fold rather than 30 special
cases.

The fold is oracle-gated like every other member in _emit_oracle_gated_fields: the
port claims __init__ only where the reference records one. And the claim is TRUE
of the port, not paperwork — these are C++ aggregates with no user-declared
constructor, aggregate-initialized by field name.
std::is_default_constructible is true for every one (probed:
BasicCredentials, BearerCredentials, RelayEvent, PlayEvent, RequestOptions).

SURFACE-DIFF still exits 1, on the 38 unexcused SWMLService.* EXTRAS. That set is
unchanged and pre-existing — it fails identically at 2c6cf6d — and is a separate
backlog, not something this commit or its parent touched.

Verified: DRIFT clean against the advanced oracle (python_signatures.json md5
0c11202ace94c1f84ac995af35bc69ed) — 1561 reference symbols, 1023 excused, zero
hard drift. SURFACE-FRESH FRESH. Tests 2079/2079.

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

`Call::prompt` was a one-line forwarder to `play_and_collect`, which does the
actual work and emits the wire frame (p["play"], p["collect"]). The reference
(signalwire/relay/call.py) declares prompt_tts, prompt_audio and
play_and_collect — there is no bare `prompt`. So play_and_collect is canonical
and `prompt` was port-only surface.

§3.0 does not accept "alias" as a valid addition reason, so this is deleted
rather than excused. Both PORT_ADDITIONS entries go with it, and both were
wrong about which direction the alias ran:

  * the play_and_collect entry claimed play_and_collect was "an alias for
    prompt" and that "C++ keeps prompt as the documented method" — inverted,
    and self-contradictory, since its own next sentence admits Python has
    Call.play_and_collect;
  * the Call.prompt entry filed it as "cpp_typed_accessor: const-ref accessor
    or state predicate", which it never was — it is an Action-returning verb.
    That rationale is bulk boilerplate applied verbatim across a run of
    consecutive Call entries.

The one call site (tests/test_relay.cpp) was a test of the alias itself, in a
sweep asserting each verb reaches the wire; it is retargeted at
play_and_collect so the coverage is kept rather than dropped. The wire
behaviour of play_and_collect is separately pinned by the mock-backed tests in
test_relay_mock_actions.cpp / test_relay_mock_convenience.cpp.

port_signatures.json and port_surface.json regenerated. Surface additions drop
400 -> 398; excused omissions unchanged at 39; drift stays zero.

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

Owner ruling 2026-07-28: every port declares 3.0.0, and the release floor
(port_signatures.baseline.json) is re-anchored to that wave.

Two halves, both required. Setting baseline_version alone does NOT satisfy
SEMVER-DIFF: the gate does not compare version strings, it DIFFS the current
surface against the floor's recorded `modules` payload and sets required='major'
whenever they differ (semver_diff.py:335). Neither 'none' nor 'downgrade' can
satisfy 'major' (both rank -1 at :528). So the payload is replaced too.

  CMakeLists.txt                  project(signalwire VERSION 3.2.1 -> 3.0.0)
  port_signatures.baseline.json   baseline_version 3.0.2 -> 3.0.0
                                  modules 87 -> 92 (+ construction), copied from
                                  a FRESH regen of port_signatures.json
                                  generated_from / generated_from_commit
                                  re-anchored 9817418 -> this branch's HEAD

The version declaration site is `project(<name> VERSION ...)` on line 2 — the
same site semver_diff.py documents for cpp and matches with its CMakeLists regex,
and the same site CMakeLists itself names as "the single version source", from
which cmake/version.hpp.in generates signalwire/version.hpp. The
`cmake_minimum_required(VERSION 3.16)` on line 1 is a TOOLCHAIN floor, not the
SDK version, and is untouched.

This is a PAYLOAD SWAP, not a file copy — the floor carries release-anchor
metadata (baseline_version, generated_from_commit) the current artifact does not,
and every one of those keys is preserved.

What it means semantically: the floor stops being "the surface as last
published" and becomes "the surface as of the 3.0.0 wave". That is coherent
because nothing 3.x ever shipped — cpp's published tags top out at v2.0.1 — so
the downgrade regresses no artifact. The consequence to be explicit about is
that SEMVER-DIFF will no longer flag anything already present in today's
surface; future breaking changes are still caught, now measured against the new
floor.

This sets version INTENT only. No tag, no release. The CHANGELOG's "## [3.2.1]"
heading is a historical release entry, not a declaration site, and is left as-is
(same call the typescript and php lanes made today).

Verified: semver_diff.py --port cpp exits 0 —
  [semver-diff] cpp: 3.0.0 (3.0.0) -> 3.0.0 (CMakeLists.txt)
  actual bump = 'none', required = 'none'  [ok]
No SEMVER_DIFF_ALLOW.md entry was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Nothing above v2.0.1 was ever published, so the 3.0.2/3.1.0/3.2.0/3.2.1
headings were never a release history. Collapsed into a single 3.0.0 entry,
merging by subsection (Fixed / Added / Changed / Release engineering) so every
bullet is preserved in its own category.

Owner ruling 2026-07-28. Fixes META-CONSISTENT version-vs-changelog.
Owner ruling 2026-07-28. DOCUMENTATION ONLY: no gate reads this file and nothing
fails on its presence. It records why the version looks the way it does, so the
next session does not re-derive it or casually bump one — and it carries the
delete-before-release checklist in its own body.

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

Exempted in porting-sdk root_hygiene.py 287b7f2.
…cation

The mcp_gateway skill made its gateway HTTP calls with no control over TLS
server-certificate verification, and advertised no verify_ssl parameter. The
reference's MCPGatewaySkill exposes verify_ssl with a SECURE default (true).

- get_parameter_schema now advertises verify_ssl (type boolean, default true)
  alongside gateway_url / tool_prefix / request_timeout, matching the reference.
- setup parses it with a secure default: get_param<bool>(params, "verify_ssl", true).
- WIRED, not merely declared: call_gateway() passes it to
  enable_server_certificate_verification(), so every gateway call verifies the
  server cert unless the operator explicitly opted out. SIGNALWIRE_REST_CA_FILE
  is honoured for a custom CA with verification left ON.

Tests are behavioral, not schema-only: an in-process HTTPS gateway is stood up
with the shared test cert (signed by the test CA, deliberately NOT trusted), and
the suite proves the default REJECTS it while verify_ssl=false accepts it. A
schema assertion alone would pass even if the flag were ignored.

Note on scope: this lands only the mcp_gateway source + its tests. The original
commit on the stale wave/1-aplus branch also removed a duplicate McpGatewaySkillR
stub from skill_registry.cpp; main has since deleted ALL 18 such duplicate stubs
in a broader fix, so that hunk is obsolete and carrying it would revert the
larger change.

Verified: run_tests 2082/2082, with skill_mcp_verify_ssl_default_verifies and
skill_mcp_verify_ssl_false_disables_cert_check both EXECUTING (not skipped —
they self-skip when the porting-sdk test CA is unreachable, which silently
hides them).

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

base64url_encode popped the trailing '=' padding — while its own header comment
claimed it matched Python's base64.urlsafe_b64encode, which KEEPS the padding.
The reference validates with base64.urlsafe_b64decode, which RAISES on a stripped
'=', so every token this port minted was unusable to the reference and to any port
that decodes strictly, even with a correct key and a correct HMAC. In production
that means every secure tool call fails authentication.

Our own base64url_decode kept accepting them because it re-pads before decoding.
That encoder/decoder asymmetry is exactly why round-tripping a token against
ourselves could never surface the bug, and why the new gate validates against the
REFERENCE's decoder rather than our own.

Also wires the TOKEN-INTEROP gate (property 3 of the SWAIG tool-token contract: a
token this port MINTS validates under the reference's decoder). SECURE-DEFAULT
proves a token is minted and the keying check proves the HMAC key; neither sees the
base64 ENVELOPE. tools/token_interop_mint.cpp mints one token from the fixed inputs
the checker exports and prints just that token; the binary is built alongside the
other dump binaries in the TEST gate (local, exec: and run: build modes all
updated). Per-PR rather than nightly — a security property should not wait.

This is the third port found with this exact defect (java and perl are the others),
so the gate is closing a real fleet-wide class, not a one-off.

Verified: TOKEN-INTEROP exit 0 with the fix; re-introducing just the padding strip
reproduces "base64 envelope is not decodable the way the reference decodes it /
urlsafe_b64decode raised Error('Incorrect padding')", so the gate fails for the
right reason. run_tests 2082/2082.

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

PUBLIC-JARGON flagged two shipped doc comments in skill_registry that explained the
cross-port AUDIT rather than the C++ behaviour: they cited "the surface enumerator",
"parity was being measured", and "parity can pass against dead code". None of that
means anything to someone reading the header to learn what register_skill does, and
it leaks internal porting vocabulary into published API docs.

The technical content is kept and is genuinely useful — duplicate registration is
undefined-order across translation units, so a shadowing skill could win by link
order and change between builds with no source change; register_skill therefore
THROWS. The list of what the old shadow copies were missing stays too, since it
explains why they were deleted rather than merged.

Verified: public_jargon -> "cpp: clean (scanned 1259 source file(s))"; run_tests
still builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
`DataMap::expression` emitted `nomatch_output`. The reference emits
`nomatch-output` (data_map.py:202), and behavioral_manifest.yaml:3443 records the
same `nomatch-output?`. An underscored key is one the server does not recognise,
so the no-match branch of every DataMap expression silently never fired.

datamap_expression_with_nomatch WAS VACUOUS IN THE WORST WAY — it asserted only
`contains("nomatch_output")`, which is exactly the buggy key, so it passed against
the defect and would have passed against nothing else. It now asserts the
hyphenated key, that the underscored one is ABSENT, and the VALUE that lands
there, so the divergence cannot come back green.

FLEET CONTEXT — found by sweeping all nine ports, not just this one:
  correct already:  java, go, typescript, ruby, perl
  same bug:         cpp (here), dotnet (c9e0e3c), rust (92259a8), php (8e13640)
No gate catches this class of divergence.

Verified: scripts/run-tests.sh datamap_ -> exit 0, all datamap_* OK.

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

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

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

    ratchet 122 -> 85  (the delta is measurement correction, not a surface change)

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

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

Verified: diff_port_type_erosion.py --port cpp --repo . --max 85 -> exit 0.

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