Skip to content

feat(web): shared-topology wizard + SCM role controls (spec 024, plan 5/5 — spec complete) - #1546

Merged
zbigniewsobiecki merged 10 commits into
devfrom
feat/024-5-wizard-config-ui
Aug 25, 2026
Merged

feat(web): shared-topology wizard + SCM role controls (spec 024, plan 5/5 — spec complete)#1546
zbigniewsobiecki merged 10 commits into
devfrom
feat/024-5-wizard-config-ui

Conversation

@zbigniewsobiecki

Copy link
Copy Markdown
Member

Summary

Plan 5 of 5 — the last of spec 024, which this PR marks .done.

Plans 1–4 made shared JIRA keys and shared GitHub repositories route, scope, stamp and validate correctly. But nothing could set a discriminator: buildIntegrationConfig rebuilds the config from wizard state, so a value written out-of-band was wiped by the next save. Sharing a board was configurable only in theory — which is why the README has carried a "do not configure this in production" caveat since plan 2.

That caveat is now gone, and this PR is mostly about earning its removal.

  • JIRA wizard — "Team routing": discriminator kind + value, optional and empty by default. "None" is a legitimate choice, not an unfinished one: a key may have exactly one project without a discriminator, and that project is its default owner.
  • SCM tab — "Repository role": primary/secondary, sent only when the operator touches it.
  • Operator walkthrough for both topologies, replacing the caveat.

⚠️ Three review rounds, four must-fixes — and the first invalidated the premise

cascade-tools commands do not read the database. They rebuild the project from environment variables, and augmentProjectSecrets emitted no routing. So inside every worker config.routing was undefined: stampDiscriminator stamped nothing, discriminatorJqlClause returned ''.

The consequence was the worst kind of silent:

An agent files a friction report or split child. The issue is created with no discriminator. Its next webhook matches no sibling and is handed to the key's default project. One team's work becomes another's, permanently, with no error anywhere.

That was the ninth hand-picked projection of the JIRA config in this spec's territory. Round 2 then found a tenthreportFriction.ts had its own near-clone synthesizer, on the default engine path, with a perverse asymmetry: immediate filing was unstamped, but the sidecar retry re-filed via the DB project and stamped correctly.

Adding a third copy would have guaranteed an eleventh, so both now share src/jira/config-from-env.ts, with a static guard test that fails if any other file reads the CASCADE_JIRA_* vars. The precedent supports this shape: CASCADE_JIRA_AUTH_TYPE landed in all three sites at once when MNG-1736 threaded it; spec 024 managed two of three.

Removing the caveat on top of that would have been actively dangerous — and the operator prose I wrote in the same PR asserted the broken behaviour verbatim.

Two of my own verification failures, stated plainly

I marked an AC done on a premise I never checked. I recorded "renders a backend rejection verbatim" as uncoverable "because the dashboard suites have no DOM harness". They do — project-worker-image.test.ts renders in the same directory, and vitest.config.ts carries a react-query alias dedupe added to make it work.

Then my replacement test gave false assurance on its own headline claim. Mutating the save slot to wrap the message passed all five tests — the component has a second error slot fed by the same mocked hook, and my text query found the untouched one. Scoped by data-testid, the wrap now kills two tests.

Both are worth knowing when weighing this PR's test evidence.

Testing

27 new tests — 15 routing/serialization, 6 SCM payload, 5 SCM render (jsdom), 6 env round-trip including the one-synthesizer guard. 11205 unit passing, typecheck clean in both workspaces, lint at its 13-warning baseline.

The env round-trip drives the real projection both ways — actual augmentProjectSecrets output into actual synthesizeProjectFromEnv — because a hand-built config on either side would only prove that two shapes I wrote agree with each other.

AC #5 is deferred, not done

Its manual protocol needs npm run dev:web against a live dev API and a JIRA project, neither available here. Round 1 found a real UX defect in exactly that surface — clearing the value box unmounted the box you were typing in — which is precisely what the deferred pass would have caught. Treat visual placement as unverified.

Spec 024 closes here

Five plans, thirteen review rounds, thirteen must-fixes. The dominant theme was never the routing logic — that was largely right first time — but a config field surviving the hand-written projections between the database and the code that reads it. Ten instances, two in paths nobody had enumerated.

Not done, and worth a follow-up: docs/decisions/ does not exist in this repo, so the spec's six strategic decisions have nowhere to land. I did not create a ledger unilaterally. docs/BUGS.md also still carries the "moving a project's repo can strand the old one" entry.

🤖 Generated with Claude Code

zbigniewsobiecki and others added 8 commits August 24, 2026 21:15
Plans 1-4 made shared JIRA keys route, scope and stamp correctly, but
nothing could SET a discriminator. buildIntegrationConfig rebuilds the
config from wizard state, so a value written out-of-band was wiped by the
next save — sharing a board was configurable only in theory, which is why
the README has carried a "do not configure this in production" caveat
since plan 2.

The step is optional and empty by default: a project that owns its key
outright leaves it alone and saves a config byte-identical to before this
plan. "None" is a legitimate choice, not an unfinished one — a key may
have exactly one project without a discriminator, and that project is the
key's default owner.

The step mirrors the backend jiraConfigSchema constraints inline (no quote
or backslash; no whitespace in a label) so the operator is told here
rather than by a save-time rejection, and warns that a component must
already exist on the JIRA project because JIRA will not create it.

Placed after status mapping in BOTH the manifest and the wizard step list.
The existing "one step per wizardSpec entry, in the same order" test
caught that I had them disagreeing — the manifest at position 4 and the
wizard at position 6.

Round-trip pinned: set → serialize → hydrate → re-serialize preserves the
discriminator, which is precisely the wipe this closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan 4 made a repository shareable and enforced exactly one primary per
repo at save time; this is how an operator says which they are. Without
it the choice existed only in the API.

The role is omitted from the payload when untouched. That matters
backend-side: an update that does not mention the role preserves it, so
an unrelated save — a base-branch edit, say — leaves a shared
repository's topology alone rather than restating it. It is also omitted
when there is no repository at all, since the backend rejects that pair
and there is no reason to let the UI build a request that cannot succeed.

The payload shape lives in a pure builder rather than being asserted
through a DOM render: whether a field is ABSENT is the load-bearing part
here, and absence is what a rendered assertion sees worst.

The existing error slot already renders the mutation message verbatim, so
plan 4's operator-actionable rejections ("Repository X is already used by
project Y") surface as written rather than as a generic failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "do not configure a shared key in production" warning has ridden along
since plan 2. Its two reasons are now gone: read-scoping shipped in plan
3, and the wizard field it warned about — the one whose absence meant a
discriminator could only be written out-of-band and was then wiped by the
next save — exists as of this plan.

Replaces it with what an operator actually needs: how to pick a
discriminator (label unless you have a reason, since JIRA creates labels
on demand and will not create components), that exactly one project per
key may be left on None, and that exactly one project per repository is
the primary.

Marks AC #5 deferred rather than done — the visual-placement protocol
needs a live dev API and a JIRA project, which this session does not have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review round 1 must-fix, and it invalidated the caveat removal this plan
is built on.

cascade-tools commands do not read the database: CredentialScopedCommand
rebuilds the project from env vars via synthesizeProjectFromEnv, and
augmentProjectSecrets emitted only projectKey, baseUrl, authType and
statuses. So inside the worker `config.routing` was undefined and the
JIRA provider stamped nothing on the work items an agent created and
scoped nothing on the ones it listed.

The consequence is a silent, permanent misroute: a friction report or
split child filed via `cascade-tools pm create-work-item` carried no
discriminator, matched no sibling on its next webhook, and was handed to
the key's DEFAULT project. One team's work quietly became another's. The
mirror break made `list-work-items` return every sibling team's issues.

This is the NINTH hand-picked projection of the JIRA config in this
spec's territory — a hop nobody had enumerated. authType got its env var
when MNG-1736 threaded it; spec 024 had not. The README prose added by
this plan asserted the broken behaviour verbatim.

The round-trip test drives the REAL projection in both directions —
actual augmentProjectSecrets output into actual synthesizeProjectFromEnv
— because a hand-built config on either side would only prove the two
shapes I wrote agree with each other. A malformed value degrades to "no
discriminator" rather than throwing at the head of every invocation.

Also from the review:

- The value input unmounted mid-edit: clearing the box cleared the kind,
  which drove the input's own visibility. Selection is now view state, so
  the box survives being emptied.
- `repoPrimary` vs `effectiveRepoPrimary` were two same-shaped bindings six
  lines apart, one never undefined; substituting one for the other would
  have destroyed the absence invariant with every test still green. Renamed
  to `repoPrimaryChoice` / `displayedRepoPrimary`.
- `SCMTabProject` now declares `repoPrimary` instead of casting it, so a
  renamed field or new upstream projection is a compile error rather than
  a secondary silently displaying "Primary".
- A test named "does not emit a value the backend would reject" asserted
  that the wizard DOES emit one. Renamed to what it proves.
- `describeInvalid` duplicated the backend constraints untested; now pinned
  against the real jiraConfigSchema, and mutation-proved.
- The README claimed two projects on None are rejected at save time. Not
  for grandfathered pre-024 pairs — corrected, and three stale "until plan
  5" parentheticals updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review round 2 found a TENTH hand-picked projection of the JIRA config:
`reportFriction.ts` had its own env→ProjectConfig synthesizer, a near-clone
of the cascade-tools one, hand-picking the same four fields and so never
learning about routing. Friction reports were filed unstamped and routed
to the key's default project on a shared board — and `claude-code` is the
default engine on that path, so this was the default configuration.

Two details make it worse than one more hop. This branch's own README
asserted the opposite in prose. And the failure was asymmetric: if the
immediate filing failed, the sidecar drain re-filed it via the DB project
and stamped correctly, so success was unstamped and retry was stamped.

Adding a third copy would have guaranteed an eleventh, so there is now
one: `src/jira/config-from-env.ts`. A field added there reaches every
worker-side consumer; a field forgotten there is missing from all of them
at once, which is far easier to notice than missing from exactly one. A
static guard fails if any file outside it reads the CASCADE_JIRA_* vars.
The precedent supports this — CASCADE_JIRA_AUTH_TYPE landed in all three
sites at once when MNG-1736 threaded it; spec 024 managed two of three.

Also from the review:

- AC #2 was marked done on a FALSE premise. I recorded "renders a backend
  rejection verbatim" as uncoverable because the dashboard suites have no
  DOM harness. They do — project-worker-image.test.ts renders in this exact
  directory, and vitest.config.ts carries a react-query alias dedupe added
  to make it work. Now covered for real.
- That render test then gave FALSE ASSURANCE on its own headline claim: a
  wrap mutation on the save slot passed, because the component has a second
  error slot fed by the same mocked hook and the text query found the
  untouched one. Scoped by testid; the wrap now kills two tests.
- Lint had regressed 13 → 14: the round-1 fix pushed secretBuilder past the
  cognitive-complexity threshold. Extracted `injectJiraConfig`, matching the
  file's existing inject* shape. Back to baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared board and repository topologies are now configurable by an
operator rather than only by the API, which is what the README's
"do not configure this in production" caveat had been waiting on since
plan 2. That caveat is gone.

Three review rounds, four must-fixes. The first invalidated this plan's
premise outright: cascade-tools rebuilds its project from env vars, and
the discriminator was not among them, so stamping and JQL scoping both
silently no-opped inside every worker. Removing the caveat on top of that
would have been actively dangerous.

Three of the four were verification failures rather than coding ones — an
AC claimed on a premise I never checked, and a render test that passed a
wrap mutation because it matched by text and found a second, untouched
error slot.

AC #5 is deferred, not done: its manual protocol needs a live dev API.
Round 1 found a real UX defect in that surface — the value input unmounted
mid-edit — which is precisely what the deferred pass would have caught.

Verified: 11205 unit tests, typecheck clean in both workspaces, lint at
its 13-warning baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… complete

Several CASCADE projects can now share one JIRA project key or one GitHub
repository. Events route to the project that owns them, a scoped project
sees and creates only its own slice, the previously-silent shadowing
configuration is unsaveable, and both are configurable in the dashboard.

Five plans, thirteen review rounds, thirteen must-fixes. The dominant
theme was not routing logic — that was largely right first time — but a
JIRA config field surviving the projections between the database and the
code that reads it. This spec hit that class TEN times, twice in code
paths nobody had enumerated. It ended by collapsing the two worker-side
synthesizers into one with a static guard, which is the only fix that
addresses the pattern rather than an instance.

CLAUDE.md untouched by this spec — nothing to audit.

Decision records not written: docs/decisions/ does not exist in this repo,
so extraction would mean establishing a ledger. Proposed to the operator
rather than created unilaterally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.15385% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/jira/config-from-env.ts 78.57% 7 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

APPROVE — plan 5/5 correctly closes spec 024. The root-cause fix (one env→config synthesizer in src/jira/config-from-env.ts, with routing threaded through secretBuilder) is sound and verified by a real bidirectional round-trip; the wizard state/serialization/hydration and the SCM role control round-trip correctly against the actual backend contracts.

What I verified

  • The env hop is a real projection, not shape-agreement. injectJiraConfig emits CASCADE_JIRA_ROUTING only when set; buildJiraConfigFromEnv parses it defensively (degrades to no-discriminator on malformed JSON, so it can't take a cascade-tools invocation down). The round-trip test drives the actual augmentProjectSecretssynthesizeProjectFromEnv, and the one-synthesizer guard holds — grep confirms the four guarded vars are read only in config-from-env.ts.
  • The baseUrl precedence flip (|| vs ??) is unreachablesecretBuilder sets JIRA_BASE_URL and CASCADE_JIRA_BASE_URL to the same value, so no worker can observe a difference. Correctly claimed.
  • SCM role wiring is complete. projects.update accepts repoPrimary; getProjectFull does a full select() so getById returns it; false survives ?? true (only null/undefined defaults to Primary). buildScmSavePayload omits the role when untouched or repo-less, matching resolveRepoPrimary + assertRepoPrimaryHasRepo.
  • UI validation mirrors the backend. describeInvalid agrees with jiraConfigSchema (quote/backslash always, whitespace for labels), and the test cross-checks against the real schema rather than hardcoded strings — so a schema tightening can't silently desync the UI.
  • Manifest wizardSpec and jiraProviderWizard.steps agree by id and order; the caveat removal in the README is justified now that the field exists; no active doc links point at the renamed .md.done files.

The self-corrected verification failures (AC #2 false-premise, the wrap-mutation that passed by finding a second error slot) are handled well — the data-testid-scoped assertion is the right fix.

Non-blocking

One observation on the routing step's selectedKind mitigation (inline). It closes the "value box unmounts mid-edit" defect for the create flow but not the edit flow. Disclosed under the deferred AC #5, so not a blocker.

🕵️ claude-code · claude-opus-4-8 · run details

function JiraRoutingAdapter({ state, dispatch }: ProviderWizardStepProps): ReactElement {
// `selectedKind` is view state, not config: it keeps the value input mounted
// while the box is empty, which the reducer treats as "no discriminator".
const [selectedKind, setSelectedKind] = useState<JiraRoutingKind | undefined>(undefined);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, edit-flow only. selectedKind initializes to undefined, so shownKind = selectedKind ?? routingKind. Fresh project: fine — picking a kind from the dropdown seeds selectedKind, so clearing the value keeps the input mounted. Existing project: the saved discriminator is hydrated into routingKind (e.g. label) and the operator never touches the dropdown, so selectedKind stays undefined. Clearing the value box dispatches SET_JIRA_ROUTING_DISCRIMINATOR(label, empty); the reducer clears both fields, routingKind becomes empty, and shownKind falls back to empty → the input unmounts mid-edit. That is the exact round-1 defect, only closed for the create path. Seeding from the hydrated kind, e.g. useState(() => state.jiraRoutingKind || undefined), would cover edit too. Consistent with your deferral of AC #5, so flagging as non-blocking.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. JiraRoutingAdapter now seeds selectedKind from the hydrated jiraRoutingKind via a mount effect, so clearing a saved value on the edit path keeps the value input mounted instead of dropping it (and the kind dropdown no longer snaps back to None).

I used a useEffect rather than useState(() => state.jiraRoutingKind || undefined): buildEditState reaches state through a deferred INIT_EDIT dispatch, so a useState initializer would read the pre-hydration '' and still miss it — the exact async caveat you raised on the sibling thread. The effect self-guards on selectedKind === undefined, so it never overrides an explicit dropdown choice (including None).

Added tests/unit/web/jira-routing-adapter-render.test.ts — a jsdom regression that drives the real reducer with a post-mount hydration dispatch and asserts the input survives a clear; verified failing against the pre-fix code before restoring the fix.

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Solid, well-tested completion of spec 024. Verdict: COMMENT — no correctness/security/data issues; one non-blocking UX gap worth the author's attention for the deferred AC #5 pass.

Verified end-to-end:

  • Routing thread: secretBuilder.injectJiraConfig emits CASCADE_JIRA_ROUTINGbuildJiraConfigFromEnv rebuilds it → JiraPMProvider reads this.config.routing?.discriminator for both stampDiscriminator and discriminatorJqlClause. Shapes match.
  • SCM repo-role: getProjectFull selects all columns (incl. repoPrimary) → serializeProjectgetById, so the tab receives the stored value; projects.update accepts repoPrimary; buildScmSavePayload omits it when untouched or repo-less (matching assertRepoPrimaryHasRepo).
  • Wizard wiring: manifest wizardSpec.steps and jiraProviderWizard.steps align 1:1 by id/order, so jira-routing (index 3) renders JiraRoutingAdapter via ManifestProviderWizardSection.
  • One-synthesizer guard is genuine: only src/jira/config-from-env.ts reads the four CASCADE_JIRA_* vars in src/.
  • UI validation: describeInvalid faithfully mirrors jiraConfigSchema (quote/backslash for both kinds; whitespace for labels).

Code Issues

Should Fix (non-blocking)

  • web/src/components/projects/pm-providers/jira/wizard.ts:328 — the selectedKind unmount-prevention has a residual gap on the edit path (same defect class Round 1 fixed for fresh-add). Details inline.

Minor

  • src/api/routers/projects.ts:502-503 — the retained comment "the SCM tab … cannot yet send a role (that toggle is plan 5)" is now stale: this PR is plan 5 and ships the toggle. The requested === undefined branch is still correct, but its rationale is now "the tab omits the role when the operator hasn't touched it," not "cannot yet send a role."
  • The acknowledged follow-ups in the PR description (no docs/decisions/ ledger to land the six strategic decisions; stale docs/BUGS.md repo-strand entry) are reasonable to defer.

🕵️ claude-code · claude-opus-4-8 · run details

function JiraRoutingAdapter({ state, dispatch }: ProviderWizardStepProps): ReactElement {
// `selectedKind` is view state, not config: it keeps the value input mounted
// while the box is empty, which the reducer treats as "no discriminator".
const [selectedKind, setSelectedKind] = useState<JiraRoutingKind | undefined>(undefined);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selectedKind view-state prevents the value input from unmounting mid-edit on the fresh-add path (picking a kind from the dropdown sets selectedKind, so shownKind = selectedKind ?? routingKind stays non-empty even when the reducer clears the value). But it's still reachable on the edit path — the exact defect Round 1 fixed:

  1. buildEditState hydrates jiraRoutingKind='component'; selectedKind stays undefined.
  2. shownKind = undefined ?? 'component' = 'component' → value input shown with the saved value.
  3. Operator backspaces the value to empty → SET_JIRA_ROUTING_DISCRIMINATOR with value='' clears jiraRoutingKind to ''shownKind = undefined ?? '' = '' → the <Input> unmounts and the dropdown snaps to None mid-edit.

So an operator clearing a saved discriminator to retype it loses the box and must re-pick the kind. Consider seeding selectedKind from the hydrated routingKind when the step mounts with a discriminator already set. Note useState(state.jiraRoutingKind) alone won't fix it cleanly: '' is non-nullish (it would defeat ?? routingKind) and hydration is async, so a mount effect — or a visibility rule that also keeps the input while a value was recently non-empty — is safer. Non-blocking, but worth catching in the deferred AC #5 manual pass since that's precisely the surface Round 1's UX defect lived on.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and done the way you recommended — a mount effect, not a useState initializer.

JiraRoutingAdapter seeds selectedKind from the hydrated jiraRoutingKind once (guarded on selectedKind === undefined). After buildEditState's deferred INIT_EDIT dispatch, backspacing a saved discriminator now keeps the input mounted, while an explicit None still hides it. As you noted, useState(state.jiraRoutingKind) alone wouldn't do it — '' is non-nullish and hydration is async, so the initializer reads '' and would still unmount.

Regression: tests/unit/web/jira-routing-adapter-render.test.ts renders the real step under jsdom, simulates the deferred hydration, and pins both cases (clear keeps the box; None hides it).

Cascade Bot and others added 2 commits August 24, 2026 20:52
…e input mounted

Addresses review feedback on PR #1546.

- JIRA Team routing step: the `selectedKind` unmount-prevention only covered the
  fresh-add path. On edit, `buildEditState` hydrates the saved discriminator via
  a deferred INIT_EDIT dispatch and the operator never touches the dropdown, so
  `selectedKind` stayed `undefined` — clearing a saved value then dropped
  `jiraRoutingKind` to '' and unmounted the input mid-edit (the Round 1 defect,
  still reachable on the edit path). Seed `selectedKind` from the hydrated kind
  via a mount effect; a `useState` initializer would read the pre-hydration ''
  and miss it. Adds a jsdom regression test that proves the fix under the real
  async-hydration timing (and fails against the naive initializer approach).
- projects.ts: refresh the now-stale `resolveRepoPrimary` comment — plan 5 ships
  the role toggle, so the rationale is "the tab omits the role when untouched,"
  not "cannot yet send a role."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`resolveJiraBaseUrlFromEnv` reads the non-prefixed `JIRA_BASE_URL` before
`CASCADE_JIRA_BASE_URL`, but the round-trip helper only propagates the emitted
`CASCADE_JIRA*` secrets into the test env. When the shell already exports
`JIRA_BASE_URL` (as some worker/dev environments do) it leaked past the
`beforeEach` reset and won over the emitted value, failing the "leaves the other
JIRA fields intact" assertion. Unset in CI, so it only bit locally / at pre-push.
Clear it alongside the other JIRA vars, consistent with the existing stubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

APPROVE — spec 024's final plan lands the JIRA routing-discriminator wizard field and the SCM repository-role control correctly, and the load-bearing fix (threading routing through the worker env) is verified end-to-end, not just at the shape level.

What I verified

Core fix, runtime-traced. injectJiraConfig now emits CASCADE_JIRA_ROUTINGbuildJiraConfigFromEnv / parseJiraRoutingFromEnv rebuild it → getJiraConfig returns the whole object (no re-projection) into new JiraPMProvider(jiraConfig)discriminatorJqlClause / stampDiscriminator read config.routing?.discriminator. The { discriminator: { kind, value } } shape matches at every hop (config-from-env.ts, pm/config.ts, pm/jira/adapter.ts). jira-routing-env-roundtrip.test.ts drives the real augmentProjectSecretssynthesizeProjectFromEnv, so it proves the projection, not two hand-built shapes agreeing.

Consolidation is a genuine improvement. Collapsing the cascade-tools and friction synthesizers onto config-from-env.ts plus the static one-reader guard removes the exact drift class that produced the unstamped-friction misroute. The base-URL precedence flip on the friction path is unreachable because injectJiraConfig sets CASCADE_JIRA_BASE_URL and JIRA_BASE_URL to the same value inside every worker.

SCM data flow intact in real usage. repoPrimary flows DB column (repo_primary, NOT NULL default true) → getProjectFull (select()) → serializeProject (...rest) → getByIdprojectQuery.dataSCMTab prop, so the dropdown reflects stored state, not just the mocked prop. buildScmSavePayload omits repoPrimary when untouched or repo-less, matching resolveRepoPrimary's "absent preserves the stored role" contract and assertRepoPrimaryHasRepo.

UI validation agrees with the backend. describeInvalid mirrors jiraConfigSchema's quote/backslash + label-whitespace rules, pinned by it.each against the real schema; the empty value is handled by omitting routing (not by a min(1) divergence). The JQL-injection boundary (AND field = "${value}") is enforced at save time by the schema's ^[^"\\]+$ regex, so a validated config value is always safe by the time it reaches the adapter.

Wizard mechanics. Manifest ↔ provider-wizard step IDs and order align (jira-routing after status-mapping); the reducer's empty-value-clears rule plus the selectedKind view-state correctly keep the value input mounted mid-edit on both the add and the deferred-INIT_EDIT edit path — the jira-routing-adapter-render.test.ts timing reproduces the Round 1 defect.

Non-blocking

  • AC #5 (visual placement of both controls) is deferred/unverified, as the description discloses. Behavior is covered at reducer / payload / jsdom-render level, but pixel placement and usability are not — worth the promised manual pass against npm run dev:web, since Round 1's real defect surfaced in exactly that surface.

CI 7/7 green; 27 new tests; typecheck + lint clean in both workspaces.

🕵️ claude-code · claude-opus-4-8 · run details

@zbigniewsobiecki
zbigniewsobiecki merged commit e907150 into dev Aug 25, 2026
9 checks passed
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.

3 participants