wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold) - #52
Open
mjerris wants to merge 81 commits into
Open
wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold)#52mjerris wants to merge 81 commits into
mjerris wants to merge 81 commits into
Conversation
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
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ismeaningless held against PHP's
__constructtaking a singlearray $options. Themeaningful comparison is the §10
constructionnode, keyed by NAME — and it staysfully live and unexcused (numbers below).
Deletion-only PR. No allowlist/omission/divergence entry added; no PHP source touched.
PORT_OMISSIONS.mdandPORT_ADDITIONS.mdare deliberately untouched — a different toolwith a hard dead-entry gate.
Before / after counts
PORT_SIGNATURE_OMISSIONS.md(baseline =main):__init__entriesThe 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.excusedat all. The delta showsup against the pre-fold diff script:
7041 → 7001 is the fold retiring exactly 40 dunder members from the excused pool.
Decomposed (replaying the pre-fold
diff_with_surfaceagainstmain's ledger andpartitioning its
excusedlist by the fold predicate):Note 39, not 28: the fold also stops excusing 11
__init__symbols that had no ledgerentry and were being excused via
PORT_OMISSIONS.md/PORT_ADDITIONS.mdclass-levelcoverage 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 classthe reference's construction node covers, plus the one
__str__). Only 40 of those werepreviously landing in
excused; the other 100 were alreadymatchon both sides, whichwas never counted as a divergence.
Mutual dependency, demonstrated: pre-fold script + pruned ledger is RED —
39 signature drift(s), e.g.Construction node UNCHANGED
__init__-as-a-member and the §10 construction params are different contracts. Theconstruction node is untouched, so nothing was traded for a blind spot:
Reference construction node: 139 classes. A fresh
python3 scripts/enumerate_signatures.pyrewrites
port_signatures.jsonbyte-identically (mtime moves, content does not), so the_restore_treerevert-to-HEAD trap does not apply here — the tree is clean at commit.Gate output actually seen
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 themerge-order note.)
__init__entries the rule does NOT cover — 16 keptThe fold's guard is structural (
cls_path in reference["construction"]), not a symbollist, 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 membercomparison is the only comparison there is for them:
Datasphere—PHP-default-ctor, directionmissing-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.CallingNamespaceandsignalwire.rest.namespaces.fabric.FabricTokens—PHP-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, andDatasphere. The modulesignalwire.rest.namespaces.callingdoes not exist in either the oracle orport_signatures.json: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.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
Coordinated-With: porting-sdk@wave6/ctor-dunder-fold