Skip to content

Never report success for a site a visitor cannot open - #17

Merged
korya merged 10 commits into
masterfrom
dmitri-fix-servable-versions
Aug 7, 2026
Merged

Never report success for a site a visitor cannot open#17
korya merged 10 commits into
masterfrom
dmitri-fix-servable-versions

Conversation

@korya

@korya korya commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Problem

Creo told people their website was live when visitors got a 404. A build that produced no home page was reported as finished, offered for publishing, and celebrated — the platform's most damaging failure mode is not breaking, it is lying.

Nothing between the agent and that celebration ever asked whether the artifact was a site. Commit accepted any set of files, success was inferred from the model's stop reason, and publishing checked only that a version existed. The one component that knew better was the serving gateway, which joins index.html onto every site root and 404s when it is absent.

Solution

Make a version impossible to create unless the vertical can serve it, and give the agent a chance to fix its own mistake first. Three layers, outermost first:

harness    model says "done" ─▶ Commit ─┬─ ok ──────────▶ run.completed
                                        └─ unservable ──▶ repair turn (×2) ─▶ honest failure
project    Commit ─▶ Validate ─▶ Quota ─▶ write        ← nothing is written if refused
publish    Publish/Rollback ─▶ Validate ─▶ pointer flip ← for versions minted before the gate

The profile owns the policy. ValidateArtifact is the floor to ValidatePalette's ceiling — one stops a vertical granting more capability than its level allows, the other stops it declaring victory over something a visitor cannot open. The websites vertical requires index.html, present and non-empty. That is not a convention: it is what the gateway resolves a site root to, so a bundle without one is unservable by construction.

The stores enforce policy they do not author. Both gates are injected closures wired in one place, mirroring the existing Quota hook, so project and publish import no policy package. Inside Commit, Validate runs before Quota — servability is a property of the content, capacity a property of the account, and a tenant near their limit mid-repair should hear "there is no home page yet" (which another turn can fix) rather than "you are out of space" (which it cannot). There is a test pinning that order.

The harness asks rather than checks. When the model declares itself finished, the harness calls Commit and branches on the error. Asking the gate rather than duplicating it means the two cannot disagree, and it needs no new workspace API. An unservable artifact buys up to two repair turns; the instruction is written to the log because reconstruct() rebuilds the conversation from the log alone, so an unlogged one would vanish on takeover and the model would simply re-declare itself done. The budget is counted from those logged events for the same reason.

Two paths that used to lie now do not. An exhausted budget commits nothing and fails in plain language. The step-limit path also goes through the gate — "the work so far is saved" is only true when the work is a site, and it was previously said either way.

Before / After

Same scenario in both: a build that never writes a home page.

Before — success over a 404 After — honest failure
"Your site is ready with all eight pages." Status Ready, Publish enabled, preview shows 404 page not found. One plain sentence, Publish disabled, and the empty state instead of a 404 — because with no version minted, the client's existing logic already does the right thing.

And when the agent can fix it, the user simply gets a website:

Verified against the real bug, not only fixtures

The CSS-only version from the original report was still in a local data directory, so the backstop was tested against it rather than against a mock:

publish the CSS-only version  → 409  "That version of your site doesn't have a home page,
                                      so it can't go online. Ask for the page you want,
                                      then publish again."
publish the good sibling      → 200
rollback (parent is broken)   → 409

A full qwen3.6 build also ran end to end: it produced a real four-file site, published, and served HTTP 200 — the gate does not false-positive on healthy output.

Honest gaps

The repair acknowledgment is currently invisible in the browser — tracked as #18, since web changes are out of scope here.

A real-model early stop could not be forced on demand. The original occurrence was luck. The repair mechanics are covered exhaustively by deterministic fixtures; the live run proves absence of false positives, not presence of a real-world repair.

Other Changes

  • A standing guardrail, not just a fix. assertServable fetches the newest version's preview root after a run completes and requires 200. It lives inside waitCompletedAuthed, which every authenticated completion already funnels through, so a scenario cannot opt out by forgetting; the two AC-1 tests wait on an inline predicate and get it explicitly. Verified it bites by recreating the pre-fix world — gate removed, slow-site writing no home page — which fails with "the run completed but the site root serves HTTP 404 — a visitor would see an error page".
  • The fake fleet had drifted into modelling an impossible model. slow-site wrote page1.htmlpage8.html and no home page — an eight-page website nobody could open — and passed the entire suite for four milestones, because nothing asserted servability. Its first page is now index.html; step count, per-step delay and the kill-mid-run window are unchanged, so both AC-1 tests still prove kill → resume → completion. This corrects a premise in the plan document, which had claimed every script wrote a home page; recorded there as a correction because it is stronger evidence for the same conclusion, not a retraction.
  • Two adversarial scripts (no-page, repairs-site) plus asks-before-page stay registered permanently, and AGENTS.md now asks that fixtures model outputs a real model could produce.
  • repair.started / repair.completed move from specified-but-unemitted to emitted; validators in the component catalog stops being a declaration with nothing behind it.
  • PRD open question Composer clips the text you are typing #6 gets a partial answer: missing-page repairs are autonomous and acknowledged with one line naming the delay rather than the artifact. A deliberate softening of P7's "repairs silently" — the repair is silent, the time it cost is not. Recorded as settling one rung of the ladder, not the ladder.
  • The refusal copy is pinned by an httptest-level test covering both the publish and rollback paths — it asserts 409, absence of implementation jargon, and that the sentence names the missing home page, rather than exact strings that a comma edit would break. Verified against both regressions it exists to catch: dropping the mapping yields 500, and degrading the copy to "invalid artifact" trips the jargon check.
  • No new dependencies; no web/ or internal/webui/dist changes.

Related:

🤖 Generated with Claude Code

korya and others added 8 commits August 7, 2026 11:21
ValidateArtifact is the other end of the run from ValidatePalette. That
one stops a vertical granting more capability than its level allows;
this one stops it declaring victory over something a visitor would get a
404 from.

The websites vertical requires index.html, present and non-empty. Paths
must match exactly: a nested pages/index.html does not count, because
the serving gateway joins index.html onto the site root and nowhere
else. A written-but-empty file does not count either — it is unservable
in the only way that matters to a visitor.

This is the minimal first form of the validators the component catalog
has declared since M3 without any implementation behind it. A profile
that requires nothing still accepts anything, so the gate is opt-in per
vertical rather than a platform-wide opinion about what a project must
contain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Commit gains an injected Validate hook beside the existing Quota one, so
"a version exists" now means "a site works". Making it a hook rather than
a check in the harness matters: Commit is the only way a version is
minted, so every present and future caller is covered by construction
rather than by remembering to ask first.

Validate runs before Quota. Servability is a property of the content and
capacity is a property of the account, so a tenant near their storage
limit mid-repair hears "there is no home page yet" — which another turn
can fix — instead of "you are out of space", which it cannot. There is a
test pinning that order, because it is easy to swap and hard to notice.

The store executes policy it does not author: the closure is wired in
server.go and its error propagates with %w, so callers branch with
errors.Is while the store imports no policy package. A nil hook is the
old behaviour exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The harness no longer decides for itself that the model is finished. When
the model declares done, the harness calls Commit — the mint gate — and
branches on the answer. Asking the store rather than checking separately
means the gate and the check are the same code path and cannot disagree.

An unservable artifact buys a repair turn: the agent is told what is
missing and given the workspace it already built. Two attempts, counted
from repair.started events in the log rather than a local variable, so a
worker taking over mid-repair inherits the attempts already spent instead
of granting a fresh set. The instruction is logged for the same reason —
reconstruct() rebuilds the conversation from the log alone, so an
unlogged instruction would vanish on takeover and the model would simply
re-declare itself finished.

repair.started renders nothing: the user asked for a website and is
getting one, and a stumble they never saw is not news. If the repair
works, repair.completed carries one line acknowledging the time rather
than the artifact — naming the home page would invite worry about a
problem that no longer exists.

Two paths that used to lie now do not. An exhausted budget fails with
plain language and commits nothing. The step-limit path also goes through
the gate: "the work so far is saved" is only true if the work is a site,
and it was previously said either way.

commitProgress treats the refusal as "nothing servable to snapshot yet"
and skips silently — parking mid-build before a page exists is
legitimate, and the workspace keeps the partial work regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The mint gate stops new unservable versions being created; this stops the
ones that already exist from ever going live. At least one such version
exists in real data, so the backstop is not hypothetical.

Rollback checks inside its transaction, between resolving the parent and
moving the pointer. A handler-side pre-check would be validating a
different version than the one about to go live, because the parent is
only known once resolved.

The API translates the refusal into a sentence and nothing else — the
policy stays in the profile, the enforcement in the stores, and the
handler does the one job it has. Both routes were already tenant-scoped,
so no new hostile case is needed.

Covered by a unit test rather than e2e on purpose: post-fix the product
can no longer mint an unservable version, so the only way to exercise the
backstop is to seed one through a store with no gate wired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Adds no-page (never writes a page, so the repair budget exhausts
deterministically), repairs-site (produces the page only once told, the
one script that exercises the repair path), and asks-before-page (parks
on a question while the site is still unservable, which is what the
mid-run commit skip is for). They stay registered permanently.

Also changes slow-site: its first page is now index.html rather than
page1.html. The gate exposed that fixture as modelling an impossible
output — an eight-page website with no home page, which no real build
should ever leave standing — and it had passed the whole suite for four
milestones because nothing asserted a version was servable. Step count,
per-step delay, and the kill-mid-run window are unchanged, so the two
AC-1 tests still prove kill → resume → completion; only the filename the
existence check looks for moved with the fixture.

assertServable is the guardrail that makes this class visible in future:
after a run completes, fetch the newest version's preview root and
require 200. Wired into the publish choreography and the new scenarios,
so any path that mints a version a visitor cannot open fails in CI rather
than in someone's browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
components.md §10: validators stop being a declaration with nothing
behind them. The ProductProfile contract gains the artifact-side refusal
next to the palette-side one — ValidateArtifact is the floor to
ValidatePalette's ceiling — and states the invariant plainly: a version
exists only if the vertical can serve it, and the live pointer only ever
targets one that exists.

architecture.md §3.2: repair.* moves from specified to emitted.

PRD open question #6 gets a partial answer, not a closure. A build that
finishes without a servable site is repaired autonomously and, if that
works, acknowledged with one line naming the delay rather than the
artifact. That is a deliberate softening of P7's "repairs silently": the
repair is silent, the time it cost is not.

The plan's RCA claimed every fake script wrote index.html. An audit
found seven of nine did, and the exception backed the two AC-1
acceptance tests while passing the whole suite for four milestones —
because nothing asserted servability anywhere. Recorded as a correction
rather than quietly fixed: it is stronger evidence for the same
conclusion, since the fleet held not just kind fixtures but an
impossible one.

AGENTS.md now asks that fixtures model outputs a real model could
produce, with slow-site named as the in-repo example of that drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The gate's copy is a user-reachable contract and had only manual
verification behind it. A refactor could have dropped the errors.Is
branch and degraded it to serverError's generic "something went wrong on
our side" with nothing noticing.

Covers both refusal paths, because rollback resolves its target inside
its transaction and so travels separate code with separate copy. The
fixture seeds a three-version lineage with the middle one unservable,
which is the only shape that reaches both: publishing that version
directly, and rolling back onto it. Seeded through a store with no gate
wired, since post-fix the product cannot create that state — which is
the point of the backstop.

Asserts the properties that matter rather than exact strings: 409, no
implementation jargon, and that the sentence names the missing home page
so the reader can act on it. Pinning the literal text would make a comma
edit a test failure while catching nothing a user would feel. Verified
against both regressions — dropping the mapping yields 500, degrading
the copy to "invalid artifact" trips the jargon check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Records the accepted coverage limit alongside it: the repair mechanics
are owned by deterministic fixtures, and the live qwen3.6 run proves only
that the gate does not false-positive on healthy output — a real model's
early stop could not be forced on demand.

Also drops a now-dangling reference. The provenance line named the
superseded draft by path; that file has been deleted, so the line
describes it without pointing at something a reader would go looking for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
korya and others added 2 commits August 7, 2026 13:37
The acknowledgment was gated on "did this worker repair?", which a
takeover answers no. A worker resuming after a crash mid-repair sees the
attempt already in the log, finishes the work the instruction asked for,
and then said nothing — losing a repair that demonstrably happened and
contradicting the decision recorded in PRD open question #6.

The right question is "was this run repaired?", and the log is what
answers it: any spent budget followed by a successful commit means a
repair worked, whoever finished it. Found in review; the test seeds the
state a crashed worker leaves behind and fails on the old predicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The guardrail was wired at three call sites while the suite has
twenty-three run-completion waits — the gap between what it claimed and
what it covered was exactly the drift the AGENTS.md rule it shipped with
warns about, starting on day one.

Moved into waitCompletedAuthed, which every authenticated completion
already funnels through, so a scenario can no longer omit it by
forgetting. The session is mapped back to its project through the public
API, so call sites need no new plumbing. The two AC-1 tests wait on an
inline predicate rather than the helper, so they get the assertion
explicitly — they run over slow-site, the fixture that hid this class for
four milestones.

Verified it bites: recreating the pre-fix world (gate removed, slow-site
writing no home page) fails with "the run completed but the site root
serves HTTP 404 — a visitor would see an error page".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
@korya
korya marked this pull request as ready for review August 7, 2026 17:45
@korya
korya merged commit 42c38b4 into master Aug 7, 2026
1 check passed
@korya
korya deleted the dmitri-fix-servable-versions branch August 7, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant