Skip to main content

Risks and alternatives

Author: research pass for MEP-55 (Mochi-to-PHP 8.4 transpiler). Date: 2026-05-29 15:00 (GMT+7). Sources: transpiler3/php/colour/colour.go, transpiler3/php/lower/lower.go, transpiler3/php/runtime/composer.json, transpiler3/php/build/phase13_test.go, transpiler3/php/build/phase17_test.go, .github/workflows/transpiler3-php-test.yml, website/docs/mep/mep-0055.md.

This note collects the risks MEP-55 accepts, the mitigations applied, and the alternatives explicitly rejected. Each risk entry has a severity, a description, and a mitigation or current status.

Risk register

R1: PHP has no preemptive scheduler

Severity: medium (design constraint, not a defect).

Description: PHP has no preemptive scheduler in the standard library. Mochi agents and streams on PHP are synchronous: an agent's recv() call does not yield to other agents while waiting. All Phase 9-10 fixtures use emit-before-recv patterns specifically because of this.

If a Mochi program is written with interleaved concurrent agent behaviour (agent A sends to agent B which replies to agent A), that program cannot be correctly lowered to the synchronous PHP model.

Mitigation: The lowerer emits sequential code. Programs that require genuine concurrency must target a different Mochi backend (BEAM MEP-46, JVM MEP-47). The MEP-55 spec documents this limitation explicitly.

Status: Accepted design constraint. colour/colour.go documents: "no PHP function ever needs an Amp\Future<T> return type."

R2: PHP_INT_MAX overflow for DJB2 cassette keys

Severity: high (would produce wrong cassette keys for some prompts).

Description: PHP's native int is 63-bit signed on 64-bit platforms (PHP_INT_MAX = 9223372036854775807 = 2^63 - 1). The DJB2 hash of some (provider, model, prompt) combinations produces a 64-bit unsigned value greater than PHP_INT_MAX. Under PHP's native integer arithmetic, the value would be silently truncated or wrapped as a negative number, producing a key that does not match the cassette file.

Example: djb2("openai", "", "Say hello.") = 15023835511162652990, which is larger than 2^63 - 1 = 9223372036854775807. PHP would miscompute this without GMP.

Mitigation: The mochi_llm_cassette_key helper (lower.go lines 388-403) uses GMP to perform all hash arithmetic in unsigned 64-bit space:

$h = gmp_init(5381);
$mask = gmp_init('FFFFFFFFFFFFFFFF', 16);
$h = gmp_and(gmp_mul($h, 33), $mask);
$h = gmp_xor($h, gmp_init(ord($buf[$i])));
return gmp_strval($h, 10);

GMP is a required extension ("ext-gmp": "*" in composer.json).

TestPhase13DJB2HashMatchesCassetteFilenames pins the exact expected hash for every known tuple, confirming that the GMP path produces the same result as Go's native uint64 arithmetic.

Status: Mitigated. Requires ext-gmp, documented in README and composer.json.

R3: Psalm 5.x PHP 8.4 incompatibility

Severity: high (blocks CI).

Description: Psalm 5.x did not recognise PHP 8.4 as a valid platform version in its internal version table. When run against the runtime package with platform: {php: 8.4} in psalm.xml, Psalm 5 reported the PHP version as unknown and flagged abstract readonly class syntax as invalid PHP, producing false-positive errors.

Mitigation: Migrated to "vimeo/psalm": "^6.0" in composer.json. Psalm 6 adds full PHP 8.4 support. This migration happened in audit round 1 after the initial umbrella PR.

Status: Resolved. composer.json requires ^6.0.

R4: final readonly class extends abstract readonly class requires PHP 8.4

Severity: high (would break sum-type lowering on PHP < 8.4).

Description: PHP 8.4 introduced the rule that a final readonly class can only extend an abstract readonly base. In PHP 8.3 and earlier, abstract readonly class is not valid syntax. Attempting to use a plain abstract class base for a sum type breaks in PHP 8.4 because final readonly cannot extend a non-readonly parent.

This constraint was discovered during Phase 5 CI on the umbrella PR. The CI run failed with a PHP parse error on the emitted sum-type class hierarchy.

Mitigation: The ClassDecl ptree node has an Abstract field. When Abstract: true, the emit pass writes abstract readonly class NAME {} (nodes.go lines 738-739). The lowerUnion function (lower.go lines 1006-1030) sets Abstract: true on the base class. The fix was a single line in the lowerer.

Status: Fixed in umbrella PR Phase 5. No further action needed.

R5: phar.readonly = 1 default on most distributions

Severity: medium (blocks Phar creation without workaround).

Description: Most Linux distributions and macOS Homebrew ship PHP with phar.readonly = 1 in the global php.ini. This prevents the Phar::startBuffering() and addFile() calls in the Phase 17 stager from executing.

Mitigation: The stager is invoked with php -d phar.readonly=0 in runPharFixture (phase17_test.go line 74). Documentation notes that production deployments can use humbug/box compile (which handles phar.readonly internally) or add phar.readonly = Off to a project php.ini.

The -d flag works per-invocation without modifying the system configuration. CI uses shivammathur/setup-php@v2 which sets up a clean PHP environment; the flag is still needed there.

Status: Mitigated. No change to default distribution configuration required.

R6: amphp/revolt removed from runtime

Severity: low (was never shipped, removed in audit round 1).

Description: An early draft of composer.json listed amphp/revolt in require-dev as the intended async event loop backend for Phase 11. Phase 11 was implemented as synchronous wrappers instead, making the dependency unnecessary. The dependency was removed in audit round 1.

If amphp/revolt had been left in require-dev, it would have added a large transitive dependency tree and potentially caused Psalm/PHPStan analysis to take longer. More importantly, it would have created a false expectation that Phase 11 supports true async.

Mitigation: Removed. composer.json has no amphp/revolt entry. The colour.go package comment documents the decision permanently.

Status: Resolved.

R7: Live LLM providers deferred

Severity: low (Phase 13 is cassette-only; acknowledged in spec).

Description: Phase 13 ships cassette-only LLM dispatch. The mochi_llm_generate helper reads $MOCHI_LLM_CASSETTE_DIR/<hash>.txt. When the env var is unset or the cassette is missing, the helper writes a stderr diagnostic and returns "":

fwrite(STDERR, "mochi_llm_generate: MOCHI_LLM_CASSETTE_DIR not set; live mode not yet implemented for PHP\n");
return '';

Live OpenAI, Anthropic, Google, and llama.cpp integrations are not implemented.

Mitigation: The C runtime (MEP-45) similarly defers live providers until a later phase. The cassette-only approach is sufficient for testing and for offline evaluation scenarios.

Status: Accepted deferral. Documented in MEP-55 spec and in the PHP helper's stderr message.

R8: PHP 8.5 allow_failure

Severity: low (early warning, not a current breakage).

Description: PHP 8.5 was in alpha/beta at MEP-55 ship time (May 2026). Its full release may introduce changes to:

  • Strict-type handling (new coercion rules).
  • Deprecation of functions used by the runtime or lowered code.
  • PHPStan/Psalm compatibility.

Mitigation: The CI matrix runs PHP 8.5 as allow_failure: true (.github/workflows/transpiler3-php-test.yml). This provides early warning of 8.5 breakage without blocking CI on main. When 8.5 reaches stable, the allow_failure flag will be set to false.

Status: Monitored.

R9: Mochi\Runtime\IO flagged as dead code by Psalm

Severity: low (causes false-positive CI failure without @api).

Description: Psalm's UnusedClass check reports IO as dead code because no code within src/ calls its methods. Lowered Mochi programs call it from outside the scanned source tree.

Mitigation: The @api PHPDoc tag on IO (runtime/src/Mochi/ Runtime/IO.php lines 17-18) suppresses the finding. See 04-runtime for details.

Status: Mitigated by @api. TRUST.md documents the convention.

R10: PHP reserved word collision with user agent/record names

Severity: low (affects programs that name a type after a PHP keyword).

Description: PHP rejects class names that are reserved words. A Mochi program with agent Switch { ... } or record Int { ... } would emit invalid PHP without the collision guard.

Mitigation: phpClassName(name) (lower.go lines 965-972) appends _ to any name in phpReservedClassNames (lines 939-963). The agent_bool.mochi fixture pins the Switch_ case. The list covers all PHP 8.4 reserved words and soft-reserved type names (int, float, bool, string, void, etc.).

Status: Mitigated. Fragment test agent_bool pins it.

Rejected alternatives

A1: Amp/Revolt for async

Evaluated: amphp/amp v3 and revolt/event-loop for Phase 11 async.

Decision: Rejected. Adds runtime dependencies, changes function signatures, requires event-loop configuration. Sync wrappers cover all Phase 11 fixture use cases.

A2: Haxe PHP backend

Evaluated: route Mochi through a Mochi→Haxe transpiler using Haxe's existing PHP backend.

Decision: Rejected. Two translation layers. Haxe's PHP backend not under Mochi's control. Would add a large build-time dependency.

A3: HHVM/Hack as the primary PHP-family target

Evaluated: target HHVM instead of Zend PHP.

Decision: Rejected. HHVM dropped PHP backward compatibility in 2018. HHVM is not available on shared hosting. Standard Zend PHP 8.4 reaches a wider deployment surface.

A4: humbug/box for Phase 17 CI gate

Evaluated: use humbug/box compile in the Phase 17 CI gate.

Decision: Rejected for the in-tree gate. Requires composer global require humbug/box and network access in every test run. The built-in Phar class is sufficient for structural validation. Production builds can use humbug/box.

A5: PHP 8.3 minimum floor

Evaluated: lower the floor to PHP 8.3 for wider compatibility.

Decision: Rejected. PHP 8.3 has no abstract readonly class. Sum types cannot be encoded cleanly. See 02-design-philosophy for the full version history analysis.