Remove dev-login: Cognito is the only authentication provider - #112
Remove dev-login: Cognito is the only authentication provider#112satvikOS wants to merge 4 commits into
Conversation
DO NOT MERGE until the Cognito pool holds a CONFIRMED identity for every person on the approved roster. The pool holds 1; the roster is 82. Merging this today takes the workspace away from 81 people who have done nothing wrong. The change being ready is not the condition — the pool being populated is. Deletes the passwordless Credentials provider and everything that switched it on: `AUTH_DEV_LOGIN`, `ALLOW_DEV_LOGIN_IN_PRODUCTION`, `DEV_LOGIN_PASSPHRASE`, `src/lib/dev-login.ts`, `dev-login-gate.tf` with its generated passphrase and Secrets Manager entry, the `TF_VAR` in deploy.yml, and the second form on /signin along with every conditional that arranged the two of them. `env.ts` now states the contract the other way round: with one provider, an incomplete Cognito pool is not a degraded sign-in page but a site nobody can log into, so it is fatal at boot rather than a warning, and the message names which of the three variables is unset. The e2e suite keeps its coverage. All 34 specs authenticated through the `dev-login` form; Cognito verifies credentials with an IAM-authorized AdminInitiateAuth call that CI cannot make. The suite now establishes the session directly with `next-auth/jwt`'s own `encode`, the server's AUTH_SECRET and the user id read out of the seeded database — the cookie the server issues, decrypted by the same code, with every authorization decision downstream taken for real. ADR-0015 records that, including the one property it costs. `e2e/signin-cognito.spec.ts` replaces `dev-login-gate.spec.ts`. Governance: register row IDENT-001 → PASS with liveViolation null (its backwards predicate fired on this change, as designed, and is one-shot); ADR-0008 → Accepted, option C, one word and no qualifier; ADR-0015 added; PD-005/PD-006, HANDOFF, RUNBOOK, SESSION-STATE and the backlog re-pinned. The standing guard against the provider returning is now a test, `lib/auth/cognito-is-the-only-provider.test.ts`. Verified: tsc 0, jest 106 suites / 1574 passed, next build 0, Playwright 163/163 against a real Postgres and a production build. Negative controls, each confirmed RED then restored GREEN: re-adding a dev-login provider (2 suites); AUTH_DEV_LOGIN back in ecs.tf; a component reading process.env.AUTH_DEV_LOGIN; ADR-0008 back to Proposed while the register says PASS (3 assertions); the register claiming PASS on a proposed ADR; `Accepted` carrying a qualifier. And the one that matters most — the runner given a secret the server does not know: "there is still a working way in" fails, so the minted session is genuinely validated rather than waved through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
The guards this PR relies on to keep dev-login gone were escapable, and
each escape was demonstrated before it was fixed:
1. Both files found providers with /Credentials\(\{\s*\n\s*id:\s*"..."/,
which only matches when `id` is the FIRST key. Writing `name:` before
`id:` hid a fully working passwordless provider from both guards --
tsc, all 106 suites and 1574 tests stayed green with it installed.
2. `every-provider-is-gated` split the file on `Credentials({`, so the
LAST provider's block ran to end-of-file and absorbed the `callbacks`
section. `signIn` there mentions gateOnEligibility, so the final
provider always read as gated -- and appending is how a provider is
added. An appended provider with no eligibility check passed.
3. The retired-switch scan matched `process.env.AUTH_DEV_LOGIN` but not
`process.env["AUTH_DEV_LOGIN"]`, which is the same read and the same
schema bypass the test's own comment says it covers.
Providers are now bounded by their own closing brace in one shared
parser, ids are read wherever they sit among the keys, a textual count of
`Credentials(` is asserted against the parsed count so an unreadable
declaration fails closed, and the switch scan matches the bracket form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR number arbitration — this PR must renumber to ADR-0019Five open PRs each independently claim
Why numbers cannot simply be assigned per-PR
Merge order is therefore fixed: #101 → #107 → #110 → #112 → #106. Merging out of that order makes the next PR's CI red on the gap check — which is the guard working, not a flake. What this PR needs to change
Rename the file and update every cross-reference: the ADR body, the Do not create a new gap, and do not touch 0005. (Assigned from the collision audit.) |
ADR allocation — CORRECTED. This PR takes ADR-0020My earlier table missed #104, which also adds an
#116 is out of this sequence entirely — it takes no number at all, deferring to #104 is placed second, not last, because it is verified and ready while #106 is blocked on two real defects — a ready PR must not queue behind a stuck one. Verified the hard way: renaming an ADR to 0021 on a branch whose numbers stop at 0014 yields Rename the file and update every cross-reference — the ADR body, the |
* Name the unit Engagement, and give the sign-in page a hierarchy The unit is the Office of Student **Engagement**. `lib/policies.ts` already said so; `lib/tenant/brand.ts` said Experience, and that is the copy the live sign-in page renders. Renamed at all five sites — the registry, its test, the e2e assertion, HANDOFF and ADR-0014, whose text describes what this page shows and would otherwise have become false by standing still. `grep -rn "Student Experience"` now returns nothing. The `Simon-OSE` pill under the heading is gone, and `displaySlug` went with it. A survey of the tree found the pill was its only reader: everything else naming it was the interface, the two registry entries, and two tests asserting it agreed with `slug`. A slug is a key — the thing a hostname, a URL segment and a row are keyed by — and the names a person should read are `institutionName` and `unitName`. The two tests are replaced by their inverse, which fails if the field comes back. The page itself is now two zones. The backdrop answers "whose system is this?" — the institution's mark, the unit, and one line on what it is for. The card answers "how do I get in?" and holds nothing else; it used to open with the Tenure wordmark at 24px and the vendor's tagline, so the first two things a person read on their university's login page were ours and the field they came to fill in was third. Tenure is an eyebrow above the rule now, and "Sign in" is the first line of the card. A slot for the institution's logo, and deliberately no logo. We hold no licence to Simon's lockup, and an unlicensed approximation on the institution's own login page is the worst place to be wrong. `InstitutionMark` renders `public/brand/<slug>.svg|png` when one is supplied and otherwise sets the institution's name as a wordmark — a finished treatment, not a placeholder. public/brand/README.md carries the spec, including the requirement that is easiest to get wrong: the mark sits on the tenant's dark field, so it has to be the reversed lockup. The slug reaches a filesystem path, so it is whitelisted rather than `basename`d — a test plants a real file where a basename implementation would land and proves it is still refused. Cognito is the visual primary, because it is the only real way in. The two forms were identical — same fields, same green button, same word on it — which offered two equal doors and said nothing about which one disappears. The interim block is now a recessed panel with a heading and a secondary button. `CredentialsSignInForm` grew a `tone` rather than a class string because the pending spinner has to be recoloured with the button, and the primary treatment follows whichever door is actually the way in: with no Cognito configured (local, and the e2e job) the pilot form is the only one on the page and takes it back. Dev login is untouched and still rendering. Removing it is PR #112, and it cannot land until Cognito is populated — ~81 people have no account yet and would be locked out. What changed here is its weight, not its presence. The tenant's second brand colour is used once, as a 32px decorative rule, and never for text: measured, Simon's dandelion on Rochester Blue is 7.7:1 but the neutral tenant's own pair is 2.65:1, and the registry cannot promise anything about contrast between two colours a tenant supplies. Also fixes a focus indicator that was a 1px hue shift on a hairline — on a password field, which is where it is needed most. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Label the fenced block in the brand README (MD040) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`main` moved five commits under this branch and two of them landed in the same files. #119 rebuilt /signin as two zones — an institution backdrop and a card — and deliberately KEPT dev-login inside it as a de-emphasised "Pilot access" panel, because removing it was this change's job and doing it there would have locked out the ~81 people with no Cognito account. So the resolution is the new layout with that panel taken out: `devLoginEnabled`, `passphraseRequired`, `devSignIn` and `PILOT_TITLE_ID` are gone, the Cognito form's `: null` becomes the sentence that says why there is no form, and the card is one form and one notice. Four things the merge exposed, each a real defect rather than a textual clash: - The divider test asserted a divider EXISTS. #119's card has no "Or" at all — the SSO notice is a labelled panel, not an alternative — so the test failed on a page that is correct precisely because it draws none. It now asserts the invariant (every separator is guarded by something governing its neighbour), which a page with no separators satisfies, plus a separate row recording that today there are none. - ADR-0015 collided. `main`'s index reserves 0015 BY NAME for the platform exception object and 0016 for another change in the same merge sequence, and `decision-records.test.ts` fails a reserved number that has a file. This change's ADR takes 0019; all 13 references moved with it. - `.env.example` — which RUNBOOK.md calls the file that documents every variable for local setup — still assigned all three retired switches and named no COGNITO_* variable at all, so following the documentation produced an app with no provider and a sign-in page with no form. Neither standing guard could see it: one walks `src` for TypeScript, the other walks `*.tf`. Both now also read `.env.example`, for the dead switches and for the live ones. - RUNBOOK's "while it is still on" procedure told an operator to set DEV_LOGIN_PASSPHRASE as a repository secret, or let Terraform generate one into `tenure-pilot/dev-login`. This change deletes both mechanisms, so read after the merge it was a procedure that appears to work and does nothing. `restricted-registry.test.ts` takes main's SEEDED_AND_SEALED() with this branch's provider name; `app.spec.ts` takes main's "Office of Student Engagement", which retires the last of the four Experience strings the PR body had flagged as out of scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`signin-cognito.spec.ts` asserted `dividers === ssoNotices` — one "Or" for each SSO notice. That was a fact about the old single-column card, not the property being defended, and it failed against #119's card, which introduces the notice with its own heading and draws no separator at all. The direction is what matters: never a divider with less than two things to divide. `dividers <= ssoNotices` keeps the regression it was written for (a separator with one option beside it) and stops asserting a separator has to exist. Caught by running the suite, not by reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolved five conflicts, all of them two changes adding beside each other
rather than disagreeing:
* prisma/schema.prisma — Institution gains all three back-relations;
TenantConfigPack and the seat-metering block are both kept.
* tenancy/registry.ts + registry.test.ts — the pins are MEASURED against
the merged schema, not incremented. grep -c '^model ' = 44, and the
three buckets are 25 / 5 / 14, which sums to 44. #125's new
prose-agreement case is kept and the doc sentence updated with it.
* docs/decisions/README.md — this change takes ADR-0021 per the
allocation on PR #106, and declares 0020 as a reservation because
#112 has not merged yet.
* docs/implementation/global-engine-execution-ledger.md — same measured
counts, with this change's step appended rather than restated.
ADR-0015-tenant-configuration-packs.md is renamed to ADR-0021 and every
cross-reference moved with it: the ADR body, the index table, the backlog,
.env.example, publish-tenant-packs.yml and four source comments.
|
Closing. #140 does this properly, and #112 carries two defects that make it unsafe to land as-is. #112's intent is right and is not being dropped — Two measured reasons this specific branch should not be the one that lands:
Also carried forward to #140: this branch adds One requirement from here that is currently prose and must become a step: sessions are JWT and signature-only, so cookies minted while dev-login was live stay valid after the provider is gone. |
⛔ DO NOT MERGE until Cognito is populated
Cognito holds 1 confirmed identity. The approved roster is 82 people. Merging this today removes the only way 81 of them can sign in. This PR is correct and complete; the condition that gates it is not in this repository.
The exact precondition for merging: every person on the approved Tenant #1 roster has a CONFIRMED identity in Cognito user pool
us-east-1_EBxatAx2Zand can sign in with it. Confirmed, not created — an account left inFORCE_CHANGE_PASSWORDexists and still cannot authenticate, becausesrc/lib/auth/cognito.tsrefuses a Cognito challenge rather than half-handling it. Theops-statusworkflow reports both numbers;apps/web/scripts/provision-cognito-cohort.mjsdoes the provisioning and is a dry run by default.Opened as a draft for that reason. PD-005 already set this order and it has not changed: provision → verify → remove.
One step that is not in the diff and must not be skipped. After merging and deploying, rotate
AUTH_SECRET(Actions → Rotate Auth Secret). Removing the provider stops new dev-login sessions; it does not invalidate ones already issued. Sessions are JWTs validated by signature alone, so anyone holding a cookie minted while dev-login was on stays signed in as whoever they typed, for the life of that token, after the door is closed behind them. Then delete the now-unmanagedtenure-pilot/dev-loginsecret from Secrets Manager.What this removes
dev-loginCredentials provider insrc/lib/auth.tssrc/lib/dev-login.ts+dev-login.test.tsAUTH_DEV_LOGIN,ALLOW_DEV_LOGIN_IN_PRODUCTION,DEV_LOGIN_PASSPHRASE— out of the schema, the task definition and the deploy workflowinfrastructure/terraform/dev-login-gate.tf(generated passphrase, Secrets Manager secret, two outputs), the ARN insecrets.tf, the secret ref inecs.tf/signin, the "Pilot access" divider, and every conditional that arranged two providersThe three Cognito variables are now the whole of authentication.
The contract flipped direction
env.tsused to warn on a half-configured pool, because a missing Cognito field meant one provider was absent from a page that still had another. With one provider that same state is a site nobody can log into, so it is fatal at boot — and the message names which of the three variables is unset, rather than restating the rule.The e2e suite kept its coverage
All 34 Playwright specs authenticated through the
dev-loginform. Cognito verifies credentials with an IAM-authorizedAdminInitiateAuthcall against a real user pool, which CI has no credentials for and a fork PR never will.The suite now establishes the session directly (
e2e/support/auth.ts):next-auth/jwt's ownencode, the server'sAUTH_SECRET, the salt and cookie name @auth/core derives for the deployment's URL, and the user id read out of the seeded database. That is the cookie the server issues, decrypted by the same code; thejwtandsessioncallbacks run and every authorization decision downstream — tenant scope, the(app)entitlement gate, capability resolution — is taken for real against real rows.ADR-0019 records the decision, the three options rejected (a real pool in CI; an endpoint override in production code; a second provider for tests), and the one property it costs:
redirectTo: callbackUrlin the sign-in action is no longer executed end to end, and is asserted structurally instead. The destination behaviour is still exercised in a browser from the other side of the same code —/signinholding a session redirects to the same validatedcallbackUrl, so the off-origin case still has to land on/dashboard.e2e/signin-cognito.spec.tsreplacesdev-login-gate.spec.ts: the form is there, it is the only form, a credential the pool refuses becomes page state rather than a 500 or a blank page, a refusal leaves no session, and an authenticated person reaches the app with the server agreeing who they are. In CI the pool is real code talking to real AWS and being refused — the log showsCognito sign-in unavailable: UnrecognizedClientException, mapped to the same generic refusal a wrong password would get.Governance
IDENT-001-dev-login-in-productionBLOCKED_ARCHITECTUREPASS,liveViolation: null, evidence re-pointed, review date keptProposed (2026-08-17). Records a conflict; decides nothing.Accepted— one word, no qualifier — option C, with what it does not decide split outAcceptedAUTH_DEV_LOGIN=false@tenure.demorows do not go with the providerThe register row was designed to trip, and it did. Its
liveViolationassertedAUTH_DEV_LOGIN=truewas still present in the task definition, so fixing the conflict turns the suite red and forces somebody to close the row. That worked exactly as intended — and it is one-shot. A closed row has no predicate left, so the standing guard is now a test:lib/auth/cognito-is-the-only-provider.test.tsfails if a second provider, any of the three retired switches, or the Secrets Manager entry returns — in the application, in the task definition, in Terraform, or in.env.example.liveViolationisnullrather than inverted toexpect: "absent". The field's documented meaning is "a condition that holds because the conflict is unresolved"; there is no such condition here any more, and writingabsentwould keep the row red-on-fix while claiming the opposite of what the field means.Deliberately still open. §19.1 asks for MFA, recovery and session controls alongside invitation-only authentication and verified email ownership. The pool enforces the first two; MFA is not enabled.
IDENT-001therefore keeps its 2026-09-30 review date instead of being cleared, and ADR-0008 says so under "What this ADR does not decide" rather than qualifying its status.Verification
tsc --noEmit0 ·jest106 suites, 1574 passed ·next build0 · Playwright 163/163 against a real Postgres, a production build and the CI environment.Negative controls — each broken, confirmed RED, restored, confirmed GREEN:
dev-loginCredentials provider toauth.tsAUTH_DEV_LOGINback inecs.tf(app code untouched)process.env.AUTH_DEV_LOGINin a component, bypassing the schemaProposedwhile the register saysPASSPASSwithstate: "proposed"Accepted (code done; cohort provisioning outstanding)The last one is the control that matters: it is the difference between a fixture that proves the app admits an authenticated person and one that proves nothing.
A defect the suite caught and I had missed:
e2e/app.spec.tslocated the sign-in form bygetByRole("region", { name: "Pilot access" })and contained no string my greps matched. Fixed, and it is why the full suite was run rather than the auth specs alone.Out of scope, flagged not fixed
Resolved, not deferred. The four surviving "Office of Student Experience" strings this section used to list are gone: #119 landed the Engagement rename on
main, and the merge below takes main's string ine2e/app.spec.ts. Nothing is outstanding here.Left to its owner:
OKTA_ISSUER/OKTA_CLIENT_ID/OKTA_CLIENT_SECRETare still inapps/web/.env.example. PD-004 retired Okta andsrc/lib/env.tshas noOKTA_*in its schema, so nothing reads them — but PR #123 is already removing the last Okta mentions from that exact file, and deleting them here would collide with it for no gain. They now carry a comment saying they are dead and why they are still there.A defect on
main, not in this PR:apps/web/prisma/migrations/20260820180000_seat_metering_events/migration.sql:14andapps/web/src/lib/billing/seat-meter-boundary.test.ts:131both cite ADR-0015 for the seat-metering boundary. That decision landed as ADR-0018; 0015 is reserved for the platform exception object. Two stale citations, already merged, and not this branch's to rewrite.Adversarial verification (second agent, independent worktree)
Every number in "Verification" above was reproduced from a clean checkout of
7caa9e5(
npm ci, own Postgres, own production build):tsc --noEmitjestnext buildnext start)All seven claimed negative controls were re-run independently and each went RED for the
stated reason, then GREEN on restore — including the decisive one: runner given a secret the server does not
know → "there is still a working way in" fails (
waitForURL(/\/dashboard/)times out, not aconnection error). The constant-time claim on
secretsMatchwas measured, not read: ~1700 nsacross exact-match, same-length-differs-at-byte-0, same-length-differs-at-byte-63 and 1-char
candidates — indistinguishable. Sign-in is non-enumerable in observable output: an address in
the database and one absent from it both produce one identical message, one identical URL.
Three escapes were found in the guards this PR relies on, and are now closed (
c061937)The register row is one-shot, so
cognito-is-the-only-provider.test.tsis the standing guard.It was escapable. Each escape was demonstrated before being fixed:
with
/Credentials\(\{\s*\n\s*id:\s*"..."/, which only matches whenidis the firstkey. Writing
name:beforeid:— a one-line reordering — hid a seconddev-loginproviderthat signed in any seeded address with no password.
tsc0, all 106 suites and 1574tests green, with that backdoor installed.
provider. It split the file on
Credentials({, so the last block ran to end-of-file andabsorbed the
callbackssection;signInthere mentionsgateOnEligibility, so the finalprovider always read as gated. An appended provider with no eligibility check passed.
Appending is how a provider arrives.
process.env["AUTH_DEV_LOGIN"]. It matched only the dottedform. The bracket form is the same read and the same schema bypass the test's own comment says
it covers.
Fixed by bounding each provider at its own closing brace in one shared parser
(
src/lib/auth/provider-blocks.ts), readingidwherever it sits among the keys, asserting atextual count of
Credentials(against the parsed count so an unreadable declaration failsclosed, and matching the bracket form. All three escapes now go RED, plus a fourth control
(
Credentials(someVariable)) which the new count assertion catches. Gate re-run after the fix:tsc0 ·jest106 suites / 1575 passed ·next build0 · Playwright 163/163.Confirmed by test, not by reading: sessions are replayable across sign-out
The
AUTH_SECRETrotation step above is load-bearing, not hygiene. Measured: a session cookiecaptured before sign-out, replayed in a fresh browser context after that user signed out,
returns
200from/api/auth/sessionasdirector@tenure.demoand loads/dashboard. Sign-outclears the cookie in one browser; it does not end the session. So rotating
AUTH_SECRETafterdeploy is the only thing that invalidates sessions minted while dev-login was live. (PR #104,
"Sessions the server can actually end", is the durable fix and is not merged.)
Scope and asset checks
eligibility.ts,csp.ts,[tenant]/page.tsxand
slack/install.tsare comment/docstring-only;secretsMatch's body is byte-identical. ✅data:URIs, zerobase64 payloads in the diff; the only Simon reference in an added line is a test fixture
address. ✅
test counting the existing "Coming soon" SSO notice, and a comment in
env.tsabout rejectingplaceholders. ✅
pool is unreachable, "an address with no account behind it is refused the same way" passes via
the
unavailablebranch rather than by exercising Cognito's user-existence collapsing. Thenon-enumerability is structural —
refuseToSignInalways redirects to?error=1and the pagerenders exactly one message string — so the property holds, but that spec is not what proves it.
🤖 Generated with Claude Code
Independent review (third agent, own worktree, own database)
The gate was reproduced from scratch at
c061937— ownnpm ci, own Postgres, own production build, own port, because port 3000 was another agent's server andreuseExistingServerwould have run the suite against their build without saying so.c061937tsc --noEmitjestnext buildnext start)The decisive control re-run independently: runner given a secret the server does not know → "there is still a working way in" fails on
waitForURL(/\/dashboard/)timing out, not a connection error. The session fixture is genuinely validated by the server.Answering the review's own question — was e2e coverage migrated or deleted? Migrated.
mainhad 34 specs, all 34 importingsupport/auth.ts, whosesubmitSignInhard-assertedgetByRole("region", { name: "Pilot access" }). The branch has 33 (dev-login-gate.spec.tsretired,signin-cognito.spec.tsadded), all 33 still importing it, now viaestablishSession. No spec was silently dropped: the one other list difference,seat-metering.spec.ts, is a filemaingained after the branch point.The branch could not merge, and fixing that surfaced four defects
mergeablewas CONFLICTING.mainhad moved on and #119 rebuilt/signininto two zones — while deliberately keeping dev-login as a de-emphasised "Pilot access" panel, because removing it was this PR's job. The resolution is main's layout with that panel taken out. Everything below was found by doing that merge and running the suite, not by reading the diff.expect(divider).toBeGreaterThan(-1)— Name the unit Engagement, and give the sign-in page a hierarchy #119's card draws no "Or" at all, so the test failed on a page that is correct because it has none. It now asserts the invariant (every separator is guarded by something governing its neighbour), which a page with no separators satisfies, plus a separate row recording that today there are none.dividers === ssoNotices→dividers <= ssoNotices. Caught by running the suite.main's index reserves 0015 by name for the platform exception object (One exception object and one operator worklist — and the ADR-0013 fork answered on a new table #101) and 0016 for another change in the same sequence — and five open PRs each wrote themselves an ADR-0015.decision-records.test.tsfails a reserved number that has a file (verified: 4 assertions RED). This change's ADR is now ADR-0019; all 13 references moved with it..env.examplewas missed entirely. RUNBOOK calls it the file that "documents every variable for local setup", and it still assigned all three retired switches and named noCOGNITO_*variable — so following the documentation produced an app with no provider at all and a sign-in page with no form. Neither standing guard could see it: one walkssrcfor TypeScript, the other walks*.tf. Both now also read.env.example, for the dead switches and for the live ones.Also fixed: RUNBOOK's "while it is still on" procedure told an operator to set
DEV_LOGIN_PASSPHRASEas a repository secret or let Terraform generate one intotenure-pilot/dev-login— both mechanisms are deleted by this PR, so read after the merge it was a procedure that appears to work and does nothing.Gate on the merged tree
tsc0 ·jest122 suites / 1873 passed, 1 skipped ·next build0 · Playwright 168/168.Negative controls — each broken, confirmed RED, restored, confirmed GREEN:
AUTH_DEV_LOGIN=trueback in.env.exampleCOGNITO_USER_POOL_IDremoved from.env.examplename:beforeid:, no gateConfirmed, and unchanged by the merge
AUTH_SECRETrotation is in the plan and was not erased with the thing it warns about: RUNBOOK keeps it as step 3 of 4, therotate-auth-secret.ymlworkflow is real (openssl rand,put-secret-value,ecs update-service), and the first paragraph of this body repeats it.dev-login/devLogin/AUTH_DEV_LOGINreference survives insrc,e2e,infrastructureor.githubexcept deliberate history comments and the negative tests that assert the switches no longer do anything. Noterraform planhard error: nothing referencesdev-login-gate.tf's resources.Acceptedwith the sequencing intact · ADR-0008Accepted, one word, no qualifier · IDENT-001PASSwithliveViolation: nulland a written rationale for not inverting it — the backwards predicate was tripped on purpose and closed, and the standing guard is now a test. 38 governance assertions pass.