Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ jobs:
- name: breaker acceptance vs CorPay
run: bash evals/skills/breaker/accept.sh samples/corpay

# The U2 exit gate (console RFC D3): the REAL console driven against a REAL Goldpath
# app — capability discovery, the confirm gate, a run reaching terminal, and the repair
# queue replayed. Behaviour only; no screenshot diffing.
# The console exit gate (console RFC D3): the REAL console driven against a REAL
# Goldpath app — capability discovery, the confirm gate, a run reaching terminal, the
# repair queue replayed (U2), and the bulk intake journey end to end: upload → the
# engine's own validation report → refused approval → rejection with evidence →
# approval of a clean file (U3). Behaviour only; no screenshot diffing.
console-smoke:
runs-on: ubuntu-latest
timeout-minutes: 25
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ not propose anything that contradicts them; changes only via a superseding ADR).
- "latest" dependencies are forbidden; everything is pinned. Air-gapped networks are a first-class scenario.
- Code style: `.editorconfig` + analyzers + `dotnet format`; XML summaries mandatory on public APIs.

## Status (2026-07-24)
Phases 0–1 and the hardening set (H1–H8) are complete; the `0.1.0-preview.2` train is on
nuget.org (plus `specdrift` 0.4.1 as tool/MCP/Docker/Action). Phase D shipped the CorPay
## Status (2026-07-27)
Phases 0–1 and the hardening set (H1–H8) are complete; the `0.1.0-preview.3` train is on
nuget.org (plus `specdrift` 0.4.2 as tool/MCP/Docker/Action). Phase D shipped the CorPay
reference app (`samples/corpay`, proven nightly against the published packages). The UI
phase is active (`docs/rfc/goldpath-console.md`, U1 in flight). Live status ledgers —
phase is active (`docs/rfc/goldpath-console.md`: U1–U2 met, U3 in flight — the bulk
intake panel is proven in the nightly console smoke). Live status ledgers —
keep them updated in the same PR that changes reality: `docs/strategy/ai-sdlc-status.md`
(AI-assisted SDLC vs reality) and `docs/strategy/coverage-matrix.md` (capability × sample).
Roadmap gates: `docs/strategy/foundation.md` §12.
2 changes: 1 addition & 1 deletion docs/rfc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ All "implemented" modules are published on nuget.org at `0.1.0-preview.2`.
| [goldpath-versioning](goldpath-versioning.md) | SemVer & support promise (H7) | accepted (binding) |
| [goldpath-event-contracts](goldpath-event-contracts.md) | event contracts idiom (per-app `<Name>.Contracts`) | accepted (2026-07-14) |
| [goldpath-skills-v1](goldpath-skills-v1.md) | the AI skill layer v1 | implemented — ships inside the template; field status: `../strategy/ai-sdlc-status.md` §2 |
| [goldpath-console](goldpath-console.md) | Goldpath.Console (the UI phase) | accepted — U1 in flight |
| [goldpath-console](goldpath-console.md) | Goldpath.Console (the UI phase) | accepted — U1–U2 met, U3 in flight |
| [goldpath-ai](goldpath-ai.md) | Goldpath.Ai — opt-in runtime AI (gateway · tool registry · decision record · confidence gate) | **ACCEPTED** (2026-07-26, D1–D4 as recommended) — implementation opens after the UI phase |
| [spec-engine-v1](spec-engine-v1.md) | specdrift (separate repo) | implemented — 0.4.2 published (NuGet tool + MCP + Docker + Action) |
2 changes: 1 addition & 1 deletion docs/rfc/goldpath-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ custom-develop ON, with the same kit, the same way they add features to the back
|---|---|---|
| U1 | `ui/kit`: tokens + primitives + composites | component tests green; kit gallery page |
| U2 | Run console over the registry (single-app path) | **MET** (2026-07-26): `scripts/console-smoke.sh` drives the real console against a real Goldpath app (Postgres + Quartz + the frozen surface) — capability discovery, confirm gate, run reaching terminal, repair queue replayed; runs nightly |
| U3 | Module panels (bulk/campaign/notification/archival), capability-lit | each panel drives its module's real verbs in smoke |
| U3 | Module panels (bulk/campaign/notification/archival), capability-lit | each panel drives its module's real verbs in smoke — **bulk MET** (2026-07-27): the smoke host composes bulk, and Playwright uploads a real file, reads the report the engine wrote, is refused by the engine when invalid rows block approval, rejects with the mandatory reason, then approves a clean file; campaign/notification/archival remain open |
| U4 | Cross-service registry + triage home + auth story + `MapGoldpathConsole()` + GmConsole in nightly | the full D3 bar; CorPay screenshots into the README |

## 6. Non-goals
Expand Down
2 changes: 1 addition & 1 deletion docs/strategy/ai-sdlc-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ document exists to prevent.
| Skill quality (evals) | eval set per skill; a skill that fails evals is not released (§6.2) | **PARTIAL** — as of 2026-07-24 the 4 acceptance runners are portable (published specdrift tool, no local-checkout assumption; failures print their output) and the deterministic half runs nightly (`evals-acceptance`: runner syntax + breaker vs CorPay). The LLM half — running the skills themselves per fixture — stays deferred until an agent-in-CI story exists (P2) | nightly `evals-acceptance` job | agent-in-CI story (P2) unlocks full skill-run evals |
| Model proficiency matrix | skill × model → pass-rate matrix (§6.2) | **NOT BUILT** — Phase 2 (needs the eval lane first) | — | after the eval lane |
| Release / DevOps | release train + delivery telemetry (§6.5) | Train **SHIPPED-PROVEN** (OIDC trusted publishing, license gate, roll script); delivery report **NOT BUILT** — Phase 2 | release workflow + license gate | — |
| Operations | admin APIs + ops packs + console (§7.1) | Admin APIs + dashboards + runbooks **SHIPPED-PROVEN** per module; console **PARTIAL** (U1 in progress, RFC accepted) | admin contract frozen + integration proofs | console phases U1–U4 |
| Operations | admin APIs + ops packs + console (§7.1) | Admin APIs + dashboards + runbooks **SHIPPED-PROVEN** per module; console **PARTIAL** (U1–U2 met; U3's bulk intake panel proven in the nightly console smoke, three module panels open) | admin contract frozen + integration proofs | console phases U1–U4 |
| Pipeline (chained skills + human gates) | §6.1 level 3 | **NOT BUILT** — Phase 2 by written deferral (§12) | — | — |
| Domain memory | project knowledge that grows with every run (§6.3) | **NOT BUILT** — `domain-memory-v1.md` is a v0.1 draft | — | Phase 2/3 |

Expand Down
30 changes: 30 additions & 0 deletions tests/Goldpath.Jobs.TestHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,26 @@ static async Task RunConsoleHostAsync(string connectionString, string url)
var consoleOrigin = Environment.GetEnvironmentVariable("GOLDPATH_CONSOLE_ORIGIN") ?? "http://localhost:5201";
web.Services.AddCors(cors => cors.AddDefaultPolicy(policy => policy
.WithOrigins(consoleOrigin).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
// Bulk is composed too, so the console's capability probe LIGHTS the intake panel and
// the U3 gate can drive the real four-eyes verbs (upload → validate → approve/reject).
web.Services.AddScoped<IGoldpathBulkRowHandler<ClusterPaymentRow>, SmokePaymentHandler>();
web.AddGoldpathBulk<WebApplicationBuilder, ClusterDb>(bulk =>
{
bulk.ChunkSize = 5;
bulk.AddBatch<ClusterPaymentRow>("payments", b => b
.MaxRows(10_000)
.RowKey(r => r.EndToEndId));
});

web.AddGoldpathJobs<WebApplicationBuilder, ClusterDb>(jobs =>
{
jobs.SchedulerName = "console-smoke";
jobs.ConnectionName = "jobsdb";
jobs.CheckinInterval = TimeSpan.FromSeconds(1);
jobs.AddJob<SmokeJob>();
// Validation runs on a tight cron so the smoke can WAIT for a real report instead
// of forging one; execution stays far-future — the gate decides when rows move.
jobs.AddGoldpathBulkJobs<ClusterDb>(validateCron: "0/5 * * * * ?", executeCron: "0 0 0 1 1 ? 2099");
});

var app = web.Build();
Expand All @@ -104,6 +118,7 @@ static async Task RunConsoleHostAsync(string connectionString, string url)
app.Urls.Add(url);
app.UseCors();
app.MapGoldpathJobsAdmin<ClusterDb>(exposeUnsecured: true);
app.MapGoldpathBulkAdmin<ClusterDb>(exposeUnsecured: true);
await app.StartAsync();
Console.WriteLine("CONSOLEHOST-READY");
await app.WaitForShutdownAsync();
Expand Down Expand Up @@ -205,6 +220,21 @@ public async Task ExecuteChunkAsync(GoldpathJobChunk chunk, GoldpathJobContext c
}
}

/// <summary>The console smoke's row handler: records the payment, no artificial delay.</summary>
public sealed class SmokePaymentHandler(ClusterDb db) : IGoldpathBulkRowHandler<ClusterPaymentRow>
{
public async Task ExecuteAsync(ClusterPaymentRow row, GoldpathBulkRowContext context, CancellationToken cancellationToken)
{
db.PaymentSink.Add(new PaymentSinkEntry
{
EndToEndId = row.EndToEndId,
RowNumber = context.RowNumber,
Instance = Environment.ProcessId.ToString(),
});
await db.SaveChangesAsync(cancellationToken);
}
}

/// <summary>Chaining proof: must run exactly once, AFTER the slow job completes.</summary>
public sealed class ChainedProofJob : IGoldpathJob
{
Expand Down
93 changes: 87 additions & 6 deletions ui/console/e2e/console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ test.describe("the run console against a real Goldpath app", () => {
await page.goto(`/?base=${encodeURIComponent(service)}`);

await expect(page.getByRole("button", { name: "Runs" })).toBeVisible();
// This host composes jobs ONLY — the other four surfaces answer 404, so no panels.
await expect(page.getByRole("button", { name: "Bulk intake" })).toHaveCount(0);
// This host composes jobs + bulk; the other three surfaces answer 404, so no panels.
await expect(page.getByRole("button", { name: "Bulk intake" })).toBeVisible();
await expect(page.getByRole("button", { name: "Campaigns" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Archival" })).toHaveCount(0);
});

test("triggers a job, watches the run finish, and replays its repair item", async ({ page }) => {
Expand All @@ -22,20 +23,23 @@ test.describe("the run console against a real Goldpath app", () => {
// The fleet appears because the executor exists (zero-config discovery).
await expect(page.getByRole("button", { name: /console-smoke/ })).toBeVisible();

// Trigger through the confirm gate — the console never fires a verb on one click.
await page.getByRole("button", { name: "trigger" }).click();
// Scope the verb to ITS job row: this fleet also carries the bulk validate/execute
// jobs, so an unscoped "trigger" would be three buttons and one ambiguous click.
const smokeJob = page.locator("li", { hasText: "SmokeJob" });
await smokeJob.getByRole("button", { name: "trigger" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("audited");
await dialog.getByRole("button", { name: "trigger" }).click();
await expect(page.getByRole("status")).toBeVisible();

// The run appears and reaches a terminal state — polled through the real API.
const smokeRun = page.getByRole("row", { name: /SmokeJob/ }).first();
await expect(async () => {
await page.goto(`/?base=${encodeURIComponent(service)}`);
await expect(page.getByText("Completed").first()).toBeVisible({ timeout: 5_000 });
await expect(smokeRun).toContainText("Completed", { timeout: 5_000 });
}).toPass({ timeout: 60_000 });

await page.locator("table button").first().click();
await smokeRun.getByRole("button").first().click();
const detail = page.getByTestId("run-detail");
await expect(detail).toBeVisible();
await expect(detail).toContainText("Completed:"); // chunk counts by status
Expand All @@ -49,4 +53,81 @@ test.describe("the run console against a real Goldpath app", () => {
await replayDialog.getByRole("button", { name: "replay-items" }).click();
await expect(page.getByRole("status")).toBeVisible();
});

test("uploads a batch, reads the real validation report, and works the four-eyes gate", async ({ page }) => {
const openBulk = async () => {
await page.goto(`/?base=${encodeURIComponent(service)}`);
await page.getByRole("button", { name: "Bulk intake" }).click();
await expect(page.getByTestId("bulk-panel")).toBeVisible();
};

const upload = async (name: string, csv: string) => {
await page.getByLabel("batch file").setInputFiles({ name, mimeType: "text/csv", buffer: Buffer.from(csv) });
await page.getByRole("button", { name: "upload" }).click();
const dialog = page.getByRole("alertdialog", { name: "confirm upload" });
await expect(dialog).toContainText(`Upload ${name} into payments?`);
await dialog.getByRole("button", { name: "upload" }).click();
await expect(page.getByText(/Received — batch .* is queued for validation/)).toBeVisible();
};

// The newest batch is the first row (the contract orders by receipt, newest first).
const newest = page.getByRole("row", { name: /payments/ }).first();
const waitForState = async (state: string) =>
expect(async () => {
await openBulk();
// Read the state from the batch ROW: the filter's <option> list carries every
// state name too, so an unscoped text match would pass before anything happened.
await expect(newest).toContainText(state, { timeout: 5_000 });
}).toPass({ timeout: 60_000 });

// ── act 1: a file with a structurally broken line
await openBulk();
await upload("payments.csv", "EndToEndId,Amount\nE2E-1,10.00\nE2E-2,20.00\nE2E-3,30.00,stray\n");
await waitForState("Validated");

await newest.getByRole("button").first().click();
const detail = page.getByTestId("batch-detail");
// The report the ENGINE wrote — the broken line, named by its own message.
await expect(detail).toContainText("expected 2 fields");

// The gate refuses to fire without evidence.
await page.getByRole("button", { name: "reject" }).click();
const rejectDialog = page.getByRole("alertdialog", { name: "confirm reject" });
await expect(rejectDialog.getByRole("button", { name: "reject" })).toBeDisabled();
await rejectDialog.getByRole("button", { name: "cancel" }).click();

// And the ENGINE refuses this approval: invalid rows block it. The console shows the
// refusal verbatim — that message is how the operator learns what to do next.
await page.getByRole("button", { name: "approve" }).click();
const blocked = page.getByRole("alertdialog", { name: "confirm approve" });
await expect(blocked).toContainText("audited");
await blocked.getByRole("button", { name: "approve" }).click();
await expect(page.getByText(/1 invalid rows block approval/)).toBeVisible();

// ── act 2: the operator rejects it, with the reason the contract demands
await page.getByRole("button", { name: "reject" }).click();
const rejecting = page.getByRole("alertdialog", { name: "confirm reject" });
await rejecting.getByLabel("reason (required)").fill("line 3 is malformed — resend the export");
await rejecting.getByRole("button", { name: "reject" }).click();
await waitForState("Rejected");

await newest.getByRole("button").first().click();
await expect(detail).toContainText("line 3 is malformed — resend the export");

// ── act 3: the fixed file passes the gate, and the decision becomes a server fact
await openBulk();
await upload("payments-fixed.csv", "EndToEndId,Amount\nE2E-4,10.00\nE2E-5,20.00\n");
await waitForState("Validated");

await newest.getByRole("button").first().click();
await page.getByRole("button", { name: "approve" }).click();
const approving = page.getByRole("alertdialog", { name: "confirm approve" });
await approving.getByLabel("note (optional)").fill("checked against the ledger");
await approving.getByRole("button", { name: "approve" }).click();
await expect(page.getByText("approved", { exact: true })).toBeVisible();

await waitForState("Approved");
await newest.getByRole("button").first().click();
await expect(detail).toContainText("checked against the ledger");
});
});
Loading
Loading