Gate invoice tree activation on audit schema (#339)

## Summary
- gate object-tree v2 on exact, schema-backed supersession audit columns
- serialize checked additive DDL and fail closed without disrupting
legacy invoicing
- preserve pre-schema supersession markers when structured columns are
still null
- add a superuser-only, self-scoped canary endpoint with locked
legacy-to-canonical allowlist migration
- verify effective activation, roll back failed readiness, and audit
enable/disable/failure

## Verification
- focused invoicing safety: 16 tests passed (99 assertions)
- full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing
warnings only
- PHP syntax and `git diff --check` clean
- independent architecture, security, and reviewer gates: GO

## Activation
Deploy with global database/environment enablement off. POST the
self-canary endpoint for one authenticated superuser, require
`configured_enabled=true` and `effective_enabled=true`, then verify the
exact period and tree GET routes. Roll back with the same endpoint using
`enabled=false`.
This commit is contained in:
Jeppe B
2026-08-03 13:03:55 +02:00
committed by GitHub
parent 068f9e254f
commit 0b304a2203
7 changed files with 587 additions and 6 deletions
@@ -0,0 +1,142 @@
<?php
namespace classes;
/**
* Ensures additive audit columns used when invoice collections are superseded.
*/
class invoice_collection_schema_bootstrap
{
private const TABLE = 'collected_order_invoices';
private const LOCK_NAME = 'invoice_collection_schema_v1';
/** @var array<string, string> */
private const REQUIRED_COLUMNS = [
'superseded_by_collection_id' => 'INT NULL',
'superseded_at' => 'DATETIME NULL',
'superseded_by_user_id' => 'INT NULL',
];
public static function hasRequiredColumns(): bool
{
try {
global $db;
if (!self::canInspectSchema($db) || !self::tableExists($db)) {
return false;
}
if (self::allColumnsExist($db)) {
return true;
}
if (!self::acquireLock($db)) {
return false;
}
try {
// Another worker may have completed the additive migration while
// this request waited for the advisory lock.
foreach (self::REQUIRED_COLUMNS as $column => $definition) {
if (self::columnExists($db, $column)) {
continue;
}
$result = $db->query(
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `{$column}` {$definition}"
);
if ($result === false) {
return false;
}
}
if (!self::allColumnsExist($db)) {
return false;
}
return true;
} finally {
self::releaseLock($db);
}
} catch (\Throwable) {
// Schema readiness is a capability gate. It must never break the
// legacy invoicing routes when DDL or metadata access is unavailable.
return false;
}
}
private static function canInspectSchema(mixed $db): bool
{
return is_object($db)
&& method_exists($db, 'query')
&& method_exists($db, 'escape_string')
&& method_exists($db, 'getDatabase');
}
private static function tableExists(object $db): bool
{
return self::informationSchemaCount(
$db,
'information_schema.TABLES',
'TABLE_NAME',
self::TABLE
) > 0;
}
private static function columnExists(object $db, string $column): bool
{
return self::informationSchemaCount(
$db,
'information_schema.COLUMNS',
'COLUMN_NAME',
$column
) > 0;
}
private static function informationSchemaCount(
object $db,
string $informationSchemaTable,
string $nameField,
string $name
): int {
$database = $db->escape_string((string)$db->getDatabase());
$name = $db->escape_string($name);
$result = $db->query(
"SELECT COUNT(*) AS c FROM {$informationSchemaTable} "
. "WHERE TABLE_SCHEMA = '{$database}' AND TABLE_NAME = '" . self::TABLE . "' "
. "AND {$nameField} = '{$name}'"
);
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
throw new \RuntimeException('Invoice collection schema inspection failed');
}
$row = $result->fetch_assoc();
if (!is_array($row) || !array_key_exists('c', $row)) {
throw new \RuntimeException('Invoice collection schema inspection returned an invalid result');
}
return (int)$row['c'];
}
private static function allColumnsExist(object $db): bool
{
foreach (array_keys(self::REQUIRED_COLUMNS) as $column) {
if (!self::columnExists($db, $column)) {
return false;
}
}
return true;
}
private static function acquireLock(object $db): bool
{
$result = $db->query("SELECT GET_LOCK('" . self::LOCK_NAME . "', 5) AS acquired");
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
return false;
}
$row = $result->fetch_assoc();
return is_array($row) && (int)($row['acquired'] ?? 0) === 1;
}
private static function releaseLock(object $db): void
{
try {
$db->query("SELECT RELEASE_LOCK('" . self::LOCK_NAME . "')");
} catch (\Throwable) {
// The connection also releases advisory locks automatically.
}
}
}