Railway now pulls ghcr.io/copilotkit/aimock:latest directly
via a startCommand that fetches fixtures from GitHub raw URLs.
The wrapper Dockerfile that copied fixtures into the image is
no longer needed.
Also updates README.md to reflect the new architecture:
- References Railway startCommand instead of Dockerfile
- Removes the Dockerfile entry from the fixtures listing
- Updates related workflow descriptions
⚠️ **Docs sync — MANUAL REVIEW REQUIRED**
This PR was auto-opened because the docs-sync script detected
showcase-local modifications overlapping with upstream changes.
The script attempted a best-effort 3-way merge:
- Where `git merge-file` produced a clean merge, the merged content was
written.
- Where `git merge-file` produced conflict markers, **upstream content
was written as-is** and showcase-local modifications were overridden.
**Manual review required.**
### Review items
```
Files where 3-way merge FAILED — upstream content written as-is, local modifications overridden. Manual review REQUIRED before merging this PR:
- docs/snippets/shared/generative-ui/tool-rendering.mdx
Files auto-merged via 3-way merge (clean, no conflict markers — still worth a glance):
- docs/content/docs/integrations/langgraph/generative-ui/state-rendering.mdx
```
### Source
- Upstream ref:
[`189c45fb4`](https://github.com/CopilotKit/CopilotKit/commit/189c45fb4)
- Workflow run:
https://github.com/CopilotKit/CopilotKit/actions/runs/24736203326
**Review before merging.** Auto-merge is intentionally disabled
for `needs-review` PRs — confirm the upstream-wins sections
preserve any intentional showcase-local divergence you want to
keep, then merge manually.
The initial shell-docs content-import left 21 reference pages with missing
import statements: 8 component pages had empty `## Import` code fences, and
13 hook pages had `## Signature` fences whose leading `import { X } from
"@copilotkit/react-core/v2";` line was stripped.
This commit restores the import(s) in the Import and Signature fences only.
Usage examples and other code blocks are intentionally not touched.
- 8 components: fill empty Import fence with named (or default for
CopilotChatView) import + styles.css side-effect import
- 13 hooks: prepend import line(s) at the top of the Signature fence.
- useComponent: 3 imports (z, type ComponentType, useComponent)
- useRenderTool: imports injected into both Wildcard and Named overload fences
- useCopilotChatConfiguration: imports injected into both Provider and
hook ### Signature subblocks
Replace em-dashes in prose across ~37 shell-docs files with appropriate
punctuation (colons, semicolons, commas, parens, periods). Also fixes a
hardcoded "LangGraph Python" reference in quickstart.mdx bridging text,
adds a user-friendly placeholder when no framework is selected on snippet
pages, and makes heading code font size proportional rather than fixed.
- Update registry.json, demo-content.json, status.json, constraints.json,
docs-status.json across shell/shell-docs/shell-dojo
- Add integration="langgraph-python" default to quickstart InlineDemo so
the base unscoped page shows a demo instead of being empty
- Replace FeatureMatrix stub with a real server-rendered table reading from
registry — 17 integrations × 12 curated feature columns, sticky first column,
✓/— cells, integration names link to /{slug} landing pages
- Fix matrix links from /integrations/{slug} (404) to /{slug}
- Remove redundant "Feature comparison" section from index.mdx (FeatureMatrix
was a stub pointing to same destination as IntegrationGrid)
- Add .reference-content .not-prose a rule to suppress underlines on card grids
inside MDX without breaking prose link styling
- Add intro sentence to "Explore by AI backend" section
- Correct 3 mismatched feature IDs in MDX (generative-ui-tool-based ->
gen-ui-tool-based, frontend-tools-sync -> frontend-tools, reasoning ->
agentic-chat-reasoning) so FeatureIntegrations renders integration chips
- Remove stale 'matrix' from RESERVED_ROUTE_SLUGS (no app/matrix/ route exists)
- Replace broken /matrix links in IntegrationGrid and search-modal with /
since the matrix lives in shell-dashboard, not shell-docs
The YAML had key_template "e2e-smoke:${name}" (hyphen) but every other
reference — DIMENSIONS constant, alert rules, kind: e2e_smoke — uses
the underscore form. This mismatch caused the producer to write
e2e-smoke:<slug> while the dashboard filtered for e2e_smoke, leaving
17 orphan hyphen rows and zero matches on the dashboard.
Changing the YAML key_template aligns the emitted probe key with the
dashboard filter. Driver code (deriveSlug) splits on ':' regardless
of prefix, so no driver/test changes are required.
The showcase shell's demo Docs tab was rendering "No documentation
available" for 25 of 31 langgraph-python demos because their demo
folders lacked a README.md. The bundler pulls each demo's Docs content
from README.md at the demo folder root.
This adds READMEs for the 24 manifest-listed demos that were missing
one, following the 3-section template used by the 6 pre-existing
READMEs (What This Demo Shows / How to Interact / Technical Details).
An orphan shared-state-read/README.md whose route doesn't match any
manifest demo is deleted.
Also extends the check-binaries pre-commit hook allowlist to cover
the shell-docs/shell-dojo copies of demo-content.json. The bundler
emits the same content to all three shells, but only the shell path
was previously allowlisted, so any bundle regen that grew past 1MB
got blocked.
As an incidental side effect of running the bundler, demo-content.json
also drops stale entries for langgraph-python::open-gen-ui and
langgraph-python::open-gen-ui-advanced — those folders exist in source
but are not listed in manifest.yaml.
The cli-start entry in each integration's demos[] is a copy-paste CLI
command, not a runnable demo, but the profile page rendered it as a
Live Demo tile whose drawer iframe loaded ${backend_url}undefined.
Split demos into liveDemos (runnable) and commandDemos (command-only)
and render commandDemos in a new "Get Started" section above the
Live Demos grid, mirroring how the dashboard already handles them.
## Summary
- Adds `showcase/aimock/RAILWAY.md` — reconstruction reference for the
Railway service
- Documents image, startCommand flags, env vars, deploy recipe, and the
legacy Dockerfile cleanup path
- Supports Phase 2 of the aimock wrapper-elimination work
## Why
Railway service config (image + startCommand + env) previously lived
only in Railway's state. If Railway loses it, the Notion plan was the
only reconstruction source. This persists an authoritative copy in-repo
so any reviewer can rebuild the service from scratch.
Values (project ID, environment ID, public domain) were queried live
from Railway GraphQL at doc-writing time.
## Test plan
- Docs-only, no runtime impact
- Verify every referenced URL renders on GitHub after merge
- Commitlint + CI green
The railway-services DiscoverySource wrapped its filter schema in an
outer {filter: FilterSchema} ConfigSchema, but the probe-invoker calls
source.enumerate(ctx, cfg.discovery.filter ?? {}) — it passes the filter
contents DIRECTLY, not a wrapped object. At runtime cfg.filter was
always undefined, so namePrefix AND nameExcludes silently defaulted to
undefined and ZERO filtering happened.
Impact: all 7 infra services declared in smoke.yml's nameExcludes
(showcase-ops, showcase-pocketbase, showcase-shell,
showcase-shell-dashboard, showcase-shell-docs, showcase-shell-dojo,
showcase-aimock) produced smoke:/health:/agent: ProbeResults on every
15-minute tick. PocketBase carried ~21 false-red rows (7 services × 3
dimensions) across smoke:*, health:*, agent:*; after this fix the
discovery source filters them out and only the 34 user-facing
package+starter services emit probe rows (102 legitimate rows).
Fix: drop the outer wrapper. ConfigSchema IS the filter block — parse
rawConfig against it directly. Matches the documented enumerate()
contract in probes/types.ts ("config is discovery.filter") and the
shape pnpm-packages has used all along.
Tests updated: the prior discovery tests passed a {filter: {...}}
wrapped argument that only parsed because passthrough() swallowed the
misshape; they now pass the flat filter object the invoker actually
hands the source. Added one regression test that enumerates two real
showcase services plus all 7 infra services and asserts the seven are
dropped before the per-service env fetch (saves seven variables
round-trips per tick as a side benefit).
## Summary
The `railway-services` DiscoverySource in `showcase/ops` passed
`environmentId` as an argument to `Service.serviceInstances` in its
project-level GraphQL query. Railway's current schema does not accept
that argument — every discovery tick 400'd with:
```
railway gql 400: Unknown argument "environmentId" on field "Service.serviceInstances".
```
Result: no probes ticked, PocketBase stopped receiving fresh rows, alert
pipeline starved of data.
Fix: drop the invalid field argument. The returned
`serviceInstances.edges` are already filtered by `environmentId`
client-side a few lines down (`svc.serviceInstances.edges.find((e) =>
e.node.environmentId === environmentId)`), so there is no semantic
change — the argument was dead weight that tripped GraphQL validation.
## Why
- Blocks `railway-services` from enumerating anything, which blocks
every downstream probe that fans out across Railway services
(image-drift, smoke, e2e-smoke, redirect-decom).
- Unblocks the fresh-redeploy of showcase-ops that carries the #4180
merge — that PR's fix is in the running image but never reachable
because no probes tick.
## Verification
Introspection confirmed the current shape of `Service.serviceInstances`:
- `Query { project(id: String!) }` exists and returns `Project`
- `Project.services` paginates to `ServiceEdge { node: Service }`
- `Service.serviceInstances` accepts no field arguments
- `ServiceInstance.environmentId` is present, so client-side filtering
is correct
Direct `curl` against `backboard.railway.com/graphql/v2` with the fixed
query returned 41 services for the CopilotKit showcase project, matching
the expected ~35 (17 starters + 17 packages + shell + aimock + a few
infra services).
## Test plan
New test: `omits environmentId arg from Service.serviceInstances
selection to match current Railway schema`
- Mocks a Railway endpoint that rejects any query whose
`serviceInstances` call includes parenthesised arguments with the
production 400 error body, and accepts the arg-less form.
- Red on pre-fix code: `DiscoverySourceBackendError: railway gql 400:
{"errors":[{"message":"Unknown argument \"environmentId\" on field
\"Service.serviceInstances\".",...}]}`
- Green after fix; full suite 743/743.
- [x] `pnpm --filter @copilotkit/showcase-ops typecheck`
- [x] `pnpm --filter @copilotkit/showcase-ops test` (743 passed, was 742
+ 1 new regression test)
- [x] `pnpm format`
- [x] `oxlint` clean on touched files (one pre-existing unused-import
warning left alone — not in this diff)
The `railway-services` DiscoverySource was passing
`environmentId` as an argument to `Service.serviceInstances`,
but Railway's current GraphQL schema doesn't accept it. Every
discovery tick 400'd with `Unknown argument "environmentId"
on field "Service.serviceInstances"` and no probes ran.
Drop the field argument; the returned instances are filtered
by `environmentId` client-side in the subsequent loop (existing
behaviour), so no semantic change — the bad arg was dead
weight that tripped validation.
Add a regression test that scripts Railway's exact 400 when
the old query shape is sent and asserts the source succeeds
against a schema-accurate mock. Verified against the live
Railway schema via introspection and a direct curl.
The Promise branch of applyFlushResult set flushedAt optimistically
before the promise resolved, and the .then handler never reverted it
when the flush was suppressed. Since onAggregationFlush is async, this
broke the A5 "keep bucket live for re-threshold" contract in production.
The sync branch test did not exercise the Promise path. Added
bucket_stays_live_when_flush_suppressed_async to lock the invariant.
probeOne and probeAgent catch-blocks both assigned
`errorDesc: sanitizeErrorDesc(errorDesc)` where the local `errorDesc`
had already been sanitized on the line above. Double-sanitize is
harmless today because sanitizeErrorDesc is idempotent on the token
set we emit, but it would turn into a silent escape-on-escape
regression the moment the sanitizer grew a non-idempotent rule
(e.g. entity-encoding `&` → `&`). Drop the outer wrap in both
sites.
validateTripleBrace previously scanned only rule.template.text and
rule.on_error.template.text. A rule with a malformed
`{{{firstSignal.unsafe}}}` reference in aggregation.template passed
load validation and rendered the unsafe value at runtime via
Mustache.render inside onAggregationFlush — same Slack mrkdwn-injection
surface R21-a closed for on_error.template.
Pull aggregation.template into the same scan:
- firstSignal.* and lastSignal.* are aliases for signal.* in the
aggregation render context; normalize both prefixes to `signal.`
before applying the existing per-dimension slackSafe allowlist.
- `count` and `services` are engine-injected scalars (number / string)
and are always loader-known-safe — allow their bare form for symmetry
with the existing `{{count}}` usage in fleet rules.
Tests: rule-loader.test.ts adds
"rejects aggregation.template with triple-brace on an unsafe signal.*
field" under the new "validateTripleBrace covers aggregation.template"
describe.
A2 — Run link guard
Every L1–L4 red-tick alert used to render the dashboard Run link
unconditionally, so a tick with no runId (invariant probes, cron ticks
without a propagated workflow run) emitted a broken
`<https://dashboard/.../runs/|Run>` in every alert. Wrap the token in
`{{#event.runId}}…{{/event.runId}}` across all four red-tick YAMLs
(smoke/agent/chat/tools) so the entire "· <…|Run>" chunk disappears when
runId is absent. The Showcase dashboard link stays unguarded (always
valid).
A3 — smoke endpoint link from signal.url
The smoke-red-tick template referenced `{{{signal.links.smoke}}}` and
`{{{signal.links.health}}}` section-guarded on those fields. The driver-
emitted SmokeDriverSignal (probes/drivers/smoke.ts) has no `links`
object — it exposes `url` instead, the URL that was actually probed. So
the guard sections were always empty and the link never rendered. Swap
the template to reference `{{{signal.url}}}` directly and emit a single
"endpoint" link when present. The other three red-tick YAMLs never
referenced `signal.links`; leave them untouched.
SMOKE_SLACK_SAFE_FIELDS updated: drop `links.smoke` / `links.health`,
add `url`. errorDesc stays safe.
Tests:
- render-red-tick.test.ts: parametrize across all four YAMLs, assert
renders_no_run_link_when_runId_missing (output contains no
`/runs/|` or `/runs/ |`) and renders_run_link_when_runId_present
(output contains `/runs/run-abc|Run`). Add
renders_endpoint_link_from_signal_url to cover the smoke endpoint
link swap directly.
- rule-loader.test.ts: update the smoke-red-tick full-YAML tests to
seed `signal.url` and assert `<…|endpoint>` instead of seeding
signal.links.
Alert engine (handleStatusChanged):
- A1: call resolveTriggers on aggregated-rule ingress and skip when the
triggered set is purely red_to_green. Prevents recovery events from
ingesting into the fleet bucket and firing <!channel> on recoveries.
error transitions also skip ingest — aggregated rules don't support
onError today.
- B3: dispatchCronAlert returns early when rule.aggregation is set. A
rule ever declaring both cron triggers and aggregation would otherwise
render the empty top-level template or bypass aggregation entirely.
Alert engine (onAggregationFlush + drain wiring):
- A5: return "suppressed" when the bootstrap / rate-limit gate short-
circuits dispatch, so the store keeps the bucket live. Pre-fix a
bootstrap-suppressed threshold flush was silently lost — the bucket's
flushedAt was marked and post-bootstrap ticks couldn't re-trigger.
- A4: drain() is now async. Register a SIGTERM handler that awaits
drain() so in-flight buckets actually dispatch before process exit;
keep beforeExit + stop() as best-effort fire-and-forget (Node
beforeExit cannot truly await). Remove both listeners in stop() for
test-cycle symmetry.
AggregationBucketStore (aggregation.ts):
- drain() returns Promise<void> and awaits each onFlush invocation via
Promise.allSettled.
- OnFlushCallback returns void | "suppressed" | Promise<…>. When the
callback returns "suppressed", flushedAt stays null so the bucket stays
live for re-ingestion after the engine gate reopens.
- A7: groupBy is optional; absent/empty collapses the bucket key to
rule.id alone. Schema updated so AggregationSchema.groupBy is
z.array(string).optional() (pre-fix .min(1) forced a decorative
[dimension] that always resolved to one bucket anyway).
- B4: intra-key field separator is NUL instead of "&" so a groupBy
value containing "&" + "=" cannot collide with a distinct grouping.
buildBucketKey and buildCompositeDedupeKey both match.
Schema (rules/schema.ts):
- B1: remove unused aggregation.targets field. The engine has always
used rule.targets for aggregation dispatch; the schema field was
silently dropped.
Tests (alert-engine.test.ts):
- A1 engine-level: red_to_green on an aggregated rule does NOT dispatch,
green_to_red DOES dispatch (minMatches: 1, empty groupBy).
- A4: drain_awaits_onflush_promises — async onFlush resolves before
drain's promise resolves.
- A5: bucket_stays_live_when_flush_suppressed — onFlush returning
"suppressed" keeps the bucket live; a follow-up ingestion re-fires.
- A6: fix TDZ in bootstrap_suppression_respected — declare rule before
the onFlush closure that captures it.
- A7: no_groupBy_uses_ruleid_only_as_bucket_scope — three signals
across distinct dimensions all collapse into one rule-id bucket when
groupBy is empty.
- B4: bucket_key_collision_safe_with_special_chars — concrete collision
{b:"y&c=z"} vs {b:"y",c:"z"} is rejected by the NUL separator.
Fleet aggregation rules previously listed red_to_green alongside green_to_red
and sustained_red, which meant any recovery event ingested into the fleet
bucket and could fire a <!channel> aggregation alert on recovery. Remove
red_to_green from all four fleet YAMLs (smoke/agent/chat/tools) — the engine
ingress gate that actually skips recovery-only matches is added in the
subsequent commit. While here, drop the decorative groupBy: [dimension] on
every fleet rule: signal.dimension already partitions traffic to one
dimension per rule, so the groupBy expanded to undefined → "" at runtime
and silently collapsed to a single bucket per rule. The new schema treats
absent/empty groupBy as an explicit "single bucket per rule" contract.
Four aggregated rules using groupBy=[dimension], windowMs=120s
(one 90s probe tick plus slack), minMatches=3. Each carries the
<!channel> escalation that moved off the per-service red-tick rules
so channel gets paged exactly once for a fleet-wide outage instead
of N times. rate_limit.window=10m prevents re-escalation across
consecutive aggregation windows.
Aggregated rules bypass per-match dispatch and ingress-side gates
(rate_limit, dedupe, bootstrap); the composite FLUSH callback still
honors bootstrap suppression and rate_limit against the composite
dedupe key so successive window expiries for the same groupValues
collapse to one dispatch.
Add errorDesc to SMOKE_SLACK_SAFE_FIELDS and introduce an inline
L1-L4 safe-field set (errorDesc only) for the agent/chat/tools
dimensions — they don't have dedicated probe modules, so the allow-
list is defined at the orchestrator's dimension-registration site.
rule-loader.test.ts loadRealRules() mirrors the orchestrator wiring
so the full-config-dir test path still validates successfully.
Migrate the smoke-red-tick "escalated block renders !channel"
assertion onto the new fleet rule — <!channel> now lives on
smoke-red-fleet's aggregation template, not on the per-service
red-tick template.
AggregationBucketStore collects matching signals keyed by rule-defined
groupBy, fires onFlush once the bucket reaches minMatches (threshold),
or at windowMs expiry when below threshold (timer); drains outstanding
buckets on beforeExit. buildCompositeDedupeKey stabilizes rate-limit
across consecutive windows with the same groupValues.
CompiledRule gains optional `aggregation` field; schema.ts adds a
strict AggregationSchema so a typoed aggregation key fails load rather
than silently arming a bucket with undefined.
Strip HTML tags, script/style bodies (including entity-encoded), Slack
mrkdwn controls (<!channel>, <!here>, <@U…>), backticks. Decode entities
first so entity-smuggled script payloads are caught. Cap at 120 chars
with U+2026 ellipsis. Apply at 8 call sites in smoke.ts.
Wrap each {{signal.links.<key>}} reference in a Mustache section guard
so absent links emit nothing. Switch {{signal.errorDesc}} to triple-brace
{{{signal.errorDesc}}}; the value is pre-sanitized in smoke.ts's
sanitizeErrorDesc. Drop <!channel> from per-service rules — fleet rules
(introduced in a following commit) carry it instead.
No Mustache partials are wired in the renderer (render/renderer.ts uses
Mustache.render directly with no `partials:` arg), so the guarded-link
pattern stays inline across four YAMLs rather than being extracted.
Cover all four red-tick alert YAMLs. Missing links should emit nothing
instead of empty <|label> brackets. Sanitized errorDesc should render
literal characters, not HTML-entity-escaped.
- Widen classifyShape package regex to accept hyphen-bearing multi-segment
names (showcase-langgraph-python, showcase-claude-sdk-typescript,
showcase-ms-agent-dotnet, showcase-crewai-crews) without firing the
name-shape-unknown warn on every tick.
- Broaden classifyShape warn trigger: any name that matches neither the
starter nor widened-package pattern now warns, including non-showcase-
prefixed names (my-random-service, copilotkit-cloud) and mixed-case
variants. Returns package as a safe default; warn is the audit trail.
- Drop NormalisedSmokeInput discovery-arm index signature so input.url in
a mode==="discovery" branch is a TS error rather than silently unknown.
- Drop the normaliseMode early-return arm that trusted raw.mode verbatim.
Field presence is the sole discriminator, keeping schema-parsed and
raw-input paths consistent. Bad-shape inputs now throw loud.
- Restore parse-time invariants on smokeInputSchema via a union-level
superRefine that rejects bare {key}, discovery-missing-publicUrl, and
mixed-mode {key,url,name,publicUrl} payloads that the discovery arm's
passthrough would otherwise absorb.
- Add regression tests for the primary-key rewrite in discovery mode
(smoke:showcase-ag2 -> smoke:ag2, smoke:showcase-starter-ag2 ->
smoke:starter-ag2) and verbatim pass-through in static mode.
- Add end-to-end package-shape test coverage for deriveSlug feeding
demosResolver (showcase-langgraph-python -> langgraph-python registry
lookup) and the throwing-resolver swallow behaviour as a regression
guard against the deferred fail-loud change.
Applies Round 2 code review findings to the starter-probe contract:
Critical:
- classifyShape now warns on every showcase-* name that fails both the
strict starter pattern and the single-segment package pattern; typos
(showcase-strater-*) and hyphen-bearing multi-segment names surface
audit warns instead of silently falling to package.
- C2 (add "skipped" state to the aggregate ProbeResult) deferred: the
aggregate state lives on the shared ProbeState type used by the
transition-detector, alert-engine, and status-writer; extending it to
a new literal ripples across the whole state machine and six files
outside scope, and would materially change alert semantics. Flagged
as follow-up.
Major:
- resolveShape consolidated into discovery/railway-services.ts; both
smoke and e2e-smoke drivers import it and thread ctx.logger so the
classifier's audit warn fires on the driver path.
- Silent fallback path (neither name nor shape supplied) now emits a
structured debug log (resolve-shape-fallback) so the assumption is
greppable if it breaks.
- E2eSmokeStarterSignal gains errorDesc?: never, turning the "no failure
reason on a skipped-green row" invariant into a compile error.
- smokeInputSchema refactored to a zod union over static + discovery
branches; runtime normaliseMode() maps legacy inputs onto a
NormalisedSmokeInput with a required mode discriminator, replacing
the non-null assertion on input.url and the ad-hoc refine()s.
- Tests and driver comments stripped of CR-process tags (C1:, R1
bundle, etc.); test-side two-GETs rationale shrunk to a one-line
pointer so the authoritative copy lives in the driver.
- Added classifyShape + resolveShape audit tests: warn fires on typo
names, not on well-formed roots or starters, and debug fires on the
name/shape-absent fallback.
Minor:
- Warn event key namespaced to discovery.railway-services.name-shape-unknown.
- deriveSlug JSDoc moved to sit directly above deriveSlug in e2e-smoke.
- SHOWCASE_SHAPES inlined into showcaseShapeSchema (no external consumers).
- Starter signal carries a structured skipReason: "starter-shape" enum
alongside the human-readable note.
- Removed dead "unreachable" discriminator asserts in the starter
contract test.
Critical
- smoke + e2e-smoke drivers resolve `shape` via the classifier when
`input.name` is present and throw on explicit-vs-classifier
disagreement. Silent-defaulting at the driver boundary inverted the
whole starter-shape fix; the driver now fails loud on wiring drift.
Majors
- E2eSmokeSignal is a discriminated union keyed on `shape`. Starter
variant carries `note` (never rendered as failure) instead of
`errorDesc`, so alert templates stop treating a green short-circuit
as a failure reason.
- Starter shape runs TWO independent GETs against `/api/health` (smoke
and health feed separate alert dimensions that dashboards correlate;
a single-GET reuse would make the rows byte-identical and defeat the
signal).
- smoke.test.ts uses a real `node:http` server to exercise genuine 308
redirects for the agent POST; prior tests accepted a synthetic 404
response and would have let a `redirect: "follow"` regression pass.
- New tests: starter 404 on /api/health, single-source shape enum
assertion on discovery+package and discovery+starter redirect option,
mixed-driver wiring via `.passthrough()` spread, shape-mismatch
throws on both drivers.
- e2e-smoke: starter short-circuit ordering locked in via a throwing
demos resolver; full starter signal contract asserted field-by-field.
Minors
- Single-source the shape enum: `SHOWCASE_SHAPES`, `ShowcaseServiceShape`,
`showcaseShapeSchema` exported from railway-services; both driver
schemas and `deriveUrls` now import the shared types.
- `classifyShape` emits a one-shot warning on umbrella-match-but-unusual
names (`showcase-`, `showcase-starter`) when a logger is supplied.
- Static-mode rejects `url` + `shape` combo via zod refine — shape
applies only to discovered `publicUrl`.
- Drop rot-prone counts (17, 34, 51), "CR A1", "Procedure 0" and
"previously"/"old contract" phrasing. Trim WHAT comments that restate
code; keep WHY that explains contract decisions.