Skip to content

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

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

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

Conversation

@mjerris

@mjerris mjerris commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Retires the dead __init__ ledger entries that the shared-diff ctor/dunder fold
(porting-sdk _is_folded_dunder_member, PR #125) makes paperwork.

Per ALLOWLIST_DISCIPLINE.md:495, ctor / dunder (__init__ as a member, __repr__, __enter__) is EMISSION (exclude) — never a surface capability difference.
__init__ as a member compares params BY POSITION and ignores their names, which is
meaningless held against PHP's __construct taking a single array $options. The
meaningful comparison is the §10 construction node, keyed by NAME — and it stays
fully live and unexcused (numbers below).

Deletion-only PR. No allowlist/omission/divergence entry added; no PHP source touched.
PORT_OMISSIONS.md and PORT_ADDITIONS.md are deliberately untouched — a different tool
with a hard dead-entry gate.

Before / after counts

PORT_SIGNATURE_OMISSIONS.md (baseline = main):

$ git show main:PORT_SIGNATURE_OMISSIONS.md | grep -cE '^signalwire\.[^ ]+: '
212
$ grep -cE '^signalwire\.[^ ]+: ' PORT_SIGNATURE_OMISSIONS.md
184
$ git diff main --stat
 PORT_SIGNATURE_OMISSIONS.md | 28 ----------------------------
 1 file changed, 28 deletions(-)
metric before after
omission entries 212 184 (−28)
__init__ entries 44 16 (−28)
lines added 0 (pure deletion)

The dispatcher's measured target was 28; my measurement agrees exactly.

Excused-divergence delta

The count in the gate's summary line is unchanged at 7001 — and that is the correct
result, not a null change. The fold skips a folded dunder before the drift loop
classifies it, so a folded ctor never lands in result.excused at all. The delta shows
up against the pre-fold diff script:

# pre-fold diff script (main), pre-prune ledger  -> 7041 excused, exit 0
# post-fold diff script,       pre-prune ledger  -> 7001 excused, exit 0
# post-fold diff script,       post-prune ledger -> 7001 excused, exit 0

7041 → 7001 is the fold retiring exactly 40 dunder members from the excused pool.
Decomposed (replaying the pre-fold diff_with_surface against main's ledger and
partitioning its excused list by the fold predicate):

pre-fold excused total: 7041
of excused, folded-away by the fold: 40
   folded excused that are __init__:      39
   folded excused that are other dunders:  1  ['signalwire.rest._base.SignalWireRestError.__str__']
=> expected post-fold excused: 7001   ✓ matches the observed 7001

Note 39, not 28: the fold also stops excusing 11 __init__ symbols that had no ledger
entry
and were being excused via PORT_OMISSIONS.md/PORT_ADDITIONS.md class-level
coverage instead. Those need no ledger edit here. The prune removes the 28 now-dead ledger
lines without moving the number further, because after the fold those lines were already
excusing nothing.

For scale: the fold's predicate skips 140 symbols in total (139 __init__ whose class
the reference's construction node covers, plus the one __str__). Only 40 of those were
previously landing in excused; the other 100 were already match on both sides, which
was never counted as a divergence.

Mutual dependency, demonstrated: pre-fold script + pruned ledger is RED —
39 signature drift(s), e.g.

signalwire.relay.client.RelayClient.__init__:
  - param-count-mismatch: reference has 6 param(s), port has 1:
    reference=['project','token','jwt_token','host','contexts','max_active_calls'] port=['options']

Construction node UNCHANGED

__init__-as-a-member and the §10 construction params are different contracts. The
construction node is untouched, so nothing was traded for a blind spot:

$ python3 -c "import json;d=json.load(open('port_signatures.json'));print(len(d['construction']))"
164     # before
164     # after (and after a full re-enumerate)

Reference construction node: 139 classes. A fresh python3 scripts/enumerate_signatures.py
rewrites port_signatures.json byte-identically (mtime moves, content does not), so the
_restore_tree revert-to-HEAD trap does not apply here — the tree is clean at commit.

Gate output actually seen

$ python3 ~/src/porting-sdk/scripts/diff_port_signatures.py \
    --reference ~/src/porting-sdk/python_signatures.json \
    --port-signatures ./port_signatures.json \
    --omissions ./PORT_SIGNATURE_OMISSIONS.md \
    --surface-omissions ./PORT_OMISSIONS.md \
    --surface-additions ./PORT_ADDITIONS.md
✓ signatures match (1528 reference symbols, 8008 port symbols, 7001 excused divergences).
EXIT=0

All three surface flags, exit 0. Full bash scripts/run-ci.sh: ==> CI PASS, exit 0,
21 gates PASS / 0 FAIL — including [SURFACE]
(SIGNATURES/DRIFT/SURFACE-FRESH/SURFACE-DIFF/SEMVER-DIFF/GEN-TYPE-DEGENERACY/GEN-IDIOM),
[GEN], [FMT], [LINT] (phpstan level 9), [BEHAVIORAL], [TEST].
(Green locally because porting-sdk is checked out on wave6/ctor-dunder-fold — see the
merge-order note.)

__init__ entries the rule does NOT cover — 16 kept

The fold's guard is structural (cls_path in reference["construction"]), not a symbol
list, so a class the construction node does not cover keeps its finding. All 16 are
absent from the reference signature index entirely (ref=False), so the member
comparison is the only comparison there is for them:

  • 13 skills + DataspherePHP-default-ctor, direction missing-reference
    (port has the ctor, reference does not): ClaudeSkillsSkill, DataSphereSkill,
    DataSphereServerlessSkill, DateTimeSkill, GoogleMapsSkill, InfoGathererSkill,
    JokeSkill, MathSkill, MCPGatewaySkill, NativeVectorSearchSkill,
    SWMLTransferSkill, WebSearchSkill, WikipediaSearchSkill,
    signalwire.rest.namespaces.datasphere.Datasphere.
  • signalwire.rest.namespaces.calling.CallingNamespace and
    signalwire.rest.namespaces.fabric.FabricTokensPHP-construction.

Incidental finding (NOT fixed here, out of scope for a ctor-fold PR)

Three of those 16 are absent from both sides — ref=False port=False:
CallingNamespace, FabricTokens, and Datasphere. The module
signalwire.rest.namespaces.calling does not exist in either the oracle or
port_signatures.json:

$ python3 ~/src/porting-sdk/scripts/query_signatures.py \
    ~/src/porting-sdk/python_signatures.json node signalwire.rest.namespaces.calling CallingNamespace
not found: 'signalwire.rest.namespaces.calling'
$ python3 ~/src/porting-sdk/scripts/query_signatures.py \
    ./port_signatures.json node signalwire.rest.namespaces.calling CallingNamespace
not found: 'signalwire.rest.namespaces.calling'

These are stale entries pointing at a namespace retired by the REST-generator rollout —
dead by a different mechanism than this fold, so they are left in place rather than
swept opportunistically under this PR's rationale. Flagging for the ledger burn-down lane.


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

🤖 Generated with Claude Code

https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf

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

mjerris and others added 30 commits July 27, 2026 01:11
Delete the 4 dead `logger` omission entries from PORT_SIGNATURE_OMISSIONS.md.

Per the owner ruling of 2026-07-24 (ALLOWLIST_DISCIPLINE.md §8, implemented at
porting-sdk/scripts/enumerate_python.py:365 `_LOGGER_FACTORY_RETURN`), logging is a
MODULE-LEVEL capability a port may reach however its language does. The per-instance
`logger` attribute was Python's structlog idiom leaking into the enumerated surface;
it is not contract. The oracle no longer emits any class-attribute `logger`, so these
4 entries were paperwork suppressing a symbol that no longer exists — each was a live
blind spot in the parity checker for a member name that can never again be compared.

Removed (all 4 verified absent from the oracle before deletion):
  signalwire.agent_server.AgentServer.logger
  signalwire.core.skill_base.SkillBase.logger
  signalwire.core.skill_manager.SkillManager.logger
  signalwire.skills.registry.SkillRegistry.logger

PORT_SIGNATURE_OMISSIONS.md: 212 -> 208 entries.

The capability itself is still signalled to this port by the 5 module-level free
functions the oracle records, and PHP satisfies all 5 on
src/SignalWire/Logging/LoggingConfig.php: get_logger/configureLogging/
getExecutionMode/resetLoggingConfiguration/stripControlChars. No PHP code change is
required or made.

Also: document the publish.yml python-oracle `ref: main` pin (task #70).

Campaign task #70 filed php publish.yml:52's literal `ref: main` python-oracle
checkout as a COORDINATED_PASS violation. It is not one. COORDINATED_PASS.md states
"Publish/release workflows stay hard-pinned to `main` — a coordinated pass must never
publish from a wave branch (they are NOT part of the coordinated-test set)", and
check_coordinated_refs.py skips publish/release files via `_is_publish()` with a
selftest case asserting that publish `ref: main` must NOT be flagged. Converting the
line to PORTING_SDK_REF would let a release be gated against an unmerged wave
branch's oracle — the exact failure the hard pin prevents.

The real defect was that the adjacent porting-sdk checkout carries the explanatory
comment while this identical-intent pin carried none, which is how it got read as an
oversight. Comment added; no behavior change. Left for owner ruling: whether the
plan's task #70 should be closed as a misfile (go publish.yml and cpp release.yml
carry the same correct pin).

Verification: `bash scripts/run-ci.sh` -> 22/22 gates PASS, `==> CI PASS`, exit 0.

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

Delete the 28 `__init__` entries from PORT_SIGNATURE_OMISSIONS.md that the
shared-diff ctor/dunder fold (porting-sdk `_is_folded_dunder_member`, PR #125)
makes dead paperwork.

Per ALLOWLIST_DISCIPLINE.md:495, `ctor / dunder (__init__ as a member,
__repr__, __enter__)` is EMISSION (exclude) — never a surface capability
difference. `__init__` as a MEMBER compares params BY POSITION and ignores
their names, which is meaningless held against PHP's `__construct` taking a
single `array $options`; the meaningful comparison is the §10 `construction`
node, which is keyed by NAME and stays fully live and unexcused.

The fold's guard is structural, not a symbol list: a ctor is excluded only
while `cls_path in reference["construction"]`. So the 16 `__init__` entries
whose class the reference does NOT publish a construction entry for are NOT
covered and remain in the ledger — for those the member comparison is the only
comparison there is, and deleting them would trade a visible ledger entry for
a real blind spot.

Counts (all from commands, see PR body):
  PORT_SIGNATURE_OMISSIONS.md entries: 212 -> 184 (28 removed)
  `__init__` entries: 44 -> 16 (28 covered, 16 uncovered and kept)
  port_signatures.json `construction` classes: 164 -> 164 (UNCHANGED)

PORT_OMISSIONS.md and PORT_ADDITIONS.md are deliberately untouched — that is a
different tool with a hard dead-entry gate.

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

porting-sdk d7c859d widened the SIGNATURE oracle to record DERIVED public
__init__ attributes that are caller-observable VALUES (the 2026-07-27
ALLOWLIST_DISCIPLINE class-B2 ruling). php drifted on all 7. In every case the
VALUE already existed — it just was not readable by a caller, which is exactly
what the ruling calls a drift.

SWMLService.ssl_enabled / ssl_cert_path / ssl_key_path / domain
  The values were computed into `$this->security` (SecurityConfig) and never
  surfaced on the service. Mirrored onto Service as public typed properties at
  construction, matching the reference (core/swml_service.py:143-146).

  This also fixed a REAL functional gap it exposed: getFullUrl() hardcoded
  `http://` + host:port, so a TLS-served agent advertised an http:// webhook URL
  and ignored its configured domain entirely. It now mirrors the reference's
  _get_base_url (swml_service.py:1516-1540) — https when TLS is on, the domain
  as the host part, and the port elided for the scheme's standard port.

Action.completed
  The flag existed as a protected field read back through isDone(). The
  reference exposes BOTH `completed` and the `is_done` property
  (relay/call.py:90/102) — same flag, two reads. Widened to public; isDone()
  is unchanged and still folds to `is_done`.

SpiderSkill.remove_xpaths
  The seven expressions were a hardcoded regex list inlined in a private static
  htmlToText(). Promoted to a public PREFILLED instance field that the strip
  loop is driven off, so editing it actually changes what is stripped
  (htmlToText is now an instance method). PHP ships no lxml, so each simple
  `//tag` expression compiles to its element-stripping pattern; an expression
  the regex pipeline cannot compile is skipped rather than mis-stripping.

SignalWireRestError.request_id
  Already implemented — php computes the identical value in its ctor and reads
  it back through getRequestId(). Folded onto the reference attribute name via
  CLASS_METHOD_ALIASES; the reference records no `get_request_id` spelling, so
  the rename is clean. This is NOT reachable by the derived ORACLE_ACCESSOR_FOLD,
  which is keyed off python_surface.json — the ruling widened the SIGNATURE
  oracle only. The pre-existing PORT_ADDITIONS line moved to the folded name
  and its rationale corrected: the symbol IS reference surface, missing from
  the surface oracle only, so it is a surface-oracle blind spot rather than
  php-invented surface. No new exception was created.

Verification
  SIGNATURES/DRIFT: 7 drifts -> 0
    diff_port_signatures.py ... ; echo $? => 0
    "signatures match (1557 reference symbols, 8014 port symbols,
     6978 excused divergences)"
  SURFACE-DIFF: exit 0 (2611 symbols; 187 excused omissions, 386 excused
    additions)
  TEST: PASS (paratest -p 8, 2096 tests)
  FMT: PASS   LINT: PASS (phpstan level 9, no errors)

New regression tests: TLS defaults, TLS values mirrored from SecurityConfig via
env, and the three getFullUrl TLS/domain/standard-port paths (SWMLServiceTest);
Action.completed asserted alongside isDone() on the mock-backed resolve path
(ActionsMockTest).

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

Third lane to hit the identical stale-artifact cause (after ts and ruby). The committed
artifact carried generated_from '@ e065275' — one commit BEFORE b56a107, the
derived-attr commit. That is exactly why the signature axis read clean while the
surface axis showed 6 missing (php had 6, not 7: request_id was already folded).

A plain regen closed all 6. ZERO collection logic added — php's _emit_oracle_gated_fields
is already purely oracle-gated, so porting-sdk 387667e was the only missing input.
The enumerate_surface.py change is COMMENT-ONLY (verified: git diff -U0 filtered of
comment lines is empty); the old text asserted something now false about the ruling
having widened only the signature oracle.

PORT_ADDITIONS: deleted the SignalWireRestError.request_id entry. It carried its own
retirement condition — 'Delete when python_surface.json records it' — and the oracle
now records it, so the symbol is a MATCHED reference symbol rather than an addition.
Excused additions 386 -> 385. An entry was DELETED, none added.

Verified independently: SURFACE exit 0, 'port matches Python reference (2617 symbols;
187 excused omissions, 385 excused additions)'. SIGNATURES exit 0. Tests 2096/10151
assertions, phpstan L9 clean, format no-op. port_signatures.json byte-unchanged.
…eb_hook_url

THREE defects, all in the same emission path.

1. THE BRIEFED ONE. AgentBase::buildSwaigBlock() set web_hook_url UNCONDITIONALLY for any
   tool with a _handler. Measured from php's real render before any change: the insecure
   tool carried "web_hook_url": "http://u:p@0.0.0.0:3000/demo/swaig" — a tokenless,
   function-specific callback on the wire. The guard now mirrors agent_base.py:1085-1099:
   external URL wins -> else emit only when a token or swaigQueryParams exist -> else no
   key at all. That same line also CLOBBERED an external URL passed through defineTool's
   $extraFields; the guard checks !isset($funcDef['web_hook_url']) and fixes that too.

2. php emitted NO SWAIG.defaults BLOCK. So the guard alone would have left every insecure
   tool with no reachable callback endpoint — worse than the security defect. Added per
   agent_base.py:1109-1113. php CONFIRMED the gate cannot see this: with defaults removed
   the SECURE-DEFAULT gate still passes GREEN; only the behavioural test catches it. Same
   shape go and perl hit — three of six affected so far, and none of them would have been
   caught by the gate.

3. A VACUOUS ASSERTION in bin/secure-default-dump: it hardcoded
   secure_default_true => $expectSecure, so that field could never fail. It now reads the
   real flag back via $agent->getTools()[$name]['_secure'], and the dump is migrated to
   the `rendered` payload protocol. Proved non-vacuous: flipping defineTool's default to
   secure=false now reds the gate on that field, which the old dump could not do.

VERIFIED INDEPENDENTLY (orchestrator): diff_port_secure_default.py --port php -> EXIT 0,
"✓ PASS — php". Lane: full suite 2098 tests / 0 failures; format exit 0; phpstan level 9
exit 0 (one genuine level-9 finding in the new test fixed by narrowing the type, not
suppressed). THREE mutation tests, each restored: removing the guard reds both test and
gate; flipping the secure default reds the de-vacuified field; removing defaults reds the
test while the gate stays green.

Two existing tests RETARGETED, NOT WEAKENED: testManualProxyUrlUsedInWebhook now renders
with a call_id and additionally asserts the proxy base reaches SWAIG.defaults.web_hook_url.

Not included in this commit: src/SignalWire/Security/SessionManager.php and
bin/token-interop-mint are a concurrent TOKEN-INTEROP lane's work in the same checkout.

FLAGGED, NOT FIXED — needs a decision: python auto-generates a call_id when none is passed
(agent_base.py:958-959), so a secure tool ALWAYS mints a token; php passes null and skips
minting. On a no-call_id render php now emits no per-tool webhook where python emits a
tokenized one. The unconditional emission masked this; the guard exposes it. Does not
affect the gate (which passes an explicit call_id). go and perl reported the identical
divergence — it is fleet-wide, not php-specific.

ALSO FLAGGED: java's SecureDefaultDump.java:98,101 has the IDENTICAL vacuous
secure_default_true (hardcoded true/false). java is the port other lanes are told to copy
as the already-migrated reference, so the vacuity will propagate unless fixed.

Refs #95
…ram order

The unified drift checker (porting-sdk 90164e9) now compares `type`, `kind`,
`required` AND `default` on ALL params, not just `__init__` ones. That surfaced
34 php findings: 18 default-mismatch, 11 required-flip, 5 default-invented.
All 34 are closed here — 32 by fixing the source, 2 by folding a query-door
idiom at the enumerator.

default-invented — the reference REQUIRES the param; the port supplied a value
when the caller omitted it, so an under-specified call silently succeeded:

  * AgentBase::addInternalFiller — `$languageCode` / `$fillers` defaulted to
    null, gating an INVENTED "legacy single string argument" overload that
    appended a bare string to internalFillers. The reference has no such shape
    (ai_config_mixin.py:462 requires all three). Overload and defaults removed;
    the body now mirrors the reference's all-three-truthy guard.
  * Call::queueLeave — `$queue_name` defaulted to null, gating an invented
    "leave whatever queue the call is in" no-arg shape that put NEITHER
    queue_name NOR control_id on the wire. The server rejected it
    (`None is not of type 'string' at /queue_name`); it now fails at the call.
  * RelayClient::execute — `$params` defaulted to `[]`.
  * Service::handleRequest — `$headers` defaulted to `[]` (also on
    RequestHandlerLike).

default-mismatch — the port supplied a DIFFERENT value than the reference,
which is behaviour on the wire:

  * Call::hangup — `$reason` was null-and-omitted; the reference defaults it to
    "hangup" and ALWAYS emits it (relay/call.py:542). A no-arg hangup now puts
    `reason: "hangup"` on the wire instead of dropping the key.
  * Action::wait / Message::wait — `$timeout` defaulted to 30s, an invented cap.
    The reference default is null = wait indefinitely; both now honour null.
  * Call::playSilence — the reference takes exactly ONE keyword option,
    `on_completed`, so it is declared explicitly rather than folded into a
    generic `$opts` bag that also invented a `control_id` the reference has not.
  * Call::userEvent — took a required `array $params`; the reference is
    `user_event(*, event=None, **kwargs)`.
  * The null-vs-empty family, where the port's `[]` / `''` sentinel replaced the
    reference's null: DataMap::parameter($enum), DataMap::webhook($headers,
    $formParam, $requireArgs), FunctionResult::connect($from),
    FunctionResult::sendSms($media, $tags), AgentBase::addSkill($params),
    HttpClient::put/patch($data).

required-flip — the reference DEFAULTS the param and the port required it:

  * AgentServer::serveStaticFiles — `$urlPrefix` now defaults to "/".
  * FunctionResult::switchContext — `$systemPrompt` / `$userPrompt` are now
    optional (null). This also fixed a latent wire bug the null default exposed:
    the object branch set `system_prompt` UNCONDITIONALLY, where the reference
    emits it only when truthy (function_result.py:730).
  * Service::onFunctionCall — `$rawData` now defaults to null.

Parameter ORDER — the drift checker matches params BY POSITION, so a swapped
pair is a real contract divergence even when both params are individually
correct. Two methods were transposed against the reference and are corrected,
with every call site updated (named arguments where the closure reads better):

  * SkillManager::loadSkill — was (skillName, params, skillClass); the
    reference is (skill_name, skill_class, params).
  * Service::registerRoutingCallback — was (path, callback); the reference is
    (callback_fn, path="/sip").

ENUMERATOR (2 of the 34, no source defect): ReadResource::paginate and
CrudWithAddresses::listAddresses realize the reference's `**params` query-door
as a concrete `array $params` sitting BEFORE `$requestOptions`, so reflection
reported an extra param and read `$params = []` against the reference's
`request_options = None`. This is the identical fold the GENERATED fabric
resources already get from generate_rest.py's §5.3 sidecar — extended to the
hand-written base via VAR_KEYWORD_DROP_METHODS. A new PARAM_KIND_REMAPS table
folds the four keyword-only params PHP can only spell as trailing optional
positionals (a caller reaches them by named argument, the exact capability
Python's `*,` grants). No omission or allow-list entry was added.

Aligning the params positionally UNMASKED 8 param-mismatch findings that had
been hiding behind the offset; all 8 are closed too, via PHPDoc the source
already implied plus the matching PARAM_TYPE_REMAPS entries.

Verification:
  * drift: 34 -> 0 across default-mismatch/required-flip/default-invented;
    total port drift 310 -> 265 (45 resolved, 0 regressions).
  * tests/SignatureContractTest.php pins required-ness, default VALUES and
    param ORDER by reflection. A behavioural test that passes an argument
    explicitly does not cover that argument's default — it exercises the
    supplied value and keeps passing if the default is changed or removed — so
    the reflection pins are the backstop, with the wire consequences covered in
    the per-subject suites (a no-arg hangup asserting `reason: "hangup"` on the
    journal, a one-arg serveStaticFiles mounting at "/", switchContext with no
    arguments, DataMap::webhook's null optionals).
  * Mutation-tested one representative of each kind plus both enumerator folds:
    reverting each produced RED (wire assertion AND contract pin), restoring
    produced GREEN.
  * bash scripts/run-ci.sh -> exit 0, all gates PASS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
An entry in PORT_SIGNATURE_OMISSIONS.md makes the drift checker STOP COMPARING
that symbol — a permanent blind spot. Six php entries were bucketed as
rename/"no analog" rationales; each was read against the source. Two folded to
EQUAL, one was a real wire capability gap, and three describe genuine
divergences that now compare live instead of being hidden.

REAL DEFECT — HttpClient::post could not send a query string at all.
The omission claimed "the Python 'params' are merged into the POST data dict by
the PHP HTTP layer." The source says otherwise: post() called
request('POST', $path, [], $data, ...) — an EMPTY params array, hardcoded. The
reference (rest/_base.py:295) passes `params` through to the QUERY STRING, not
the body, so a POST carrying both a JSON body and a query was unreachable from
php. No other port omits this symbol; ts/go/ruby/java/rust all implement the
3-param form. post() now takes (path, body, params, requestOptions) matching the
reference, with reference-matching null defaults.

Call sites pass `requestOptions:` as a NAMED argument rather than positionally —
post carries the query door between $body and $requestOptions while put/patch do
not, so the transport override sits at a different index per verb. Two test
scaffolds that override post() (route_registry.php, rest_test_plan.php) were
widened for LSP.

FOLDED TO EQUAL (comparison continues):
  * filter_sensitive_headers — the reference is generic over a module-level
    TypeVar (dict[str,_V] -> dict[str,_V]). The entry called this "genuinely
    un-expressible" in php; ruby and perl already emit that exact type from
    their adapters, so it is a type-map fold, not a language ceiling. Re-
    established via FREE_FUNCTION_PARAM/RETURN_OVERRIDES — the identity-
    preserving contract the PHPDoc already documents.
  * HttpClient::post/get $params — the concrete query-map type PHP reflection
    erases to `any`, re-established via PARAM_TYPE_REMAPS.

NOW COMPARING LIVE (real gaps, itemised for a ruling — not re-excused):
  * AuthHandler::verifyBasicAuth / verifyBearerToken — the reference takes
    FastAPI HTTPBasicCredentials / HTTPAuthorizationCredentials, external
    framework types with NO definition anywhere in the oracle. 5 of 7 ports
    carry this same finding live and unexcused; only php hid it.
  * strip_control_chars — the reference is a structlog processor
    (logger, method_name, event_dict). EVERY other port carries this live and
    unexcused, including ruby and perl. php was the only port hiding it.
  * Call::waitFor — returns ?Event where the reference returns a non-optional
    RelayEvent, so php yields null on timeout where the reference raises. The
    entry claimed the port's Event "folds onto RelayEvent via CLASS_RENAME_MAP";
    no such mapping exists and none could be added — php has BOTH a legacy
    Relay\Event and the faithful Relay\Event\RelayEvent, and the whole Call
    dispatch pipeline is built on the former. ts/java/go all return the
    non-optional RelayEvent. The docblock's "parity with TS Call.waitFor" claim
    is wrong.

excused 6842 -> 6835 (7 removed, 0 added); drift 127 -> 131, the delta being
exactly the four revealed gaps above. No other finding changed in either set.

run-ci failing set: SURFACE (rule DRIFT) only — those four. ROUTE-COLLISION,
GEN-FRESH-TESTS, LINT and TEST all pass.

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

php's Call wait family returned `bool` — further from the reference than the
4 PORT_SIGNATURE_OMISSIONS entries claimed. Each said "PHP's waitForX(timeout)
returns bool […] Python returns a RelayEvent. Same lifecycle short-circuit,
different return encoding", excusing the divergence as an encoding choice. It
is not: a bool cannot carry the event, so the caller loses the payload the
reference hands back, and `false` conflates "timed out" with a real result.

Measured before, per method:

    waitForState (private) -> bool
    waitForAnswered        -> bool
    waitForRinging         -> bool
    waitForEnding          -> bool
    waitForEnded           -> bool
    waitFor                -> ?Event   (raw dispatch Event, nullable)

The reference is non-optional RelayEvent across the whole family (relay/call.py
:453/:479/:792/:796/:800), and the timeout RAISES: it awaits
`asyncio.wait_for(future, timeout=timeout)`, which throws TimeoutError and never
resolves to None — which is exactly why the annotation is non-optional. The
timeout is not a wire concept (nothing in relay-protocol/ mentions wait or
timeout); it is a purely client-side deadline on a local future, so the
reference's client contract IS the contract. ts, java, go and dotnet all return
non-optional.

Fixed by projecting at the dispatch boundary, the same shape dotnet used in
01bd09f. The port ships BOTH classes and they are NOT a rename:

  * Relay\Event is the raw dispatch envelope (Call::dispatchEvent,
    Action::handleEvent, Message::handleEvent route a frame with it) with no
    reference counterpart.
  * Relay\Event\RelayEvent is the reference's twin — same field set, same
    fromPayload factory, plus the 23 typed subclasses.

So neither class is deleted or merged. `toRelayEvent()` projects the four fields
the reference's `RelayEvent.from_payload` extracts, and the public wait surface
now speaks RelayEvent:

  * waitFor returns RelayEvent, and its predicate receives the TYPED event
    (`Callable[[RelayEvent], bool]` in the reference) rather than the raw
    envelope. On timeout it throws RelayError(408) — the client-side-timeout
    sentinel the port already uses for the dial timeout (Client.php:806), not a
    new exception hierarchy.
  * waitForState returns RelayEvent: a synthetic `calling.call.state` snapshot
    on the short-circuit (the reference constructs exactly that when it has no
    wire event in hand), otherwise delegating to waitFor with the reference's
    `call_state == target` predicate. The rank comparison governs only the
    short-circuit; the forward wait matches the target EXACTLY, as the
    reference does — the previous rank->=-based loop also resolved on a later
    state, which the reference does not.
  * waitForAnswered/Ringing/Ending/Ended return RelayEvent.

wait_for(event_type, predicate, timeout) was NOT missing here — unlike dotnet,
php already shipped it; only its return type and predicate argument type were
wrong.

BREAKING for php callers: a wait no longer yields bool/null and a timeout now
throws instead of returning false. Tests updated to assert the returned event's
identity (event type, call_state, call_id) and the raise path, replacing the
`assertTrue`/`assertFalse`/`assertNull` shape.

  drift   131 -> 130  (Call.wait_for return-mismatch removed, none added)
  excused 6835 -> 6831 (all 4 wait_for_* entries retired, none added)

Mutation-tested: removing the raise fails the 3 timeout tests; removing the
projection fails 6 with a TypeError; reverting waitForAnswered to bool fails 2.

run-ci exit 1, failing set {SURFACE} (rule DRIFT) — unchanged, the 3
pre-existing gaps in core.auth_handler (x2) and core.logging_config. 22 gates
PASS.

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

Where the reference exposes a keyword parameter under one name but puts it on
the wire under a DIFFERENT key, a port can silently send the wrong key and
every construction-level test still passes. Only a wire-level (mock-relay)
assertion on the exact emitted key catches it.

An AST audit of the tracked reference (signalwire/signalwire/relay/call.py),
resolving each dict wire key against its enclosing function's parameter list,
finds exactly seven such sites — no eighth:

    :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"
    :1479  ai()               ai_params    -> "params"
    :1502  amazon_bedrock()   ai_params    -> "params"

All seven are server-confirmed in mod_infrastructure/relay_apis.c
(pay:1595 "input"; join_conference:1758 "stream"; bind_digit:1479 and
amazon_bedrock:1982 "params").

None of the seven keys was previously pinned: the existing play /
play_and_collect tests asserted only the media entry's inner "type", pay
asserted payment_connector_url but never "input", ai asserted "prompt" but
never "params", and Relay bindDigit / amazonBedrock / joinConference had no
test at all (the joinConference hits in the suite are FunctionResult's
same-named method, a different class).

Each new test asserts BOTH that the wire key carries the right value AND that
the reference's API name did not leak onto the wire.

Every test is proven non-vacuous by mutation: the emitted key was broken at
the emitter, one site at a time, and the corresponding test was confirmed to
go red. play and play_and_collect were mutated at their own re-key site in
Call.php; the five bag-spread sites were mutated by injecting a rename into
the shared execute() / startAction() emitter. 7 of 7 mutations killed their
test; Call.php is unchanged in this commit.

Classification of php's seven sites (cpp's structural rule):
  (i)  play, playAndCollect — the port names the parameter ($media) and
       re-keys correctly at the emitter.
  (ii) pay, joinConference, ai, bindDigit, amazonBedrock — the port spreads a
       verbatim options bag, so the caller writes the wire key and it lands
       unchanged.

Notably php has NO class-(iii) collision. cpp found bind_digit and
amazon_bedrock unreachable there because the wire key "params" collided with
the options bag's own parameter name. php's bindDigit(array $params) and
amazonBedrock(array $params) look like the same shape, but the bag is spread
into the RPC frame's params PAYLOAD — one level below the frame's own
"params" — so a caller-supplied "params" key nests correctly rather than
colliding. Verified end-to-end against the real mock, not by inspection.
…ken take

The reference types those two params as FastAPI's HTTPBasicCredentials /
HTTPAuthorizationCredentials (core/auth_handler.py:98,113). Until porting-sdk
dcff742 griffe could not resolve those names into the signalwire. tree, so the
oracle emitted DANGLING class: refs and both artifacts compared php against a
phantom. dcff742 filled them in as real classes, and php — which took loose
strings — went hard-red: verify_basic_auth param-count-mismatch,
verify_bearer_token param-mismatch, and 4 missing-port members.

php now expresses the contract itself rather than folding it away: two
framework-free readonly records under Security/, BasicCredentials
{username, password} and BearerCredentials {scheme, credentials}, matching the
two two-str pydantic models in fastapi/security/http.py and the identical
carriers C++ already ships. verifyBasicAuth/verifyBearerToken take the record;
validate() builds one from the parsed Authorization header. Only the token is
matched on the bearer path (the reference reads credentials.credentials alone),
which a new test pins.

Surface side: both are pure data records, so the reference declares no explicit
__init__ and griffe records only the fields on the SURFACE — while php's
method-only surface parser sees __construct and never sees a promoted readonly
property. Pinning each class to the oracle's OWN member set reconciles both
halves in emit (AGENT_RULES §2). Driven by the oracle, not a hand list, so it
cannot go stale; a degraded read leaves the parser's set untouched. This is why
no PORT_ADDITIONS entry was needed for the phantom __init__ pair.

Measured against a pinned oracle copy (python_signatures md5
7cb4b078b5b7da349ae686e24d084813, python_surface md5
d15aced4750100a0016946ede1b85a63), as SETS:

  DRIFT     7 -> 1   (-6, 0 introduced) — the 6 credential findings, exactly
  EXCUSED   6958 -> 6956  (-2) — the two construction-missing-class excuses,
                                 now genuinely resolved rather than excused
  SURFACE   6 missing -> 0, exit 0; excused omissions/additions UNCHANGED

The 1 remaining drift is signalwire.core.logging_config.strip_control_chars
(param-count-mismatch, 3 vs 1) — pre-existing, outside the credential surface,
and NOT a php defect: those two params are structlog's processor protocol
(logger, method_name), which php has no counterpart for. typescript ships the
identical 1-param shape, so it is a fleet-wide idiom question needing its own
ruling, not something this change should paper over.

Coordinated-With: porting-sdk dcff742

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

Two independent fixes, both grounded in the reference source rather than the
ledger rationale that covered them.

1. port_surface.json regenerated for the synthesized `__init__`

   porting-sdk 8828dd2 made the surface oracle record a synthesized `__init__`
   for classes that have no explicit `def __init__`. php's committed blob
   predated it, so 5 symbols read as unexcused MISSING. Regenerating adds
   exactly those 5 and removes nothing:

     + signalwire.ai_chat.client.ChatLog.__init__
     + signalwire.ai_chat.client.ChatResponse.__init__
     + signalwire.ai_chat.client.ConversationInfo.__init__
     + signalwire.core.auth_handler.BasicCredentials.__init__
     + signalwire.core.auth_handler.BearerCredentials.__init__

   No ledger entry was added or needed; the surface diff goes 5-missing -> 0.

2. on_call / on_message invoked the handler with an extra `$event` argument

   The two PORT_SIGNATURE_OMISSIONS entries for RelayClient.on_call and
   .on_message claimed "PHP's reflection emits typed handler classes; Python
   uses canonical 'callable<list<any>,any>'". That is inverted on BOTH halves:
   `callable<list<any>,any>` is what PHP's own port_signatures.json records,
   while the oracle records the TYPED
   `callable<list<class:signalwire.relay.call.Call>,void>`. The entries also
   excused nothing — removing them leaves the drift set and the excused count
   bit-identical (6951), because `any` matches anything in compare_param. They
   are deleted as dead.

   Re-deriving from source surfaced the divergence the rationale hid: ARITY.
   The reference declares
     CallHandler    = Callable[["Call"], Coroutine[Any, Any, None]]   (client.py:74)
     MessageHandler = Callable[["Message"], Coroutine[Any, Any, None]] (client.py:75)
   and invokes `await self._on_call_handler(call)` (client.py:1102) /
   `await self._on_message_handler(message)` (client.py:1134) — exactly ONE
   argument. php passed a second `$event` at both dispatch sites. ts, java and
   ruby all pass one, so php was the sole outlier, and the extra argument is
   invented surface with no reference counterpart.

   PHP silently discards surplus arguments to a closure, which is why 24 of the
   25 call sites (`function (Call $call)`) never noticed and no gate could see
   it — the signature checker only compares the REGISTRATION signature, never
   the invocation. The one site that did depend on it was the test asserting
   the divergent shape itself.

   handleInboundCall's `Event $event` parameter became unused once the extra
   argument was dropped and is removed with it.

   Added `inboundHandlersAreInvokedWithExactlyOneArgument`, a variadic probe
   (`function (...$args)`) that captures the real argument count at dispatch —
   the only way to make arity observable in a language that tolerates surplus
   arguments.

Not addressed here: the single remaining DRIFT,
`logging_config.strip_control_chars` (3 reference params vs 1), whose two extra
params are structlog's processor protocol. php has no structlog and typescript
ships the identical 1-param shape; that is a fleet idiom question awaiting an
owner ruling, and no omission was added for it.

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

Two genuine declaration sites, kept in lockstep:
  * composer.json "version"
  * SignalWire::VERSION, which its own docblock designates the single source
    of truth for the REST/AI-Chat User-Agent strings (HttpClient and
    AIChatClient both derive their UA from the constant, so no hardcoded UA
    literal needs editing).

composer.lock's content-hash covers composer.json's version field, so
`composer update --lock --no-install` refreshed it; the diff is that one
hash line and no dependency movement. Without it `composer validate` reports
the lock as stale. The `v3.3.0` elsewhere in composer.lock is the third-party
react/promise dependency and is untouched. The CHANGELOG's "[3.3.0]" heading
is a historical release entry, not a declaration site.

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

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

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

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

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

After the payload swap:

    3.0.0 (3.0.0) -> 3.0.0 (composer.json)  actual bump = 'none', required = 'none'  [ok]
      no public surface change since last release.      exit=0

This is a PAYLOAD SWAP, not a file copy: the floor carries release-anchor
metadata the current artifact does not. `modules` + `construction` come from a
FRESH `python3 scripts/enumerate_signatures.py` (119 -> 122 modules; the regen was
byte-identical to the committed port_signatures.json, confirming that artifact was
already current); `baseline_version` is set to 3.0.0.

The anchor changes KIND here, deliberately. This floor was the fleet's only
tag-anchored one (`generated_from_tag: v3.2.0` + that tag's sha 4565478). The
3.0.0 floor is not a published tag — no v3.0.0 exists — so a symbolic tag ref
would now be a false provenance claim. It is re-anchored to this branch's HEAD
53b1b9c as a bare 40-hex commit sha, which semver_diff accepts unconditionally
(_SHA_RE, semver_diff.py:169/176) and which matches the commit-anchored
convention the rust floor already uses.

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

composer.json is untouched, so the composer `content-hash` mirror does not move;
`composer validate --no-check-publish` stays exit 0.

run-ci: real exit 0, 22 gates PASS / 0 FAIL. This is GREENER than the briefed
{SURFACE (DRIFT)} baseline, and the improvement is NOT from this commit: the
strip_control_chars drift was resolved by porting-sdk cfd676a regenerating the
oracle (verified a committed ancestor of porting-sdk HEAD, with a clean
`git status` on both oracle files — not an uncommitted artifact from a concurrent
lane). Re-running the SURFACE suite with the OLD floor restored also yields
`all 7 rules PASS`, because run-ci invokes SEMVER-DIFF with --report-only — so
this floor change is gate-neutral for php and moves only the standalone verdict.

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

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

  src/SignalWire/Security/AuthHandler.php  Bearer branch
  src/SignalWire/Security/AuthHandler.php  Basic branch
  src/SignalWire/Web/WebService.php        checkAuth
  src/SignalWire/SWML/Service.php          checkBasicAuth

Outbound emitters that BUILD a canonical-case `Basic `/`Bearer ` header
(HttpClient, AIChatClient, AgentServer's PHP_AUTH_USER reconstruction,
Service's PHP_AUTH_USER reconstruction, the skills) are unchanged —
emitting the canonical case is correct.

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

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

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

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

The published boundary here is v1.1.0, not the local v3.2.0 tag: `git ls-remote
--tags origin` returns ONLY v1.1.0. The local v3.2.0 sits on the unmerged
feat/gate-enforcement branch (4565478) and was never pushed, so 3.2.0 is an
unpublished heading like the rest. The tag itself is left alone.

That makes 3.3.0 / 3.2.0 / 3.1.0 / 3.0.2 all unreleased drafts -- consolidated
into one `## [3.0.0]` entry with NO content dropped: all eleven bullets survive,
regrouped under the existing REST / SWML-SWAIG-RELAY / Packaging sections. The
`## [1.1.0]` entry is below the published boundary and is untouched.

The date suffix is dropped from the heading because 3.0.0 has not shipped.

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

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

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

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

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

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

base64url_encode was rtrim(strtr(base64_encode($data), '+/=', '-_ '), ' '), which
strips the '=' padding. The reference keeps it (base64.urlsafe_b64encode) and its
validator RAISES on a stripped '=' (urlsafe_b64decode) — so every token this port
minted was unusable to the reference and to any port that decodes strictly, even
with a correct key and a correct HMAC. In production every secure tool call fails
authentication.

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

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

Verified: TOKEN-INTEROP exit 0; reverting to the rtrim form reproduces "urlsafe_b64decode raised Error('Incorrect padding')", so the gate fails for the right reason. phpunit 2139 tests / 10434 assertions, 0 failures.

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.

testExpressionWithNomatchOutput ASSERTED THE BUG — it read `nomatch_output` and
passed. It now asserts the hyphenated key AND that the underscored one is absent,
so the divergence cannot come back green. The sibling negative assertion in
testExpression was updated to the same key for the same reason.

FLEET CONTEXT — found by sweeping all nine ports, not just this one:
  correct already:  java, go, typescript, ruby, perl
  same bug:         php (here), dotnet (c9e0e3c), rust (92259a8), cpp
No gate catches this class of divergence — it is the same shape as this port's
own un-uppercased DataMap::webhook $method (task #115).

Verified: scripts/run-tests.sh -> 2139 tests, 10442 assertions, no failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
The reference declares `output: FunctionResult` on expression(), output(), and
fallback_output(), and its bodies call `result.to_dict()` UNCONDITIONALLY
(data_map.py:334/350/198). A string or array is an AttributeError there — a call
the reference cannot make. php declared `mixed` and shipped a `resolveOutput()`
passthrough that returned non-FunctionResult values verbatim, so the port accepted
input the reference rejects and emitted a DIFFERENT wire shape for it: the raw
value where the reference always emits the to_dict() map.

  expression($output, $nomatchOutput)  mixed -> FunctionResult / ?FunctionResult
  output($result)                      mixed -> FunctionResult
  fallbackOutput($result)              mixed -> FunctionResult
  resolveOutput()                      DELETED — it existed only to service the
                                       widened arm

expression() also stored `$output` RAW; it now stores `$output->toArray()`, so all
three emit paths agree with the reference.

Four tests existed only to assert the invented width and are deleted, not excused:
testOutputOnWebhookWithArray, testOutputOnWebhookWithString,
testFallbackOutputSetsGlobalOutput, testFallbackOutputWithString. The remaining
call sites now pass FunctionResult, and six assertions that compared against the
raw value now compare against the emitted to_dict() map — which is the actual
contract, and which the old tests were silently NOT checking.

SCOPE CORRECTION, stated plainly: this closes ZERO TYPE-EROSION slots. I came to
it from dotnet, where the same three signatures WERE flagged, and assumed php
matched. Measured both ways, php is 173 before and 173 after — `mixed` is not
read as `any` by the erosion differ, so DataMap never appears in php's list at
all. The ratchet stays at 176. This is a wire/surface parity fix; it is not a
burn-down, and the fleet erosion numbers are unchanged by it.

Verified:
  run-tests.sh DataMapTest -> 38 tests, 555 assertions, exit 0
  run-tests.sh (full)      -> 2135 tests, 10414 assertions, exit 0
  run-lint.sh (phpstan L9) -> No errors
  diff_port_type_erosion   -> 173 before, 173 after (unchanged, as stated)

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 176 -> 103  (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 php --repo . --max 103 -> exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
php's `LoggingConfig::stripControlChars(array $eventDict)` already matched the
reference's contract exactly — event map in, string values scrubbed, non-strings
passed through — and it had TWO passing tests for that contract. It also had ZERO
call sites. `Logger::log` interpolated the caller's message straight into the line
it wrote to stderr, so a NUL, a BEL, or an ESC-[ escape reached the terminal intact
and could forge log lines. The reference registers this scrub in BOTH of its
structlog processor chains (logging_config.py:205,233); here it was a method
nobody invoked.

  stripControlCharsValue   NEW public per-value scrub (the unit the emitter needs;
                           stripControlChars now delegates to it for string values)
  Logger::log              now scrubs before building the stderr line

THE CONTRACT TESTS WERE ALREADY GREEN AND STAYED GREEN through the break. php is
the sharpest demonstration of that in the fleet: LoggingConfigFreeFunctionsTest
asserted the map transform at two call sites and passed for the life of the port
while every emitted log line went out unscrubbed. Two true tests, one broken
system, nothing connecting them.

The new WIRING test drives the real Logger and reads what it actually wrote, so
removing the scrub turns it RED:

    NUL survived into the emitted line
    Failed asserting that Binary String: 0x...757365722073616964001b5b33316d524544070a
    does not contain " "

That hex is the proof: 00, 1b and 07 all reaching stderr.

OUT-OF-PROCESS BY NECESSITY: STDERR cannot be redirected from inside PHPUnit's own
process, so the test spawns a child PHP process and reads its stderr pipe — the
same mechanism testLogOutputFormat already uses for exactly this reason. Scratch
files go to a repo-local .tmp/, never a shared global temp.

Also asserts tab/newline/CR SURVIVE — a scrub that ate them would satisfy "no
control chars" while mangling every multi-line message. This test caught a mistake
in my own first assertion: I carried `usersaid` over from the sibling ports, but
the ordinary space IS legal whitespace and correctly survives. The scrub was right
and the expectation was wrong.

Verified: run-tests.sh -> exit 0, 2137 tests / 10423 assertions, 0 failures.
run-format.sh --check clean, run-lint.sh (phpstan level 9) 0 errors.

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

fe39e7d put the control-char scrub on the emission path via a NEW public
`stripControlCharsValue(string)`. That method is surface the reference does not
have, and the nightly surface gate said so:

    + signalwire.logging.logging_config.LoggingConfig.strip_control_chars_value
    modules...LoggingConfig[0]: committed='<absent>' fresh='strip_control_chars_value'
    SURFACE-DIFF / SURFACE-FRESH / DRIFT ... FAIL

These are nightly-tier gates, which is why the per-PR run-ci was green locally.

The rule is not to project or allow-list an invented symbol, it is to not invent
one. The emitter now goes through the reference's own event-map contract — one key
in, one key out — and the extra public method is DELETED. The scrub body moved back
inline into `stripControlChars`, so there is exactly one implementation and one
public entry point, matching the reference.

  stripControlCharsValue   DELETED (was port-only surface)
  stripControlChars        scrubs inline; unchanged contract
  Logger::log              stripControlChars(['event' => $message])['event']

TYPE NARROWING, NOT A CAST: the map contract is `array<mixed>` in / `array<mixed>`
out, so the value reads back as mixed and phpstan level 9 rejected interpolating it
(`encapsedStringPart.nonString`). The lint rules forbid fixing that with a cast, an
ignore, or a widened type — so the call site narrows with `is_string(...)`. A string
went in and scrubbing only ever swaps a string for a string, so the check always
takes the first branch; it discharges the proof obligation without hiding a real
mismatch.

I first tried making stripControlChars generic in its value type. phpstan was right
to reject it (1 error became 3): the method REPLACES strings with new strings, so it
is not identity-preserving and `@template TValue` was a false claim about it.

The wiring test still fails when the scrub is removed — re-verified after the
refactor, so the guard is not now vacuous:

    NUL survived into the emitted line
    Binary String: 0x...757365722073616964001b5b33316d524544070a

Verified: run-tests.sh -> exit 0, 2137 tests / 10423 assertions, 0 failures.
run-lint.sh (phpstan level 9) [OK] No errors. run-ci.sh --rules SURFACE -> CI PASS.

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

PACKAGE-SMOKE failed in the nightly:

  [PACKAGE-NIGHTLY] package suite, nightly rules (PACKAGE-SMOKE/META-CONSISTENT) ... FAIL
  "returned a file that could not be opened"

Root cause is a RACE between two gates, not a deterministic break. `composer archive`
enumerates every path git does not ignore, and `.tmp/` was in neither `.gitignore` nor
composer.json's `archive.exclude` (which lists only /.sw-tmp, /.git, /vendor). The
`runChildLogger()` helper wrote `.tmp/sw_scrub_*.php`, spawned a child PHP process, then
unlinked it. Under `paratest -p 8` those files appear and vanish continuously, and the gate
scheduler overlaps TEST with PACKAGE-NIGHTLY — the CI log shows PACKAGE-NIGHTLY at
04:23:35 while paratest ran until 04:24:05. The archiver stat'd a name already deleted.

The helper arrived in fe39e7d, whose own nightly passed — because 8 archive runs at that
commit fail only 6 times. A green run there was always possible.

Fix: scratch moves to `.sw-tmp/logger-child-<pid>-<12hex>/child.php`. `.sw-tmp/` is BOTH
gitignored and already in `archive.exclude`, so the archiver never descends into it — the
window is closed by construction, not by timing. Each call gets its own directory (8
concurrent paratest workers) removed wholesale in `finally`; mkdir failure fails loud.
This is SCOPING, not serialising — the tests stay parallel.

Two further defects in the same helper, fixed here:
  - `tempnam(...) . '.php'` created a file at the un-suffixed name and only unlinked the
    suffixed one, leaking 22 zero-byte stubs (deleted).
  - test scratch shipped INSIDE the released tarball — proven at fe39e7d, where
    `tar -tf` listed `.tmp/sw_scrub_REPRO.php`. A packaging bug independent of the race.

Verification:
  bash porting-sdk/scripts/sw-verify php --gates PACKAGE-NIGHTLY  -> exit 0
    PACKAGE-NIGHTLY PASS
    [PASS] smoke: smoke-ok: SignalWire\REST\RestClient constructed
  negative control (worktree at fe39e7d, churn under .tmp/ while archiving): 6 of 8 exit 1
  positive control (identical churn, fixed tree): 8 of 8 exit 0
  run-tests.sh LoggerTest -> exit 0 (28/28); run-lint.sh -> phpstan level 9 [OK] No errors

Note for the fleet: `.tmp/` is unignored in 8 of 10 ports (only python ignores it). No other
port has anything churning into it today, so no live race — but it is a latent trap.

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

DataMap::webhook() passed $method through verbatim, so a caller writing `webhook('get', $url)`
emitted `"method": "get"` where the reference emits `"method": "GET"`.

  reference  signalwire/core/data_map.py:230
             webhook_def: dict[str, Any] = {"url": url, "method": method.upper()}
  php        src/SignalWire/DataMap/DataMap.php  (before) 'method' => $method

MY BRIEF OVERSTATED THE SEVERITY, AND THE SOURCE CORRECTS IT. I filed this as a broken call.
It is not — the engine reads the field case-insensitively, so a lower-case method still routes:

  mod_openai/actions.c:774        if ((method && !strcasecmp(method, "post"))) {
  mod_infrastructure/swml_schema.c:1415  enumerates get,GET,put,PUT,POST,post,DELETE,delete

So this is a WIRE-PAYLOAD PARITY divergence, not a functional break: the same php and python
program produce byte-different SWML for the same input. That is still worth fixing — payload
equality is the contract the whole port matrix is measured against, and every other port that
normalises is silently inconsistent with this one — but it is not a caller-facing bug and should
not be reported as one.

No gate catches this class. SIGNATURES/DRIFT compare the shape of `webhook(string $method, ...)`,
which was always identical; only the emitted VALUE diverged, and nothing compares emitted
payloads for DataMap.

Verification:
  new test testWebhookUpperCasesMethodOnTheWire, RED before the fix
    (-'GET' +'get', Failures: 2), GREEN after (OK, 2 tests, 36 assertions)
  full suite: 2139 tests, exit 0
  run-format.sh exit 0 (no changes) · run-lint.sh (phpstan level 9) exit 0

SAME DEFECT IN THREE MORE PORTS, not fixed here (filed separately, one PR per port):
  rust    src/datamap/datamap.rs:155
  cpp     src/datamap/datamap.cpp:64
  dotnet  src/SignalWire/DataMap/DataMap.cs:153

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Part of the fleet-wide false-ledger sweep. Both gates PASS before and after; PORT_OMISSIONS.md
goes from 130 of 187 entries suppressing NOTHING to zero.

THREE CLAIMS IN MY OWN BRIEF DID NOT SURVIVE SOURCE:
  - "~37 entries" — PORT_SIGNATURE_OMISSIONS.md has 168 and PORT_OMISSIONS.md has 187. Real
    scope was ~10x the brief.
  - "SWMLBuilder.add_section/reset are the known carriers; php may have them" — php has NEITHER.
    SWMLBuilder has no add_section on either side, and ContextBuilder.reset is present in both
    reference and port. The go/java/ts carrier does not exist here.
  - THE PROBE METHOD I MANDATED HAS A SYSTEMATIC BLIND SPOT. Leave-one-out cannot see
    INTERACTION effects. StandaloneCollectAction.stop measured as no-op in isolation because the
    CLASS-LEVEL entry StandaloneCollectAction was covering it (the differ treats a class-level
    surface entry as covering all its methods). Deleting both together red'd DRIFT. Caught by
    re-running the gate, restored with corrected wording. ANY LANE USING THIS METHOD ON A LEDGER
    WITH CLASS-LEVEL ENTRIES NEEDS A GATE RE-RUN, NOT JUST THE PROBE.

HOW MANY SUPPRESS NOTHING
  PORT_SIGNATURE_OMISSIONS  68 of 168  ->  13 of 113
  PORT_OMISSIONS           130 of 187  ->   0 of  58

The 13 survivors are exactly the entries reworded below: real divergences visible in the
enumerated data, inert ONLY because the comparator's global loose-type tolerance
(types_compatible accepts `any` on either side, diff_port_signatures.py:179) absorbs them. That
tolerance is the documented removable transition scaffold — when it is tightened, these 13 fire.
Deleting them would have been the go mistake.

DELETED — 55 signature entries, cause verified per group:
  16 GHOST SYMBOLS — the named class/member exists in NEITHER oracle nor port. AgentServer.app is
     not in the reference at all; WebService.app is an instance attribute (web_service.py:113),
     never enumerated, so the rationale's "Python's @Property app" is false. The 14
     rest.namespaces.{calling,datasphere,fabric}.* entries name classes that were renamed away —
     namespaces/calling.py is now a bare deprecation shim re-exporting Calling from
     calling_resources_generated, with ZERO class definitions.
  11 EXACT DUPLICATES of a PORT_ADDITIONS/PORT_OMISSIONS entry (9x ParameterSchema.* duplicating
     PORT_ADDITIONS.md:161-169; SWMLService.{on_swml_request,validate_basic_auth} duplicating
     :197,202; AgentServer.agents, SkillManager.loaded_skills, SWMLService.{security,
     verb_registry} duplicating PORT_OMISSIONS.md:249-252).
  13 skill __init__ — auto-excused by the comparator's own _is_port_state_accessor rule
     (diff_port_signatures.py:1424), not by any entry.
  11 REST entries whose signatures compare BYTE-IDENTICAL. The rationale "PHP returns the generic
     CrudResource where Python returns a per-resource subclass" is FACTUALLY FALSE:
     REST/Namespaces/Generated/ResourceTree.php:157 is `public function calling(): Calling` — the
     concrete generated class, same as the reference. Likewise paginate/list_addresses: the
     enumerator already folds `array $params` -> kwargs, so the "PHP has no **kwargs" excuse is
     dead weight.

DELETED — 130 surface entries, ALL class (b) fixed-but-not-delisted. Every one is present in BOTH
python_surface.json and port_surface.json, spot-verified at php source:
  BedrockAgent (Agents/BedrockAgent.php:22, with setVoice:177 / setInferenceParams:188 /
    setLlmModel:214) against an entry claiming "PHP ships AgentBase + SWML only; Bedrock
    integration is deprioritized".
  SkillRegistry (Skills/SkillRegistry.php, with discoverSkills:67 / getSkillClass:86 /
    listAllSkillSources:107 / addSkillDirectory:210 / getAllSkillsSchema:246) against "exposes a
    narrower public surface (register/get/list)".
  PromptManager / ToolRegistry are emitter PROJECTIONS (enumerate_surface.py:541) — folded at the
    emitter per §2, so those entries were doubly dead.

REWORDED, NOT DELETED — 27 (c) cases:
  13 signature entries. FOUR rationales were outright false at source:
     onSummary(array|string|null $summary, ?array $rawData = null) (AgentBase.php:1697) takes TWO
       positional params, not "a single (callback) handler";
     registerRoutingCallback(callable $callback, ...) (SWML/Service.php:369) takes a real
       callable, not "string-typed callback names";
     loadSkill(string $skillName, ?string $skillClass = null, ?array $params = null)
       (SkillManager.php:40) MATCHES reference param order, contradicting "param order differs";
     playSilence(int|float $duration, ?callable $onCompleted = null) (Call.php:876) is not
       "(duration, array $opts)".
     Message::wait(int|float|null $timeout) (Message.php:179) does accept floats, contradicting
       "typed as int (seconds)".
     Each now names the divergence that actually remains (param name, param type, or return type).
  10 *Action.stop — rationale claimed "no direct PHP analog"; stop() exists at Relay/Action.php:240
     on the base and every subclass inherits it. The reference declares it once on StoppableAction
     and griffe re-attributes it to each subclass; php's enumerator records only textually-declared
     members. Enumerator artifact, capability present.
  4 prefab on_summary — the entry claimed PHP LACKS it; it is the INVERSE. Both sides implement it
     (prefabs/*.py declares def on_summary; php has it in port_surface). The reference SIGNATURE
     oracle omits it, so php's declaration reads as missing-reference.

Deleted outright, no tombstones — a tombstone keeps the symbol name and the stale claim greppable.

FOR AN OWNER — A REFERENCE-ORACLE GAP, NOT A PHP GAP. griffe does not enumerate
inherited/overridden methods onto subclasses: it DROPS prefab on_summary (4 classes) from
python_signatures.json while python_surface.json DOES record it. The two reference oracles
disagree with each other. These were excused per-symbol as `reference-oracle gap`, but the correct
fix is upstream in the enumerator and it WILL RECUR ON EVERY PORT. The same root cause makes the
reference attribute StoppableAction.stop to 11 subclasses while php declares it once on the base —
the two enumerators disagree on inheritance in OPPOSITE directions.

Verification:
  BASELINE: SURFACE PASS · LEDGER PASS · NO-LAUNDER (impossible: 49 · idiom: 135)
  AFTER:    SURFACE PASS · LEDGER PASS · NO-LAUNDER (impossible: 51 · approved: 0 · idiom: 4 ·
            unclassified: 0 · banned: 0)
  The DRIFT red described above is the load-bearing evidence, not the green.
  run-format.sh: 0 of 1387 files fixable · run-lint.sh (phpstan level 9): [OK] No errors.
  git status: only the two ledger files. NO php SDK source touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Document the 170 undocumented public symbols the DOC-SURFACE gate counted,
taking php from 85.4% (994/1164) to 100.0% (1164/1164), and re-pin
.doc_surface_floor from 84.3 to 100.0 so the coverage cannot regress.

Docs only — no code, signature, or behaviour change. Each docblock was written
against the symbol's body, so the surface nine porting teams read as spec now
states the wire shape and the security semantics rather than restating names:

- FunctionResult (26): the emitted action key where it differs from the method
  name (playback_bg, stop_playback_bg, functions_on_speaker_timeout,
  user_input), and the value SHAPE each action carries — hold's bare clamped
  int, wait_for_user's scalar precedence, switchContext's bare-string vs object
  branch, connect's "transfer" string sibling, the empty-OBJECT {} values.
- ContextBuilder (24): which keys toArray emits and when (booleans only when
  true), the mutually-exclusive plain-text vs POM-section prompt forms, step
  ordering/moveStep splice semantics, and the reset object.
- AgentBase (18): the sections.main verb ordering pre-answer -> answer ->
  record_call -> post-answer -> ai -> post-ai; setWebHookUrl overriding the
  shared SWAIG.defaults.web_hook_url every tokenless tool falls back to;
  clearSwaigQueryParams changing which tools get a per-tool URL;
  cloneForRequest's deliberate deep-copy vs by-reference-callback split.
- Skills (60), Prefabs (5), REST/Relay/SWML/Security/Logging/POM/DataMap: the
  per-skill required params and clamped bounds, each skill's actual instance
  key, SessionManager's stateless HMAC token layout, PaginatedIterator's
  single-pass rewind, Logger's control-char scrub on the emission path.

Also refresh the stale "84.3% today" floor note in scripts/run-ci.sh.

Verified: run-format.sh --check (0 of 1387 fixable), run-lint.sh (phpstan
level 9, no errors), run-tests.sh (2139 tests, 10459 assertions) — the test
result is byte-identical to the pre-change baseline measured on a stashed tree,
including its 2 pre-existing PHPUnit deprecations and 2 skips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
getAllSkillsSchema() built each entry with `new $className()`, but every
skill extends SkillBase, whose constructor requires an AgentInterface:

    public function __construct(AgentInterface $agent, array $params = [])

So the construction ALWAYS threw ArgumentCountError, and an empty
`catch (\Throwable $e) {}` swallowed it. `description` and `version` were
therefore never populated for any skill, ever — the method's own docblock
promised metadata it could not deliver. Measured before this change:

    18 skills | with description: 0 | with version: 0
    sample: {"name":"api_ninjas_trivia","parameters":[]}

Read the metadata off the CLASS instead of a live skill. The reference
(signalwire-python skills/registry.py:274) reads class attributes
(SKILL_DESCRIPTION, SKILL_VERSION, ...) and calls get_parameter_schema()
off the class — it never constructs a skill to describe one. PHP holds the
same metadata as instance methods returning constants (none of the 18
built-ins' metadata methods read $this), so ReflectionClass::
newInstanceWithoutConstructor() gives a receiver for those constant
readers with no agent binding. Making them static instead would have
rewritten the whole 18-skill surface and diverged from the oracle.

Also fills out the reference's full eight-field contract, not just the two
keys php previously declared: name, description, version,
supports_multiple_instances, required_packages, required_env_vars,
parameters, source. `source` distinguishes 'built-in' from 'registered'
as the reference does. required_packages reaches the protected
getRequiredPackages() reflectively rather than widening that contract.

The empty catch is gone. A malformed skill is still skipped so one bad
entry can't abort the scan (the reference logs and skips too), but it is
now LOGGED — a silent catch is what turned a hard error into an invisible
one for the life of this method.

Tests assert POPULATED content, which is what was missing: a
construction-only test passes against a completely dead method. Adds
tests/Support/SchemaProbeSkill.php, a registered (non-built-in) skill
declaring non-default metadata so an entry that silently fell back to the
SkillBase defaults is visibly wrong rather than accidentally correct.

After:
    18 skills | with description: 18 | with version: 18
    all 8 reference fields present on all 18 skills

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
PUBLIC-JARGON was RED on this branch and I caused it. 54b6803 (the DOC-SURFACE burn to 100%)
wrote the word "parity" into two PUBLIC phpDoc comments:
    src/SignalWire/POM/PromptObjectModel.php:26  "@param bool $debug parity flag mirroring…"
    src/SignalWire/REST/CrudResource.php:71      "…exposed under both names for reference parity."

"Parity" is porting-programme vocabulary. It means nothing to an SDK user reading the API
docs — it leaks how the SDK is BUILT into what it IS. That is exactly what the gate exists
to stop, and #106 already burned this fleet-wide once.

Reworded, not suppressed:
    "@param bool $debug flag mirroring the reference's constructor"
    "…exposed under both names to match the reference."
Both still say the true thing; neither uses the banned term.

FOUND BY THE php SkillRegistry FIX LANE, which ran the gate, saw the red, and PROVED it
pre-existed its own work by stashing and re-running at clean 54b6803 — identical two leaks.
It correctly refused to absorb the red into its own change and reported it instead.

Verified: SW_CI_ONLY=PUBLIC-JARGON run-ci.sh -> PASS, CI PASS. Doc-comment text only; no
code, signature or behaviour touched, so no test or surface impact.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
mjerris and others added 30 commits July 30, 2026 05:36
…aRows()

Every SDK REST method returns the decoded JSON body as array<string,mixed> —
the honest type, since the server decides the shape at runtime. So $room['id']
is mixed, and interpolating it into a string, passing it to a `string $id`
parameter, or foreach-ing a `$resp['data']` are all genuine level-9 errors:
a response that does not match the happy path really would print an empty
string or raise a TypeError at the call.

Adds three helpers next to the safe() wrapper each example already defines, and
rewrites the repeating idioms onto them:

    field($row, 'id', $default)   read a string field, stringifying numbers
    rows($value)                   narrow a mixed to a foreach/count-safe list
    dataRows($response)            the 'data' collection of a list response

These do REAL runtime narrowing with a sensible fallback — the example-side
counterpart of tests/Support/Shape.php, which solved the same problem for the
test suite by narrowing rather than suppressing. Nothing is excluded and no
ignore comments are added.

    $roomId = $room['id'] ?? 'demo-room-id';
    -> $roomId = field($room, 'id', 'demo-room-id');

    foreach (array_slice($rooms['data'] ?? [], 0, 5) as $r) {
        echo "  - {$r['id']}: " . ($r['name'] ?? 'unnamed') . "\n";
    -> foreach (array_slice(dataRows($rooms), 0, 5) as $r) {
        echo '  - ' . field($r, 'id') . ': ' . field($r, 'name', 'unnamed') . "\n";

Interpolations become concatenations because PHP cannot interpolate a named
function call inside "{...}".

safe() also loosens to `@param callable(): mixed` — several call sites pass a
closure that prints and returns void — and narrows its own result with
is_array(), so its declared `?array` return is actually proven.

477 -> 255 findings. Full suite: Tests: 2184, Assertions: 10666, OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
bin/swaig-test parses SWML documents and SWAIG tool definitions off the wire,
where every field is `mixed` — 50 level-9 findings in 782 lines of shipped CLI
that had never been analysed (phpstan walks a directory for *.php only, so the
extensionless bin/ scripts were invisible until this rollout listed them).

Adds jsonStr/jsonStrAny/jsonArr/jsonArrAt narrowing helpers and routes both the
HTTP and in-process tool listings through them, so a malformed document prints a
placeholder instead of raising a TypeError mid-render.

Three specific fixes beyond the mechanical narrowing:

  - json_encode() can return false. The SWAIG request body was passed straight
    to httpPost(string $body); an unencodable payload would have been sent as
    the empty string. Now it reports the encode error and exits 1.

  - The 'last resort' service discovery constructed every candidate class in a
    try/catch to find one that takes no arguments, which also swallows a genuine
    error thrown from a valid constructor's BODY. It now asks reflection whether
    the constructor requires parameters, and only then constructs.

  - Two dead `isset($http_response_header) && is_array(...)` guards removed:
    PHP sets that local whenever the stream wrapper ran, which the preceding
    non-false file_get_contents already proves.

Verified both discovery paths still work:
  swaig-test --file examples/simple_agent.php --list-tools
    -> Found 2 SWAIG function(s), with parameter rows and required markers
  swaig-test --file examples/dynamic_swml_service.php --list-tools
    -> exercises the subclass path the reflection change touches
DOC-CLI: 3 documented invocations, parse-ok=3, clean.

One per-line @PHPStan-Ignore added, with its rationale on the line and the
reasoning above it: `new $cls()` where PHPStan resolves $cls to the Service
BASE (constructor requires $name) but the value is a SUBCLASS declaring its own
no-arg constructor — examples/dynamic_swml_service.php's DynamicGreetingService
is exactly that. The reflection check immediately above proves the call safe;
PHPStan cannot follow it through a class-string.

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

bin/envelope-dump built a RequestOptions (timeout / retries / retry_backoff) per
corpus case and passed it to HttpClient::post() as the THIRD argument:

    $http->post($path, $call['body'] ?? [], $reqOpts);

post() is (path, body, params, requestOptions) — the third slot is
`?array $params`. Passing a RequestOptions object there raised a TypeError
before any HTTP request left the process. (get() is unaffected: its third
parameter really is requestOptions.)

The two cases this silently destroyed are the two whose entire purpose is POST
retry semantics. Their artifact entries were:

    envelope_post_500_not_retried  {"error_kind": "bare:TypeError", "request_count": 0}
    envelope_post_503_retried      {"error_kind": "bare:TypeError", "request_count": 0}

request_count 0 — the idempotency-asymmetry cases were asserting nothing at all.
After the fix they report what their names claim:

    envelope_post_500_not_retried  {"error_kind": "typed", "status_code": 500,
                                    "body_error_code": "SERVER_ERROR", "request_count": 1}
    envelope_post_503_retried      {"raised": false, "request_count": 2}

This artifact feeds the cross-port ERROR-ENVELOPE gate
(porting-sdk/scripts/diff_port_envelope.py), so php was contributing a
TypeError where the other ports contribute real retry counts.

    python3 scripts/suites/behavioral.py --port php --rules ERROR-ENVELOPE
    [error-envelope] php: clean.  ... PASS

Also burns the rest of bin/ to zero (2113 LOC of shipped CLI that phpstan had
never seen, because it walks a directory for *.php and these are extensionless):

  - envelope-dump: corpus() and ec_case() given precise array shapes, so the
    loop reads typed fields; request_options values narrowed with is_numeric()
    rather than blind casts.
  - wait-liveness-dump: corpus() shape typed; main()'s $realStdout stream
    parameter annotated; a dead `$kase['kwargs'] ?? []` removed.
  - ai-chat-mock-router: $method is decoded JSON, and was used as an array KEY
    one line before its is_string() check. Narrowed at the source instead.
    REQUEST_URI narrowed before parse_url().
  - secure-default-dump: $doc['sections']['main'] and $section['ai']['SWAIG']
    ['functions'] chained off mixed; narrowed hop by hop.
  - pagination-dump / secret-scrub-dump: three dead defensive checks removed
    (is_array on already-typed arrays; `PHP_BINARY ?: 'php'`, where PHP_BINARY
    is always the interpreter path under the CLI SAPI).

bin/ phpstan level 9: [OK] No errors.
Dump-consuming gates: ERROR-ENVELOPE, PAGINATION-WIRED, WAIT-LIVENESS,
SECRET-SCRUB, SECURE-DEFAULT — all 5 PASS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Second narrowing pass over the REST examples, same approach as the first:
real runtime checks with a fallback, no suppressions.

    fieldAny($row, 'e164', 'number', 'unknown')   the same value under either name
    firstRow($response)                           the first element of a list response

    ($num['e164'] ?? $num['number'] ?? 'unknown')
    -> fieldAny($num, 'e164', 'number', 'unknown')

    ($available['data'] ?? [null])[0] ?? []
    -> firstRow($available)

175 -> 134 findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
    $numId = $number ? ($number['id'] ?? null) : null;
    -> $numId = field($number, 'id');

field() returns '' when the field is absent or not a scalar, so the truthiness
tests that follow ("if ($numId) {") behave identically — '' is falsy exactly
where null was — and the id is a real string at every SDK call that takes one.

134 -> 102 findings. Full suite: Tests: 2184, Assertions: 10666, OK.

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

Two shipped examples called SDK APIs with the wrong keys/arguments. Both were
invisible because the wrong value degrades silently instead of throwing.

1. examples/prefab_info_gatherer.php declared its questions as

       ['question_text' => 'What is your full name?', 'field' => 'full_name']

   InfoGathererAgent's contract is `key_name` (InfoGathererAgent.php:43), and
   its own validateQuestions() throws "missing 'key_name' field" for a question
   without one (:156). Nothing threw here because that validator runs ONLY on
   the dynamic-callback path (:205), never for a static question set.

   The defect landed at answer-recording time instead: :303 reads
   `$currentQuestion['key_name'] ?? null` and falls back to '' when absent, so
   EVERY answer was stored under an empty key. Three questions in, global_data
   held three answers all keyed '' — the collected registration data was
   unusable, and the post-prompt asking for {full_name, email, phone} could
   never be satisfied.

2. examples/lambda_agent.php called

       $agent->handleServerlessRequest($_SERVER, file_get_contents('php://input'));

   The signature is (?array $event, ?object $context, ?string $mode). $event is
   the API GATEWAY payload — httpMethod / path / headers / body /
   isBase64Encoded — not $_SERVER; $context is Lambda's context OBJECT, not a
   request body string. So Adapter::handleLambda() found no httpMethod, no path
   and no body, and every invocation degraded to "GET /" with the webhook
   payload never read.

   The adapter decodes the real event from the runtime itself when passed
   nulls (Adapter.php:254-260), so the example now calls it with no arguments.

New tests/ExamplePrefabQuestionsTest.php pins the question-set contract two
ways: the keys the example ships, and the SDK's own validator via the dynamic
callback path.

RED before (with 'field' restored):
  1) question 1 must use 'key_name' — Failed asserting that an array has the
     key 'key_name'.
  2) Failed asserting that null is of type array.   <- the SDK's validator
     rejecting the question set outright
GREEN after: Tests: 2, Assertions: 22, OK

prefab_info_gatherer.php also gains the CLI-entrypoint guard the other examples
use, so its question set can be loaded in-process without starting a server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
102 -> 78 findings. Mostly per-site narrowing, plus three findings that were
real code smells rather than analyser noise:

  - scripts/emit_error_envelope.php and bin/envelope-dump BOTH declared a
    global function named corpus(). phpstan analyses them in one run, so it
    resolved emit_error_envelope's calls against envelope-dump's array shape
    and reported offsets that 'might not exist' on the wrong type. They never
    run in the same process today, but two global functions with one name is a
    latent hazard; emit_error_envelope's is now emitCorpus().

  - rest_phone_number_management: `is_array($profile) ? 'OK' : $profile` —
    sipProfile()->get() returns array<string,mixed>, so the else branch was
    unreachable and would have printed an array if it ever ran.

  - kubernetes_ready_agent: a closure captured $agent and never used it.

Also: the datasphere env demos' requireEnv() now actually returns a string
(it declared `: string` while returning `$_ENV[...] ?? getenv(...)`, i.e.
mixed), and their optional settings go through an optionalEnv() helper instead
of casting mixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
examples/SkillsAuditHarness.php built its agent with a Python-style kwargs dict:

    $agent = new AgentBase([
        'name' => 'skills-audit',
        'route' => '/audit',
    ]);

against `AgentBase::__construct(string $name, string $route = '/', …)`. PHP
raises an uncatchable TypeError there, so the harness died on line 110 before
loading a single skill — for every skill, every invocation:

    PHP Fatal error: Uncaught TypeError:
    SignalWire\Agent\AgentBase::__construct(): Argument #1 ($name) must be of
    type string, array given, called in examples/SkillsAuditHarness.php on line 110

porting-sdk's audit_skills_dispatch.py drives this file to exercise the six
network skills end-to-end, so that audit could never have produced a real
result for php — it would have seen a fatal for every skill it asked about.

Fixed to named arguments. The harness now runs through to the skill's real HTTP
call:

    SKILL_NAME=web_search … php examples/SkillsAuditHarness.php
    {"response":"Sorry, I encountered an error while searching: HTTP GET
     http://127.0.0.1:9/customsearch/v1?… failed (curl errno=7): …"}

(connection refused against a deliberately closed port — the point is that the
skill loaded, registered its tools, dispatched, and issued the request.)

New tests/ExampleSkillsHarnessTest.php runs the harness as a subprocess against
a closed port and asserts it reaches that HTTP call rather than dying on
construction.

RED before: 'the harness died constructing AgentBase with a kwargs array' —
            asserting stderr did NOT contain 'must be of type string, array
            given'; got the fatal verbatim.
GREEN after: Tests: 1, Assertions: 5, OK

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
phpstan level 9 over the whole repo is now [OK] No errors — 575 -> 0.

The remaining example findings were per-site narrowing of the same mixed reads.
Three were dead code rather than analyser noise: getNextMember() and mfa()->
verify() both return array<string,mixed>, so `is_array($x) ? 'found' : $x` had
an unreachable else that would have echoed an array if it ever ran.

Two enumerators fixed for the fail-successfully defect class — both PRODUCE a
gate input, so a swallowed error there makes a gate pass on a smaller surface:

  - scripts/enumerate_signatures.py collected translation failures, printed them
    to stderr, then wrote port_signatures.json and exited 0 anyway. Fail-loud
    lived behind an undocumented `--strict` that NO gate passes
    (_signatures_fresh.py:163, _surface_commands.py:448 both invoke it bare).
    port_signatures.json is exactly what DRIFT compares against, so a failure
    silently SHRANK the compared surface and DRIFT went green against a file
    missing whatever failed to translate. Now fatal by default, with
    --allow-translation-failures as the explicit local override.

    Verified both ways: clean tree exits 0 (122 modules, 7994 methods); with a
    synthetic failure injected it prints "refusing to write a TRUNCATED oracle"
    and exits 1; the override still writes and exits 0.

  - scripts/enumerate_surface.py silently `continue`d past an unreadable
    generated REST source. That function injects the inherited create/update
    onto each generated subclass, so skipping a file would drop those members
    from port_surface.json and SURFACE-DIFF would report them as omissions the
    PORT is missing — blaming the source for a read error. Now raises.

Two more duplicate global corpus() declarations de-collided (bin/envelope-dump
-> envelopeCorpus, bin/wait-liveness-dump -> waitLivenessCorpus). phpstan
analyses every file in one run, so three same-named global functions meant it
was type-checking each against another file's array shape.

port_surface.json moves only its generated_from provenance hash — no surface
change. Full suite: Tests: 2187, Assertions: 10693, OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
This is a PHP SDK, but it ships 8 hand-written Python programs under scripts/
(8586 lines) that NOTHING linted or format-checked. phpstan.neon lists
`scripts` in its paths, but phpstan only reads *.php — so the Python was
silently uncovered while looking covered.

That code is load-bearing: enumerate_surface.py and enumerate_signatures.py
PRODUCE the port_surface.json / port_signatures.json that the SURFACE and DRIFT
gates compare the port against, and the five generate_*.py scripts emit the
committed generated trees GEN-FRESH byte-checks.

ruff.toml mirrors the reference implementation's rule selection verbatim
(signalwire-python/pyproject.toml [tool.ruff.lint]) rather than inventing a set:
E4/E7/E9/F/B/S/C4/PERF/SIM/PTH/RET/RUF/UP, with format preview pinned false.

54 findings -> 0. One was a real defect:

  - enumerate_surface.py declared the SAME dict key twice (F601):
    ("signalwire.core.mixins.auth_mixin", "AuthMixin") at :520 and :583. The
    second silently overwrote the first. They happen to list the same two
    methods in a different order today, so the emitted surface is unaffected —
    verified byte-identical after the dedup — but a divergence between the two
    would have been invisible.

The rest were dead assignments, unused loop variables, %-format, and
comprehension rewrites. Every generator still reproduces its tree byte-for-byte
(--check clean on all five) and both enumerators emit identical JSON, verified
after the autofix pass AND again after ruff format.

Gate wiring (REPO-LINT / REPO-FMT), following the python lane's ordering: burn
to zero BEFORE wiring, so the gate never lands red. Format contract matches the
PHP FMT gate — LOCAL applies, CI runs --check.

ruff is declared in BOTH layers per AGENT_RULES §7: requirements-dev.txt (new)
and the CI workflow's install step. scripts/_env.sh gains sw_ruff(), which fails
LOUD with the install hint when ruff is absent — a quality gate that no-ops on a
missing tool is worse than no gate.

11 findings remain suppressed via per-file-ignores, each naming specific rules
for a specific file with its rationale in ruff.toml: S603/S607 on the four
scripts that shell out to php/git/sys.executable with fixed list-form argv under
shell=False (same rationale and same per-file scoping the reference grants
signalwire/cli/dokku.py), E402 on enumerate_signatures.py where sys.path.insert
must precede the sibling import, and RUF002/RUF003 on generate_rest_tests.py
where `×` is the cross-product operator being named, not a mistyped x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The ROOT-HYGIENE gate carries a built-in list of standard tool configs it
excuses at the repo root — pyproject.toml, requirements.txt,
requirements-dev.txt, pytest.ini, .coveragerc for python, and every other
language's linter config (.golangci.yml, .rubocop.yml, clippy.toml,
rustfmt.toml, .perlcriticrc, .clang-tidy, eslint.config.mjs, phpstan.neon,
.php-cs-fixer.php). ruff.toml is the same class and is simply absent from it
(porting-sdk/scripts/root_hygiene.py:56-58).

ruff discovers its config by walking UP from the linted files to the nearest
ruff.toml / .ruff.toml / pyproject.toml, so it has to sit at the repo root to
apply to scripts/. The alternative that satisfies the gate today — adding a
pyproject.toml — would falsely declare this PHP repo a Python package.

The real fix is one entry in porting-sdk's list, which would retire this
allowlist line; reported as a cross-repo finding, since a port lane cannot edit
porting-sdk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The REPO-LINT/REPO-FMT ruff install step ran `pip install -r
requirements-dev.txt` with no working-directory, so it executed at the
workspace root where porting-sdk/ and signalwire-php/ are siblings. The
file is repo-relative, so every matrix leg (8.2/8.3/8.4) died with
"Could not open requirements file: requirements-dev.txt" before any gate
ran. Every other repo-relative step in this workflow already sets
working-directory: signalwire-php; this one was missed when the step was
added.

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

EXAMPLES-RUN was the gate that never reported in CI: all 29 other gates PASSED,
then the job sat idle ~16 minutes until the runner killed it ("Terminated / The
operation was canceled", orphan bash reaped). Identical in all three matrix legs
(8.2/8.3/8.4), so deterministic, not runner variance.

DEFECT 1 — `firstRow()` CALLED ITSELF, in ELEVEN shipped examples:

    /** The first row of a list response, or an empty array. */
    function firstRow(mixed $response): mixed
    {
        return firstRow($response);      // <- unconditional self-call
    }

Unbounded recursion. Locally it exhausts PHP's 128M limit in ~2 minutes and
crashes; on the CI runner it grinds until the job is killed. Same defect, two
symptoms — which is why the CI log showed a hang and the local run showed a
fatal.

Fixed to what the docblock and the file's own helpers already say it means:
`dataRows($response)[0] ?? []`. dataRows() is defined directly above it in every
one of the eleven files, so this delegates rather than reimplements. Each file
was checked for that definition before rewriting.

Six of the eleven crashed outright; the other five never reached the call on
their happy path, so the bug was latent there — equally real, just unobserved.

DEFECT 2 — rest_datasphere_search.php calls `field()` five times (lines 39, 40,
47, 51, 71) but never DEFINES it, so it fatals on the first call. Every sibling
example defines the helper locally; this one shipped without it. Added the
identical definition from rest_video_rooms.php rather than writing a new one.

Verified by running the gate, not by reading the code:
  before   59 ran, 6 CRASHED
  after 1  59 ran, 1 CRASHED   (the recursion fix cleared five)
  after 2  see below — the datasphere fix clears the last one
  FMT      0 of 1473 files need fixing
  LINT     phpstan level 9, no errors

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

`Action.__construct`'s `$call` was `?Call $call = null`, and the property was
`protected ?Call $call = null`. The reference declares it REQUIRED and
non-optional (relay/call.py:75-82 — `def __init__(self, call: Call, ...)`),
which the signature gate reported as 12 `construction-required-flip` findings,
one per Action subclass (Action, AIAction, CollectAction, DetectAction,
FaxAction, PayAction, PlayAction, RecordAction, StandaloneCollectAction,
StreamAction, TapAction, TranscribeAction).

The optionality was never a language limit or a product decision. The docblock
said so outright: "Null only for an Action constructed outside a Call (test
fakes), never on the production path" — i.e. the contract was loosened so unit
tests could skip an argument. There is exactly ONE production construction site
(`Call::startAction`, Call.php:1197-1227) and it always passes `$this`, so
requiring the argument costs production nothing and removes a null the
`getCall()` caller had to defend against.

  * `Action`, `StandaloneCollectAction`: `?Call $call = null` -> `Call $call`.
  * `FaxAction`: same, and `$call` now PRECEDES `$faxType` — PHP forbids a
    required parameter after an optional one and `$faxType` carries a default.
    The one production caller is updated to match. Construction parity is
    compared BY NAME, not position, so the reorder is invisible to the gate and
    is purely a language constraint.
  * property `protected ?Call $call = null` -> `protected Call $call`;
    `getCall(): ?Call` -> `getCall(): Call`.

Tests that constructed a bare Action now build the owning Call, which is what
the production path does. `FaxActionConstructorTest` gains a local `makeCall()`
for the same reason.

Measured with `--omissions` on both sides: excused 6945 -> 6933, exactly the 12
`construction-required-flip` entries removed, ZERO excused entries added, and
DRIFT stays at 0 -> 0. Nothing was re-excused elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
`handleServerlessRequest(mode: 'azure_function')` — the spelling the Python
reference documents and dispatches on — THREW a \ValueError. Same for
`'google_cloud_function'`. A caller who followed the reference got an
exception instead of a response, and no gate caught it, because no test ever
passed a reference-spelled mode.

The cause: `ExecutionMode` invented a shorter, PHP-only dialect (`gcf`,
`azure`) for the two cloud-function modes. The reference's
`get_execution_mode()` returns `google_cloud_function` / `azure_function`, and
`handle_serverless_request` switches on exactly those strings
(core/mixins/serverless_mixin.py:240,247).

Parity wins, so PHP folds to the reference tokens:

    ExecutionMode::Gcf   = 'gcf'   -> GoogleCloudFunction = 'google_cloud_function'
    ExecutionMode::Azure = 'azure' -> AzureFunction       = 'azure_function'

BREAKING: `'gcf'` and `'azure'` are no longer accepted by
`handleServerlessRequest()`, `Adapter::serve()`, or `ExecutionMode::coerce()`,
and `Adapter::detect()` now returns the long tokens. They are NOT aliased —
keeping both spellings would leave PHP with mode surface the reference does
not have.

The mode vocabulary is now identical to `LoggingConfig::getExecutionMode()`'s,
which already mirrored the reference; the two sets were previously documented
as deliberately-separate dialects, which was the bug wearing a rationale.
PHP's own CHECKLIST.md §9.1 already prescribed the long tokens.

Behaviour verified, not asserted: `google_cloud_function` and `azure_function`
now dispatch (GCF echoes a 559-byte SWML body; Azure returns a
`{status:200,...}` envelope), and the sibling `lambda` / `cgi` modes dispatch
exactly as before.

Tests: ServerlessTest pins the enum's five backing strings against the
reference set; AgentServerlessRequestTest gains a data-driven regression case
asserting every reference-spelled mode RESOLVES, plus one asserting the
retired PHP-only spellings are rejected rather than aliased.

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

The `secure=true` enforcement suite claimed to cover "every serverless
envelope (lambda / azure / gcf / cgi)" but only ever drove ONE of them:
lambda. The other three were unproven.

That matters because the four envelopes reach `handleRequest()` by four
DIFFERENT routes to the credential — lambda parses `queryStringParameters`,
azure_function parses the `url` query, and google_cloud_function / cgi read
`$_SERVER` + `php://input`. A change to any single route (a renamed mode
token, a reshaped path, a dropped query string) can un-wire `__token`
enforcement on that transport ALONE while the other three stay green. Proving
lambda proved nothing about the rest.

Adds absent / forged / valid coverage on azure_function,
google_cloud_function, and cgi — 9 new cases, so all four transports x three
token states are now pinned (19 tests -> 28).

The GCF and CGI transports needed `tests/Support/InputStreamStub.php` to be
drivable at all: both read the request body with
`file_get_contents('php://input')`, which under the CLI SAPI is ALWAYS EMPTY
(it is not wired to stdin — verified directly). Without the stub those two
transports could only ever reach the empty-body path (400 "Missing request
body"), never a real SWAIG POST — which is precisely why they had no coverage.
The stub re-registers the `php` scheme so `php://input` serves a fixture body
and every other `php://` stream delegates to the real wrapper.

Negative-controlled: flipping the tool's `secure` flag to false makes 8 of the
12 transport x state cells fail, so the cases can detect a broken contract
rather than passing vacuously.

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

Five RELAY convenience methods were carrying `PHP-idiom-options-collapse`
signature omissions. That tag is self-refuting under AGENT_RULES §2: an
options-object standing in for the reference's keyword-only params is IDIOM,
and idiom is reconciled at the enumerator so port and reference compare EQUAL
— never documented as an omission.

Adds `RELAY_OPTS_UNFOLD` to scripts/enumerate_signatures.py: a per-method
table that unfolds the trailing `array $opts` back into the reference's
keyword params, the same shape the REST §5 sidecar already applies to the
generated resources. Covers play_tts, play_audio, detect_fax, detect_digit,
and detect_answering_machine.

Each entry was derived from the PHP body, not the oracle — the explicit
`isset($opts[...])` reads plus the shared carryPlayOpts() / carryDetectOpts()
helpers — so the fold asserts what the code actually accepts rather than
copying the answer from the thing it is checked against. `control_id` is
deliberately NOT spliced: the carry helpers accept it but the reference does
not declare it, and emitting it would be invented surface.

The unfold is fail-loud. A stale entry — a method that has vanished, or a
trailing param that is no longer the expected bag — ABORTS the enumeration
(exit 1) instead of being skipped, because a silently-skipped unfold would
reintroduce exactly the drift this fold closes.

Measured as SETS, --omissions on both sides:

    excused  6872 -> 6867  (-5, exactly the five entries deleted; 0 added)
    drift       0 ->    0  (empty both sides)

The five are removed from PORT_SIGNATURE_OMISSIONS.md (113 -> 108 entries),
and the surface is no longer excused-blind on them: member param comparison
went from 1194 to 1217 params compared at 100% coverage, so 23 parameters that
the omission was hiding are now genuinely diffed against the reference — and
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The PUBLIC-JARGON gate caught internal porting vocabulary in a shipped public
doc-comment. Rewrites the paragraph to say what an SDK user needs — the
execution-mode vocabulary is shared with LoggingConfig::getExecutionMode(), and
the old short spellings 'gcf'/'azure' are no longer accepted — instead of
narrating the porting rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
13 RELAY members were carried in PORT_ADDITIONS.md as "PHP idiomatic getter /
accessor". That rationale was false on both counts: none of them is a getter,
and the reference does not merely spell them differently — it keeps the
IDENTICAL machinery PRIVATE:

  Action::handleEvent        <- Action._check_event        (call.py:93)
  Action::resolve            <- Action._resolve            (call.py:99)
  Action::executeSubcommand  <- Call._execute              (call.py, via
                                StoppableAction.stop / start_input_timers)
  CollectAction::handleEvent <- CollectAction._check_event (call.py:293)
  CollectAction::setStopMethod <- the private `_command_prefix` CLASS ATTRIBUTE
  Call::dispatchEvent        <- Call._dispatch_event       (call.py:491)
  Client::handleEvent        <- RelayClient._handle_event  (client.py:995)
  Client::handleMessage      <- dispatched inline in the private run loop
  Client::send               <- RelayClient._safe_send / ._send_request
  Client::sendAck            <- RelayClient._send_event_ack (client.py:1252)
  Message::dispatchEvent     <- Message._dispatch_event    (message.py:103)
  Message::handleEvent       <- (alias of the same)
  Message::resolve           <- Message._resolve           (message.py:129)

These form ONE dispatch chain — Client::handleMessage -> Client::handleEvent ->
Call::dispatchEvent -> Action::handleEvent -> Action::resolve — that crosses
class boundaries. PHP has no package-private visibility, so the chain MUST be
declared `public`. That is a genuine language limitation, so per the idiom rule
it is folded at the ENUMERATOR, not excused in a ledger. None of the 13 is
reachable from examples/ or docs/ (checked); they are not exported API.

The fold needed per-method `@internal` support, which NEITHER enumerator had:

  scripts/signature_dump.php honoured `@internal` on CLASSES only. Now also
    skips an `@internal` method and an `@internal` constructor.
  scripts/enumerate_surface.py tracked `internal_pending` from docblocks but
    consumed it only at interface declarations. The public-method branch now
    consumes it too, and RE_ANY_MEMBER clears a pending tag at any non-public
    member so an `@internal` on a `private function` cannot leak onto the next
    public method. The method branch deliberately does NOT `continue` — the
    brace-tracking below must still run for that line, or an `@internal` method
    whose `{` sits on the declaration line desyncs brace depth and silently
    drops every later method in the file.

Both enumerators now agree on per-method projection, so a member cannot be
public on one parity axis and absent from the other.

Verified — SETS, not totals, with --omissions on both sides:
  DRIFT (the real gate invocation, _surface_commands._drift_argv, i.e. WITH
  --surface-omissions and --surface-additions):
    exit 0 both before and after; excused 6857 -> 6844
    OPENED = 0, CLOSED = 13 (exactly the 13 above). Excused did not rise to
    absorb anything.
  Same measurement in the omissions-only shape: drift 139 -> 126,
    OPENED = 0, CLOSED = 13, excused 6718 -> 6718 (FLAT).
  SURFACE-DIFF named the same 13 as DEAD additions; all 13 lines deleted from
    PORT_ADDITIONS.md (373 -> 360 anchored entries). Re-run: exit 0.
  scripts/run-tests.sh Relay: 434/434, 2056 assertions, exit 0.
  scripts/run-format.sh: 0 of 1474 files needed fixing.

NOTE for the campaign: the 139-drift figure in the brief was a MEASUREMENT
ARTIFACT, not port state. It came from invoking diff_port_signatures.py with
--omissions alone; the gate that actually runs also passes --surface-omissions
PORT_OMISSIONS.md and --surface-additions PORT_ADDITIONS.md, under which php
measured drift 0 / excused 6867 (exit 0) before this commit. php was never
signature-drifting. The real finding was the 70 self-refuting "idiomatic /
getter / builder / alias" rationales inside those excused entries, of which
this commit burns 13.

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

SECURITY (#90, the silently-plain-HTTP shape). An operator who set
SWML_SSL_ENABLED=true but supplied no usable cert/key got a WORKING PLAIN-HTTP
LISTENER — no error, no non-zero exit, and in two of the three paths a startup
line that claimed `https://` while the socket carried cleartext. They asked for
encryption, served plaintext, and were never told.

THREE independent serving paths were affected, each with its own spelling of
"fold not-configured into TLS-off", and each had to be fixed:

  AgentServer::serve()  resolveSslPaths() returned [null, null] on a missing or
                        unreadable cert/key and control fell straight through to
                        `passthru('php -S ...')` — a plaintext listener. Only a
                        logger->warn() marked the downgrade.
  WebService::start()   getServerTlsOptions() returns [] for BOTH "TLS off" and
                        "TLS on but invalid", so $useSsl went false. Worse, the
                        computed $scheme was logged — the startup line read
                        `https://` while `php -S` served cleartext.
  SWML\Service::serve() never consulted the TLS config AT ALL: it always spawned
                        the plaintext `php -S`, while getFullUrl() advertised
                        `https://` to every caller that asked for the URL.

The refusal happens BEFORE any listener is bound, so a misconfigured service
never accepts a single plaintext byte. All three delegate to the one shared
SecurityConfig::assertTlsUsableOrRefuse() so they cannot drift apart; the error
names the offending key and both remedies (supply a readable cert+key, or set
SWML_SSL_ENABLED=false to serve HTTP deliberately). validateSslConfig() already
computed the correct verdict and message — nothing consumed it as a refusal.

PARITY NOTE — THE REFERENCE HAS THE SAME BUG. signalwire-python's
agent_server.py::_run_server (lines 676-683) does exactly this: on a missing
cert it logs `SSL cert not found`, sets `ssl_enabled = False`, and calls
uvicorn.run() WITHOUT ssl_certfile/ssl_keyfile. php was faithfully porting a
reference defect. This commit deliberately diverges toward the safe behaviour,
matching the precedent set by go 31fe57b ("refuse to serve rather than silently
downgrade TLS to cleartext"). The reference needs the same fix; that is outside
this port's tree and is called out for the owner.

BEHAVIOURALLY PROVEN AT THE WIRE, not grepped — the observable question is what
came out of the socket:

  RED before, at the socket. Driving the real AgentServer::serve() with
  SWML_SSL_ENABLED=true and a missing cert, then sending a plaintext GET:
      plaintext GET -> opening bytes:
         H T T P / 1 . 1   2 0 0   O K \r \n
      https:// client -> curl: (35) SSL_connect: SSL_ERROR_SYSCALL
  Byte-identical to the TLS-OFF control — i.e. a fully working cleartext server.
  The process table showed the smoking gun directly:
      php -S 127.0.0.1:0 ...   (spawned with SWML_SSL_ENABLED=true)

  GREEN after, same probe, same env:
      RESULT: nothing is listening on 18821  -> REFUSED (clean)
      child log: PHP Fatal error: Uncaught RuntimeException: Refusing to serve:
        TLS is enabled but not usable — SSL certificate file not found: ...

  NEGATIVE CONTROL (does the test fail for the reason claimed?): neutering the
  guard to `if (true) return;` reproduces the original failure exactly — the
  suite hangs and `php -S 127.0.0.1:0` reappears in the process table. Restored.

  SCOPE CONTROL (can the guard pass by refusing everything?): with TLS OFF the
  same probe still answers `HTTP/1.1 200 OK` — plain HTTP is unchanged and still
  works. Three *_PlainHttpStillWorks tests assert this per path, and
  testFullyConfiguredTlsPassesTheGuard proves a complete cert+key pair passes
  the guard AND yields real TLS options, so the guard rejects misconfiguration
  specifically, not TLS in general.

The rest of php's TLS surface was audited in the same pass and is CLEAN, with
behavioural evidence rather than a constant grep:
  * Certificate verification is ON by default and reachable. Pointing the real
    REST HttpClient at a self-signed listener that answers a valid 200 fails
    with `SSL certificate OpenSSL verify result: self-signed certificate (18)`
    — it refuses rather than connecting.
  * SIGNALWIRE_REST_CA_FILE is load-bearing: setting it to that same cert makes
    the IDENTICAL request succeed. So the refusal above is real chain
    verification, not a blanket failure. SIGNALWIRE_RELAY_CA_FILE is wired the
    same way through the WSS stream context (verify_peer + verify_peer_name are
    unconditionally true; the CA var only chooses WHICH root to trust).
  * HttpHelper::request() defaults $verifySsl=true (VERIFYPEER on, VERIFYHOST
    strict); false is an explicit caller opt-out.
  * AgentServer's `'verify_peer' => false` is the SERVER side of the Workerman
    SSL worker — "do not demand a client certificate". Not a client-side
    verification bypass, not a finding.

Verified:
  scripts/run-tests.sh TlsNoSilentDowngrade: 11/11, 25 assertions, exit 0
  scripts/run-lint.sh (phpstan level 9): [OK] No errors
  scripts/run-format.sh: 0 of 1475 files needed fixing
  audit_no_cheat_tests.py --root ./tests: clean
  DRIFT exit 0 — "signatures match (1630 reference symbols, 8006 port symbols,
    6854 excused divergences)"; SURFACE-DIFF exit 0. Excused UNCHANGED: the new
    guard is @internal, so it lands in port_surface_native.json (the php-native
    inventory) only and NOT in the reference-projected port_surface.json — no
    PORT_ADDITIONS entry is implicated.

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

Closes php's SURFACE-DIFF red (11 phantom missing-reference additions) by
matching the reference's actual design, not by excusing it.

The reference moved first. signalwire-python e9aa402 made
SkillBase.get_prompt_sections() a FINAL TEMPLATE METHOD that applies the
skip_prompt guard and delegates to a PROTECTED _get_prompt_sections() hook
(core/skill_base.py:88-97). All 13 built-in skills there now override the
protected hook, so the PUBLIC member exists on the base only. Oracle regen
0e0f935 dropped it from 11 skill classes, and php — which still declared a
PUBLIC getPromptSections() on every skill — began emitting public surface the
reference does not expose.

php had the same design pressure and had answered it the worse way: each of the
12 skills with a real section list re-implemented the guard

    if (!empty($this->params['skip_prompt'])) { return []; }

as its own first two statements — twelve copies of one rule, each of which a
future skill could simply forget, and one skill (NativeVectorSearch) that
already had. Nothing enforced it.

So the fold is the reference's: SkillBase::getPromptSections() is now `final`
and owns the guard; the hook it delegates to, _getPromptSections(), is
`protected`. All 13 built-ins override the protected hook and their duplicated
guards are deleted. Behavior is unchanged and the guard is now unforgettable —
`final` makes bypassing it a compile error rather than an oversight.

Both parity axes key on `public`, so the 11 phantom additions disappear at the
emitter with no ledger entry. SkillBase.get_prompt_sections stays exported (the
reference records it there); only the subclass copies go.

DEAD ENTRIES DELETED (required, not optional) — PORT_ADDITIONS.md:
  signalwire.skills.info_gatherer.skill.InfoGathererSkill.get_prompt_sections
  signalwire.skills.claude_skills.skill.ClaudeSkillsSkill.get_prompt_sections
Both excused the same now-folded idiom, and both rationales were self-refuting
under ALLOWLIST_DISCIPLINE §0 ("PHP idiomatic accessor", "Idiomatic explicit
override"). SURFACE-DIFF flagged them as DEAD the moment the fold landed.

Measured, --omissions + --surface-omissions + --surface-additions:
  surface  drift   11 -> 0   (11 additions CLOSED, 0 opened)
  signature drift   0 -> 0
  excused (sig)  6864 -> 6851   FELL by 13; absorbed nothing
  PORT_ADDITIONS  441 -> 439 lines  (2 DELETIONS; zero insertions)
  PORT_OMISSIONS / PORT_SIGNATURE_OMISSIONS  unchanged

TESTS: the guard is now asserted, not assumed. A data-provider arm constructs
each of the 11 flip-testable skills twice and asserts a NON-empty section list
without skip_prompt and [] with it — the non-empty half is what keeps the
assertion from being a vacuous empty==empty, and it caught two providers
(ClaudeSkills, McpGateway) whose sections are populated in setup() rather than
the constructor; those are covered by the reflection arm instead. A second test
asserts the shape itself over all 13: the template method is final and public,
the hook is protected, and no built-in redeclares the template method.

  run-tests.sh          2224/2224 pass, 10813 assertions
  run-lint.sh           phpstan level 9 — No errors
  run-format.sh         0 of 1475 files need fixing
  SURFACE suite         SURFACE-DIFF PASS (was FAIL), DRIFT PASS
  BEHAVIORAL            SKILL-CONTRACT / EMISSION / BEHAVIORAL-SWML PASS
  DOC-TRUTH             all 8 rules PASS

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

Two independent enumerator defects, both found by the preceding commit making
the first `final public` method in the tree.

1. `final public` was invisible to the surface parser.

RE_PUBLIC_METHOD and _RE_NATIVE_METHOD both matched an optional `abstract` but
not `final`, so `final public function x()` matched NEITHER. `final` is a
SEALING modifier, not a visibility one — a final public method is exactly as
public as any other, and a template method that seals its own guard
(SkillBase::getPromptSections) is the canonical case.

The consequence was a SILENT FALSE DELETION: a genuinely-exported member simply
stopped being enumerated. Only luck kept it off the surface axis's bottom line —
the oracle-gated field fold (_emit_oracle_gated_fields) re-added
SkillBase.get_prompt_sections downstream because the reference records it on the
same class and php's own signature oracle confirms it. The native-name sidecar
has no such backstop and lost the name outright, which is how this surfaced:
port_surface_native.json dropped `getPromptSections`, and a doc naming php's
real `->getPromptSections()` would then have failed to resolve in DOC-AUDIT.

The signature axis was never affected — signature_dump.php is reflection-based
and sees modifiers correctly. This was regex-parser-only, and it is exactly the
"enumerator parser bug" class AGENT_RULES §5 warns about: suspect the tokenizer,
not the code.

_RE_NATIVE_PROPERTY gets the same treatment (`final public readonly Type $x`).

2. Class-level `@internal` was honoured on one parity axis only.

signature_dump.php skips ANY type whose docblock carries `@internal` — it is
reflection over every kind, class and enum included. enumerate_surface.py
honoured the marker on `interface` declarations but hardcoded
`cur_excluded = False` on `class`/`enum`, so the two axes disagreed about what
`@internal` means on a type.

Latent rather than harmless: it went unnoticed only because all three
`@internal` markers in the tree today happen to be interfaces. The first
`@internal` class or enum anyone wrote would have been dropped from
port_signatures.json and KEPT in port_surface.json — one type visible on one
parity axis and not the other, surfacing as a SURFACE-DIFF addition with no
signature counterpart to explain it. Now both branches read `internal_pending`.

Non-vacuity, both arms, both defects:
  - `final`: reverting only the RE_PUBLIC_METHOD change drops
    SkillBase.get_prompt_sections from the native sidecar; restoring it brings
    the name back. Verified by sha256 on port_surface_native.json.
  - class `@internal`: marking Datetime `@internal` makes BOTH axes drop
    DateTimeSkill (surface False / signature False). Reverting just the
    one-line class-branch change makes the surface axis keep it (True) while the
    signature axis still drops it — the exact divergence. Fix restored, control
    file restored from a pre-image copy, artifacts regenerated to sha-identical.

Fix 2 is a NO-OP on today's tree, proven by sha256: port_surface.json and
port_surface_native.json are byte-identical before and after. It changes only
what happens to the next `@internal` class someone writes.

  run-lint.sh    phpstan level 9 — No errors
  run-format.sh  0 of 1475 files need fixing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Mechanical regen of every artifact the two preceding commits move, so
SURFACE-FRESH and SIGNATURES-FRESH compare the committed blobs against a fresh
regen and agree.

  port_surface.json         13 skill classes lose get_prompt_sections
                            (the folded subclass overrides) + provenance sha.
                            SkillBase keeps it — the reference records it there.
  port_signatures.json      the same 13, on the signature axis.
  port_surface_native.json  UNCHANGED, byte-identical to HEAD. It briefly lost
                            `getPromptSections` to the `final public` regex gap;
                            the enumerator fix repaired that in the same breath,
                            so the net diff is empty rather than provenance-only.

RELAY: NO diff, and that is the correct outcome — not a missed regen.

porting-sdk be7a34f added two NET-NEW schemas, calling.conference.{params,result}
.json, after mod_infrastructure 9755ef7 registered a second "conference"
protocol method (relay.c:18915). New server surface, not drift.

Both are permissive placeholders: `type: object`, `additionalProperties: true`,
`x-permissive: true`, and NO `properties` key. php's generator already filters
exactly this shape — build_outputs() skips any node failing
`GR.is_object_schema()`, which requires non-empty `properties`, on the same
object-vs-alias split the REST and SWML wire-type emitters use. A permissive
placeholder would emit a bare `dict[str,Any]` alias that the reference
enumerator drops anyway, so emitting it would ADD surface the oracle does not
record.

php therefore stays at the oracle's 123 generated RELAY files, and
GEN-FRESH-RELAY passes. (This corrects the expectation that every port gains two
open type aliases here — php's generator has had the permissive filter all
along. Ports whose generators glob `*.{params,result}.json` without one will
gain two; php does not.)

Verification — all three generators re-run at this committed tree:
  python3 scripts/enumerate_signatures.py
  python3 scripts/enumerate_surface.py
  python3 scripts/generate_relay_protocol.py

`git status --short` then shows exactly ONE line, port_surface.json, and its
whole diff is the `generated_from` sha: the artifact records the PARENT commit
(b8c48cb), because the regen that produced it necessarily ran before this commit
existed. That is inherent and unavoidable — an artifact cannot contain the sha of the commit that contains it
— and it is precisely the field check_surface_freshness.py strips before
comparing. Both freshness gates confirm it mechanically rather than by eye:

  check_surface_freshness: FRESH — committed_surface.json matches a regen
                                   (modulo provenance)
  check_surface_freshness: FRESH — committed_signatures.json matches a regen
                                   (modulo provenance)

port_signatures.json and the 123 generated RELAY files regenerate byte-identical
with no provenance field at all, so they show no diff whatsoever.

  SURFACE suite   SIGNATURES/DRIFT/SURFACE-FRESH/SURFACE-DIFF/
                  GEN-TYPE-DEGENERACY/GEN-IDIOM/SEMVER-DIFF — all PASS
  SIGNATURES-FRESH  fresh
  GEN suite       all 5 GEN-FRESH* PASS
  DOC-TRUTH       all 8 rules PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
GEN-FRESH-SWAIG was red on 6 generated files. Two spec changes landed in
porting-sdk without any port being regenerated:

  * 4336b98 (2026-08-01) re-vendored post-prompt.yaml. It moved
    PostPromptSystemLogEntry's `context` / `step` / `step_index` from top-level
    properties into `metadata.properties`, citing tl_stamp_location
    (timeline.c) as where the server actually stamps them — so those three
    top-level accessors were never on the wire at that level, and they are
    dropped. The same re-vendor typed two PostPromptSwaigLogEntry fields off
    their call sites: `mcp_response` is the MCP tool's raw result text
    (actions.c:2158, "Not parsed JSON") so array -> string, and `mcp_error` is
    a boolean const true present only when the tool returned no result
    (actions.c:2162) so string -> bool. Both committed types were wrong.

  * 99fd429 (2026-08-03) re-vendored swaig-response.yaml at mod_openai cac4984,
    which replaced the untyped `{}` property stubs with real types read off
    process_action's call sites: context_switch system_prompt/user_prompt ->
    string, hold timeout -> number|string, playback_bg file -> string,
    transfer dest -> string. It also emits each action object's property keys
    alphabetically, hence ContextSwitchAction's field order.

Net effect on the surface is a tightening plus the three dropped accessors;
DRIFT stays clean against the Python oracle. port_signatures.json and
port_surface_native.json are regenerated to match.

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

The stale-report header was written as an f-string ending in `\\n`, so it
emitted a LITERAL backslash-n instead of a newline. The first stale path was
therefore glued onto the header line, and anything parsing the output as
`  - `-prefixed lines UNDER-REPORTED THE STALE COUNT BY ONE.

That is the failure shape that makes a count-based gate pass when it should
fail: a gate comparing a parsed count against an exact bound silently accepts
one missed file. Same family as a percentage floor that rounds a single miss
away.

Measured on a planted 3-stale state in the SWAIG tree, before the fix:

    GEN-FRESH FAIL: 3 generated SWAIG-payload file(s) stale:\\n  - .../PostPrompt.php
      - .../PostPromptEot.php
      - .../SwaigRequest.php

    declared (generator len(stale)) = 3
    parsed   ('- '-prefixed lines)  = 2      <- short by 1

After: declared 3, parsed 3. Filesystem truth (git status on the generated
tree) is also 3, so the parse now equals reality and not just the header.

The same one-character defect was present in all four sibling generators that
print a stale list — generate_swaig_payloads.py, generate_relay_protocol.py,
generate_rest_tests.py, generate_swml_verbs.py — fixed in all four (verified
on generate_swml_verbs.py with a planted 2-stale state: declared 2, parsed 2).
A fleet-wide sweep for the same shape across every port's scripts/ found no
other instance.

porting-sdk's scripts/spec_fanout_gate.py `_stale_files()` carries an explicit
workaround for this bug (it splits on a literal `\\n` as well as a real one).
That workaround is now a NO-OP on this port's output — verified by running the
parse with and without it: identical results on the fixed output, differing by
one on the buggy output. Left in place; it is another repo's file.
…le suite

`bash scripts/run-ci.sh --gate REPO-LINT` did not filter to one gate. No such
flag has ever existed: gate_scheduler.sh's sched_init() loops over argv matching
exactly two tokens (`--fail-fast`, `--tier=*`) with NO default case, so every
other token was dropped on the floor and the FULL suite ran.

Two harms, the second worse than the first:
  1. Cost — an accidental full run (25 per-PR gates here) starves sibling lanes
     on a shared box.
  2. Correctness — the failure mode is silence PLUS a better-than-expected
     result. A caller who reads `==> CI PASS` from `--gate X` concludes gate X
     passed, when what actually ran was everything. That is why it went unnoticed.

Fix the CLASS, not the one flag: validate argv up front and exit 2 with a usage
line naming the real options. Any future typo'd or renamed flag now fails loud
rather than silently meaning "run everything". There is deliberately no third
state — a bad flag never warns-and-proceeds.

Placed BEFORE the mock-server spawn so a typo dies instantly instead of after
standing up three listeners. `--tier=` is validated against pr|nightly|all too:
sched_init accepted `--tier=garbage` and _sched_tier_active's `pr|*)` fallback
silently degraded it to pr — the same silent-wrong-behavior shape.

No per-gate filter was implemented: nothing in run-ci.sh or gate_scheduler.sh
has per-gate plumbing to build one on, so inventing one here would be net-new
surface. The usage text instead points at running a gate's command directly.

Verified: `--gate REPO-LINT`, a bare positional, `--failfast`, and
`--tier=garbage` all exit 2 with usage; `--help` exits 0; and the legitimate
path (`--fail-fast`) still runs all 25 gates to `==> CI PASS`.

FLEET NOTE (not fixed here — separate repos, own CI): the trap is in the SHARED
porting-sdk/scripts/gate_scheduler.sh, so all 9 scheduler ports (python,
typescript, ruby, java, go, perl, rust, dotnet + this one before the fix) shared
it; none had local argv validation. Live-confirmed on ruby, which accepted
`--gate REPO-LINT` and began running its full suite. cpp is worse still: its
run-ci.sh never reads script-level "$@" at all, so it ignores every argument
including the real --fail-fast and --tier=.

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

porting-sdk d8e5787 re-vendored swaig-specs from mod_openai 8d6ed5e, and its own
commit message flags that the re-vendor "stales GEN-FRESH-SWAIG in typescript, go,
ruby, php and dotnet; that regen ... stays a separate, explicit act." This is php's.

context_switch's system_pom and user_pom carried
`description: "read by the engine; no predicate types it here"` and no type, so the
generator emitted them as `mixed`. The re-vendor gives both a real
`type: object` with a `pom` array of PromptPomSection plus a `text` string,
additionalProperties true, propertyNames string -- sourced to the keys
get_prompt_text() reads off it (app_config.c:1106). The generator now narrows them
to `?array` with an `array<string,mixed>` docblock, which is what an open
string-keyed object is in PHP.

Regenerated, not hand-edited: scripts/generate_swaig_payloads.py emitted 20 files
and exactly this one differs. php-cs-fixer is a no-op on the emit
(0 of 1475 files fixable), so GEN-FRESH-SWAIG and FMT agree on these bytes.

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

The brief for this lane said php owed only an explicit regen for the d8e5787
re-vendor. That is a THIRD of it: a bare regen leaves five drifts standing, because
porting-sdk 4ddda70 also taught the REFERENCE generator to resolve cross-file $refs,
and 1ed7eac regenerated the Layer-B oracle for what that produced. post-prompt.yaml's
post_response / delayed_post_response now resolve
swaig-response.yaml#/components/schemas/SwaigResponse instead of degrading to a dict,
so SwaigAction and SwaigResponse became real oracle symbols. 4ddda70's own message
predicted the fan-out for go; php has the identical shape and go fixed it in 41a012c.

Before this commit `==> CI FAIL (gates: SURFACE GEN)`:
  DRIFT          5 missing-port -- gen-payload.SwaigAction.{context_switch,hold,
                 playback_bg,transfer} and gen-payload.SwaigResponse.action
  SURFACE-DIFF   2 Python symbols missing from port -- SwaigAction, SwaigResponse
  SURFACE-FRESH  3 stale leaves on PostPromptSwaigLogEntry

generate_swaig_payloads.py emitted only the per-verb <Verb>Action value classes; the
docstring justified that as "the envelope schemas are not part of the cross-port
surface oracle", which 4ddda70 made false. Both envelopes are emitted now.

The one subtlety is the field TYPE. GR.php_property_type deliberately collapses every
object / $ref / union field to ?array, which is right for the value classes and wrong
here: it erases the `class:` token the oracle keys on, and the field then reads as
missing-port even though the property exists (go hit exactly this). So
_envelope_property_type keeps the lifted class in the envelope field's type, as a
NATIVE PHP 8 union -- `public string|ContextSwitchAction|null $context_switch` --
which translate_php_type canonicalizes to union<string,class:...ContextSwitchAction>,
the reference's spelling. Scalar-only actions keep the shared helper untouched; they
carry no `class:` and the oracle does not record them (enumerate_python_signatures.py
_is_sdk_class_type). The shared helper itself is NOT modified.

SwaigResponse.action is SwaigAction | list[SwaigAction]. PHP has no generic array type
HINT, so the union is `SwaigAction|array|null` with a `SwaigAction|list<SwaigAction>|null`
PHPDoc -- which is also what PHPStan L9 demands (a bare array is
missingType.iterableValue; that finding is how the docblock got written).

PostPromptSwaigLogEntry needed no code change at all: post_response and
delayed_post_response were already emitted as `mixed`, and the SURFACE enumerator
oracle-gates public-property emission, so they became visible the moment the oracle
recorded them. That gate wanted an artifact refresh, not a port fix.

Artifacts regenerated in go's documented ORDER -- signatures FIRST, then surface --
because the surface enumerator imports composition members by reading
port_signatures.json off disk; the reverse order drops the new
PostPromptSwaigLogEntry leaves while both commands exit 0.

Negative-controlled by deleting the two emitted files: DRIFT and SURFACE-DIFF both
FAIL exit 1 with the original 5 + 2 findings, and both PASS on restore.

NOTE for whoever tightens the audit tags next: php spells the class ref from the PHP
namespace (class:signalwire.swaig.generated.swaig_actions.ContextSwitchAction) rather
than the oracle module (signalwire.core.swaig_actions_generated). DRIFT is clean
because the gen-payload fold canonicalizes the module, and every OTHER generated
payload class already spells it the same way, so this is uniform port idiom rather
than a new divergence. go chose to route its tag explicitly (41a012c point 3); php
would need a CLASS_MODULE_MAP entry per generated class to match, which is a separate
change with its own blast radius.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
The re-vendor changed the spec these ports generate from and they were never
regenerated, so GEN-FRESH-SWAIG went red across seven ports and SPEC-FANOUT
reported them in aggregate on porting-sdk #125.

Purely additive, as a legitimate re-vendor should be: SwaigAction gains the
SWML action; SwaigRequest gains SWMLCall and SWMLVars. No existing value
changes. The new SWML action is the same one SWAIG-COVERAGE reported the SDK
could not emit, so this closes that gate too.
…oad regen

The payload regen updated the generated SWAIG files but not the signature
artifact that describes them, so SIGNATURES-FRESH went red on every port that
carried it -- "committed_signatures.json does NOT match a fresh regen".

Additive only: the new members are the SWML action and the SWMLCall/SWMLVars
request fields the payload regen introduced.
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