Skip to content

Nightly sync: main -> impl (2026-09-10) - #581

Open
jsos3-cms wants to merge 29 commits into
implfrom
automation/sync-main-to-impl
Open

jsos3-cms wants to merge 29 commits into
implfrom
automation/sync-main-to-impl

Conversation

@jsos3-cms

Copy link
Copy Markdown
Contributor

Automated nightly sync of main into impl — main wins every overlap.

This branch is impl with main merged in using -X theirs, so the result is deterministic: main's version wins any conflict, and impl-only files that don't conflict are preserved. There should be nothing to hand-resolve.

Squash-merge this PR — impl is squash-only by policy (linear history). Because main and impl share no squash ancestry the diff is computed against a stale base, but -X theirs makes it deterministic and the content settles (a no-change night opens no PR). Unlike ztmf-ui (which merges as a merge commit), this stays in content-sync rather than closing the ancestry gap.

  • Review the net change, then squash-merge. Merging pushes to impl and triggers the impl deploy (orchestration-impl: backend image build + terraform apply).
  • To smoke-test in the impl environment first, run Manual Deploy to IMPL with --ref automation/sync-main-to-impl.

Opened by the nightly-impl-sync workflow.

jsos3-cms and others added 29 commits July 30, 2026 15:27
This sets the login/OIDC alarm destination to the shared ISPG Zero Trust
inbox in both dev and prod. Prod had no destination at all, so the SNS
subscription was count-gated to zero subscribers and the login auth
alarms had nowhere to go. Dev was pointed at a personal address that was
only ever meant to stand up alerting during the Entra pilot.

An email subscription requires a one-time manual confirmation click
after apply. Because dev's address is changing, Terraform will replace
that subscription too, so both dev and prod will send a fresh
confirmation email that someone monitoring the shared inbox needs to
click.

## Test plan

- [ ] `terraform fmt -check` passes in `infrastructure/`
- [ ] After apply, `aws sns list-subscriptions-by-topic` for the dev
topic shows the `ISPGZeroTrust@cms.hhs.gov` endpoint with a
`SubscriptionArn` that is not `PendingConfirmation`
- [ ] After apply, `aws sns list-subscriptions-by-topic` for the prod
topic shows the same endpoint with a confirmed `SubscriptionArn`

## Blockers

This is one of two prerequisites for merging #495 (enable Entra dual-IdP
in prod), so that monitoring exists and is confirmed before the flag
flips. Refs #495
The session cookie now omits the `Domain` attribute, so the browser
scopes it to the exact host that issued it. Both cookie setters
previously copied the configured cookie domain onto `Domain`, and per
RFC 6265 an explicit `Domain` scopes a cookie to that domain and to
every subdomain beneath it. The prod hostname is the bare apex, so a
prod session would be delivered to the dev and impl hostnames as well,
and to any subdomain added later. Omitting the attribute is what
produces host-only scoping, which keeps the credential confined to the
environment that minted it.

Go's `net/http` gives us exactly that with no extra work:
`Cookie.String()`, which `http.SetCookie` calls, writes `; Domain=` only
when the field is non-empty, so an unset `Domain` is absent from the
serialized `Set-Cookie` header rather than emitted empty.

`sameOrigin` in `backend/cmd/api/internal/auth/middleware.go` is
deliberately untouched, and that is the part worth checking. It reads
the same `Auth.CookieDomain` value as the expected `Origin` host and
falls back to the request `Host` when the field is empty. Behind
CloudFront with a VPC origin to the internal ALB, the `Host` the
container observes is not necessarily the public hostname, so clearing
or renaming the field could start failing the Origin check on every
state-changing request. This change therefore leaves the config field,
its env binding and the Origin-check path exactly as they were, and only
stops putting the value on the cookie. Splitting the overloaded field
into a separate cookie-domain setting plus an Origin-check setting named
for its actual purpose is a deliberate follow-up, still outstanding.

Both setters change in the same commit because they are coupled. A
browser only removes a cookie when the expiring cookie's `Domain`
matches the one it was stored with, so dropping `Domain` from
`SetSessionCookie` alone would leave `ClearSessionCookie` unable to
clear the session. That failure mode passes a casual test, since login
and normal browsing look fine and only logout silently stops working.

## Test plan

- [x] `TestSetSessionCookie_Attributes` asserts the issued cookie
carries no `Domain`, so a future change cannot silently re-widen it. The
test sets `Auth.CookieDomain` to a non-empty value first, because it is
empty in the test environment and the assertion would otherwise pass no
matter what the setter does.
- [x] `TestClearSessionCookie_Attributes` asserts the logout cookie is
emptied, expired, and also carries no `Domain`, covering the
coupled-setter trap.
- [x] Both new assertions were confirmed to fail when `Domain` is
reintroduced, so they are load-bearing rather than vacuous.
- [x] `go build ./...` clean.
- [x] `go vet ./...` clean.
- [x] `go test -short ./...` fully green across all 19 packages.
- [x] `go test ./...` green except the pre-existing integration tests,
which need a live database and fail the same way on `main` without one.
`cmd/api/internal/auth` passes.
- [x] `gofmt` clean on both changed files. The repo carries no
golangci-lint config; the only lint gates are terraform fmt and tflint
for infrastructure and the Redocly OpenAPI lint, none of which this
change touches.
- [ ] Manual check that no unit test can cover: sign in to prod first,
then to a lower environment, in one browser profile, and confirm the
lower environment works. That ordering is the one that breaks while the
cookie is apex-scoped.

## Deploy note

A session issued before this change is a distinct cookie from the
host-only one, so the new expiry does not delete it. Only dev is in that
state, because `entra_enabled` gates the variable and it is true only in
dev, where the configured value is dev's own leaf hostname. The effect
there is bounded: `r.Cookie` returns the older of two same-named
cookies, so a dev browser holding a pre-deploy cookie keeps presenting
it and logout does not end that session until the 3 hour `MaxAge`
elapses. It self-heals, and clearing site cookies ends it immediately.
No prod session is involved, since prod sets no cookie domain today and
so holds nothing domain-scoped. Emitting an extra legacy domain-scoped
expiry would close that window, but it would put an apex `Domain` back
on the wire for every prod logout once #495 lands, and leave a dead
dependency on a field the planned split is going to rename, so this
leaves it out. Worth a cookie clear if a dev logout looks sticky just
after this deploys.

## Blockers

This is one of two prerequisites for merging #495, which enables Entra
dual-IdP in prod. Merging #495 is the deploy that applies the flag and
injects the cookie domain into the API task, so merging it is what turns
the over-scoping on. Please do not apply `entra_enabled = true` in prod
until this is in.

The second prerequisite is the config-field split described above. That
work is outstanding, so this PR does not close the tracking ticket.

Refs #495
Refs CMS-Enterprise/ztmf-misc#257
`config.Auth.CookieDomain`, bound to `AUTH_COOKIE_DOMAIN`, used to do
two unrelated jobs. It supplied the session cookie's `Domain` attribute,
and it is the host `sameOrigin` expects to see in the `Origin` (then
`Referer`) header of a state-changing request. #497 removed `Domain`
from both `SetSessionCookie` and `ClearSessionCookie`, so no code path
sets a cookie domain any more and the same-origin check is the field's
only remaining consumer.

That leaves the name describing a job the value no longer does, which is
a trap of its own. Someone setting a variable called
`AUTH_COOKIE_DOMAIN` would reasonably expect it to scope the session
cookie, and it does not. This renames the field to `OriginHost` and the
environment variable to `AUTH_ORIGIN_HOST`, and rewrites the doc comment
to describe the same-origin check it actually feeds, including the
fallback to the request `Host` when the value is empty.

This is a pure rename. `sameOrigin` reads the same value, compares it
the same way, and falls back the same way. Terraform still injects
`local.domain_name`, only under the new name, because the public
hostname is exactly what a browser puts in `Origin` and therefore
exactly what the check should expect.

#497 described the follow-up as splitting the field into a cookie-domain
setting plus an Origin-check setting named for its purpose. This does
the rename half and deliberately skips the other half. After #497 there
is no code path that reads a cookie domain, so a new
`AUTH_SESSION_COOKIE_DOMAIN` would be either dead configuration or, the
moment something started reading it, a re-armed version of the
over-scoping #497 removed. Host-only scoping is the behaviour we want in
every environment, and the way to get it is to have no field for it at
all. Renaming the surviving field is what actually closes the root
cause, since the root cause was one name standing for two things.

#497 left the field alone specifically because clearing or renaming it
could start failing the Origin check behind CloudFront, and that is the
part of this change worth checking rather than the rename itself. It is
settled below.

## Deploy atomicity

An environment variable rename couples the task-definition change to the
image rollout. If Terraform injected `AUTH_ORIGIN_HOST` while the
running image still read `AUTH_COOKIE_DOMAIN`, `sameOrigin` would
silently fall back to `r.Host` for the duration of the gap, and behind
CloudFront that is not guaranteed to be the public hostname. The two
land together, in the same ECS task definition revision, so no such gap
exists.

`.github/workflows/orchestration-prod.yml` runs `analysis`, then
`backend`, then `infrastructure`, and the `infrastructure` job declares
`needs: [analysis, backend]`, so `terraform apply` cannot start until
the backend job has succeeded. `.github/workflows/backend.yml` pushes
the image tagged with the short commit SHA, then writes that same SHA to
the image-pointer SSM parameter with `aws ssm put-parameter
--overwrite`, and closes with the comment "Deployment of the latest
image will happen with terraform apply during infrastructure deploy".
`.github/workflows/infrastructure.yml` then checks out the merge commit
and runs `terraform apply`, which reads that pointer through `data
"aws_ssm_parameter" "ztmf_api_tag"` in `infrastructure/data.tf` and so
sees the tag the backend job just wrote.

The two changes are attributes of one resource. `infrastructure/ecs.tf`
sets `aws_ecs_task_definition.ztmf_api`'s `image` from that data source,
and builds the container's `environment` as `concat([...],
local.entra_api_env)`, which is where the renamed variable lives. A
single apply produces a single new revision carrying both the new image
and the new variable name, so there is no revision in which the new
variable name meets an image that reads the old one, and none in which
the new image meets the old variable name. `orchestration-dev.yml` gates
its backend job on a backend diff and this PR changes Go files, so the
dev deploy builds and rolls out a new image the same way.

A bare rename is therefore safe and no back-compat fallback is needed.
As a secondary point, the `/api/*` CloudFront behaviour forwards
`headers = ["*"]`, so even the fallback path would most likely resolve
to the viewer's host, but naming the expected host explicitly is still
right, because the check should not depend on header-forwarding
configuration staying as it is.

## Note on the test setup that goes away

Two call sites in `session_test.go` set `CookieDomain` to a non-empty
value before asserting the cookie stayed host-only, because the field
used to be the one thing that could have leaked a `Domain`, and #497
needed that setup to keep the assertion from passing vacuously. No
configuration can feed the cookie's `Domain` now, so the setup has
nothing to prove and it is removed. The host-only assertions stay as the
guard against reintroducing a `Domain`. They are weaker than they were,
in that they can no longer be driven by a config value, and there is no
way to keep that strength without keeping a cookie-domain field, which
is the thing this change is removing the last trace of.

## Test plan

- [x] `go build ./...` clean
- [x] `go vet ./...` clean
- [x] `go test -short ./...` green, 577 tests across 19 packages
- [x] `TestSameOrigin` passes all six subtests unchanged, confirming the
fallback-to-`Host` path behaves the same
- [x] `TestSetSessionCookie_Attributes` and
`TestClearSessionCookie_Attributes` still assert the cookie is issued
host-only
- [x] `terraform fmt -check -recursive` clean in `infrastructure/`
- [x] `gofmt` clean on the lines this change touches. `middleware.go`
carries one pre-existing alignment nit on an unrelated const block that
`gofmt` also reports against `main`, left alone to keep this diff to the
rename
- [x] `git grep -n 'CookieDomain\|AUTH_COOKIE_DOMAIN'` returns nothing
- [ ] After the rollout, confirm a state-changing request from the
browser still succeeds, which is what exercises `sameOrigin` against the
injected `AUTH_ORIGIN_HOST`

Refs CMS-Enterprise/ztmf-misc#257. This completes that ticket's code
cleanup. Its remaining post-rollout verification stays open.
)

Flips `entra_enabled = true` for prod. Same one-line change as #350 did
for dev, same file shape, different tfvars.

## Why now

Needed to kick off the **2026 data call**. HHS/OpDiv participants
authenticate via Entra — `users.identity_provider` is `okta` for CMS and
`entra` for HHS/OpDivs (migration 0030). With the flag false in prod,
the `/login/entra*` listener rule doesn't exist and
`/api/v1/auth/lookup` falls through to the OIDC-gated `/api/*` rule, so
those users have no login path at all. Every non-CMS participant is
blocked until this flips.

## Scope

One line plus its comment, `infrastructure/tfvars/prod.tfvars`. No code,
no migrations. `dev.tfvars` is untouched (already `true` on main).

## What dev has proven

Dev has run dual-IdP for ~6 weeks. Nothing Entra-related has landed
since #408 on 2026-07-09.

Prod also gets the flip in better shape than dev did — dev was flipped
bare and shook out five follow-ups afterward, all of which are already
on main here:

- #351 — CloudFront `/login/*` behavior to the ALB origin (Entra login
was AccessDenied without it)
- #354 — Okta JWT `kid` validation + key-fetch hardening
- #352 — HHS-wide admins can set `identity_provider` on user create
- #377 — ALB access logs, ELBAuth alarms, backend 401 instrumentation
- #408 — logout clearing app session + both ALB OIDC cookies

## Pre-merge blockers

These are prod-account operational state, not code. Not verifiable from
the repo, so they need confirming before merge. Status and references
updated 2026-07-30.

- [x] **`ztmf_session_signing_key` seeded in prod**
(`scripts/bootstrap-entra-secrets.sh`). An empty key fails session
minting closed (`secrets.tf:37`). Note this breaks **both** IdPs, not
just Entra — post-flip, Okta also routes through `SessionHandler` to
mint the app session cookie. An unseeded key means nobody logs into
prod.
- **Still open.** The container exists but there is no evidence a value
was written. The 2026-06-15 15:03:20Z timestamp on both secrets falls
inside #341's merge-triggered PROD `terraform apply` (15:03:03Z to
15:05:50Z), so it records container creation, not seeding. No workflow
invokes the bootstrap script, so CI cannot have written them. Settling
this needs `LastChangedDate` against `CreatedDate`, or CloudTrail
`PutSecretValue`, neither of which is reachable from a human credential.
The script generates this key itself, so no external input is required
to close it.
- [x] **`ztmf_entra_oidc` seeded in prod.** Terraform reads this one at
plan time (`data.tf:74`), so an unseeded secret fails the apply rather
than breaking at runtime.
- **Still open**, same evidence as above. Closing it needs tenant id,
client id and client secret from the Entra tenant owners.
- [ ] **Entra app registration has the prod redirect URI** and prod
users are **assigned to the app**. Flagging specifically because of the
AADSTS50105 (user-not-assigned) hit on dev on 2026-06-30 — and per the
scope note in `monitoring-login-auth.tf:12-16`, the ELBAuth alarms
*cannot* catch that class. Entra rejects it before any callback, so no
datapoint is ever emitted. It's the most likely first failure on a fresh
prod tenant and it will be silent.
- **Still open, and the only item that is not ours to close.** The URI
needed is `https://ztmf.cms.gov/oauth2/idpresponse`. Also worth settling
in the same conversation: whether prod gets its own app registration
rather than sharing dev's, since sharing means one client secret spans
both environments and stops `AUTH_ENTRA_AUDIENCE` distinguishing them.
- [x] **Decide login alarm routing for prod.**
`alarm_notification_email` is unset in `prod.tfvars`, and
`monitoring-login-auth.tf:31` count-gates the SNS subscription on a
non-empty string — so prod gets the topic and the alarms with **zero
subscribers**. Dev's `jono@aquia.us` is flagged in its own comment as an
interim individual address to be replaced before prod. Deliberately left
out of this PR since it needs a real destination decision (shared inbox
or Slack webhook); happy to fold it in here or take it as a follow-up.
- **Done, #496.** Both dev and prod now route to
`ISPGZeroTrust@cms.hhs.gov`, which also de-personalises dev. Applied,
and both SNS subscriptions confirmed rather than left pending.

### Found during review and fixed ahead of this PR

- **#497** — the session cookie is now host-only. `AUTH_COOKIE_DOMAIN`
resolved to the bare apex in prod, so this flip would have handed a live
prod session cookie to the dev and impl hostnames. Merged and deployed
ahead of this PR, so the condition is prevented rather than remediated.
Detail in CMS-Enterprise/ztmf-misc#257 (closed).
- **#498** — `AUTH_COOKIE_DOMAIN` renamed to `AUTH_ORIGIN_HOST`, since
after #497 it only feeds the same-origin check.

Tracked separately, neither gating this PR: CMS-Enterprise/ztmf-misc#258
(cross-environment cookie check, blocked *on* this PR) and
CMS-Enterprise/ztmf-misc#259 (session tokens are unbound bearer
credentials with no revocation path).

## Deploy behavior

Nothing applies while this is open, draft or not.
`orchestration-prod.yml` triggers only on `pull_request: closed` +
merged. `orchestration-dev.yml` does run on the open PR, but it applies
with `dev.tfvars`, so it can't exercise this change — there's no
pre-merge plan output for prod. **Merging is the deploy.**

The flag swap is destroy-and-create in a single apply: the two Okta
rules (priorities 1-2) are destroyed and the four dual-IdP rules
created, so priorities never collide. `moved` blocks in
`alb-internal.tf:127-135` keep the un-indexed addresses from churning.

Worth having someone on a prod Okta login the moment it applies —
priority 4 takes the ALB out of the `/api/*` auth path and hands the
gate to the backend session cookie, which is the highest-consequence
part of the change.

Refs #341, #350
Closes the P0 in #500 for the FY2026 cycle. **Temporary by design — this
block is deleted once the FY2026 data call exists in prod.**

## Problem

`copyPreviousScores` resolves **one** predecessor cycle globally, for
every system. That was correct while ZTMF ran a single annual cadence.
It is not correct now that two independent cycles cover FY2025:

| Cycle | Owner | Deadline |
|---|---|---|
| `FY2025 Q3` | CMS's own hand-entered call | 2025-05-07 |
| `FY25 ZTM` | HHS import | 2025-09-30 |

`findPreviousDataCall` orders by `deadline DESC` (the #448 fix —
correct, and kept). So `FY25 ZTM` wins on its later September deadline
and **every CMS system inherits HHS's imported view of itself** instead
of the answers its ISSOs submitted. Reproduced on a prod snapshot in
#500: the new cycle took all 38,160 rows from `FY25 ZTM` and ignored
`FY2025 Q3` entirely.

## Approach

A system's OpDiv selects exactly **one** source cycle; the other is not
read at all.

- CMS systems (`opdivs.code = 'CMS'`) → `FY2025 Q3`
- every other OpDiv → `FY25 ZTM`

Exclusion, not preference. The two cycles are separate lineages, not one
record with gaps — CMS ran its own call, and the HHS ZTM cycles were
loaded independently with their systems only arriving in ZTMF recently.
Where they overlap (the import carries its own view of some CMS
systems), the CMS entry is the record and the imported view is not
authoritative. That overlap is exactly what made the global predecessor
wrong. Confirmed with the data owner: there is no case where a CMS
system should pull from the HHS cycle.

Routing is by `fismasystems.opdiv_id`, which is `NOT NULL` with an FK to
`opdivs` (migrations 0027/0029), so every system resolves and none can
be routed by accident.

## Gating

Two independent conditions, so the override cannot leak onto any other
data call before it is removed:

1. **Name** — target starts with `FY2026` or `FY26`. Both year forms are
accepted deliberately: FY2026 is a single unified cycle covering all of
HHS including CMS, its final name is not fixed, and the prior
conventions disagree (`FY2025 Q3` four-digit vs `FY25 ZTM` two-digit).
Declining the real cycle over a naming guess is the costlier failure.
2. **Deadline** — target's deadline must be later than *both* sources'.
This is the **#448 invariant restated**: a call named FY26 but deadlined
earlier is a backfill, not the next cycle. The override replaces
`findPreviousDataCall` entirely, so it carries #448's guarantee itself
rather than inheriting it.
3. **First cycle only** — the target must be the earliest cycle matching
those prefixes, ordered by `(deadline, datacallid)`. Without this a
second same-year call (`FY2026 Q4`, a redo, a backfill) passes gates 1
and 2 and re-sources from the 2025 cycles, *discarding* everything the
real FY2026 cycle accrued. This gate is what makes the broad prefix in
gate 1 safe. It reads live state, not history — deleting a data call and
recreating it re-arms the override, so dev stays re-testable.

Sources resolve by **name, not id** — `datacalls.datacall` is `UNIQUE`
(migration 0013) and ids differ between dev and prod. Any decline logs a
specific reason and falls through to the existing global path unchanged,
so this is inert on any environment lacking both 2025 cycles.

## Safety

**The diff is purely additive — no existing line is removed.**
`findPreviousDataCall` and the normal copy path are byte-identical.

- `status` still seeded as the `'not_started'` literal in the same
`INSERT…SELECT` (#435 — the answer carries forward, its review state
does not)
- `DISTINCT ON (fismasystemid, functionid)` guarantees one answer per
question; `scores` has no uniqueness constraint, so a source cycle
holding duplicates would otherwise copy both
- rollover stays best-effort: a copy error is logged, never fails the
data call create

## Observability

One `ROLLOVER_HARDCODE` line per create. On the FY26 create:

```
ROLLOVER_HARDCODE datacall=<new> status=active mode=exclusion
  cms_source=<id>("FY2025 Q3") other_source=<id>("FY25 ZTM")
  cms_systems=… other_systems=…
  src_rows=… selected_rows=… excluded_rows=…
  copied=… deduped=… dropped_unresolvable=…
  from_cms_source=… from_other_source=…
  cms_stranded=… other_stranded=…
  misfiled_carried=… unscored_env=…
```

`from_cms_source` is the headline check — under the old behavior it
would be **0**. `excluded_rows` is the fix working: HHS's view of CMS
systems, discarded.

`status=inactive` with a reason means the override declined and the
normal path ran (`source_lookup`, `target_name_not_fy2026`,
`target_deadline_not_after_sources`, `target_is_a_source`).

Alarms reuse the existing `ROLLOVER_ANOMALY` token, so they page through
the current CloudWatch metric with no new infra:
- `reason=hardcode_empty_copy` — rows selected, none written
- `reason=hardcode_stranded_systems` — a system answered somewhere but
rolled forward empty
- `reason=hardcode_unresolvable_rows` — assigned rows the copy could not
carry because `functionoptionid` resolves to nothing

Two diagnostics were corrected while building this and are worth noting
for anyone reading the log:
- `misfiled_carried` is restricted to systems that **have** a scoring
environment. Without that, every row for a system whose
`datacenterenvironment` is unmapped or maps to a NULL `scoring_key` (the
`DECOMMISSIONED` marker, migration 0045) reports as mis-filed — measured
36 reported / 36 false on the local fixture. Those are counted
separately as `unscored_env`.
- Row accounting separates `excluded_rows` (dropped by the OpDiv filter)
from `deduped` (collapsed duplicates). Conflating them would report
deliberately excluded rows as deduplication and hide how much exclusion
drops.

`dropped_unresolvable` is a genuine partial-copy signal. An earlier
revision of this PR argued it was unnecessary because enforced FKs meant
the copy's INNER JOINs could not drop a row. **That was wrong** —
`scores_functionoptionid_fkey` reports `convalidated = t` only because
it was created against an empty table, and bulk loads run under
`session_replication_role = replica`, which bypasses FK triggers. The
real data contains 33 rows across 11 systems in `FY2025 Q3` with
`functionoptionid = -1`, which has no `functionoptions` row. The copy
discards them silently, so the check reports 33 rather than never
firing. See the review comment below.

## Testing

- `make test-unit` — passes
- `make test-integration` (isolated freshly-seeded DB) — passes
- `TestMatchesRolloverHardcodeTarget` — 12 cases covering both naming
conventions, case-insensitivity, and rejection of FY2027 / backfills /
the source cycles
- **End-to-end through `POST /api/v1/datacalls`** against a prod-like
snapshot: 993 systems, 13 OpDivs, 262 CMS / 731 other. Both routing
branches exercised.

Verified against the database after the run:

| Assertion | Result |
|---|---|
| CMS rows not sourced from `FY2025 Q3` | **0** |
| non-CMS rows not sourced from `FY25 ZTM` | **0** |
| rows with status ≠ `not_started` | **0** |
| duplicate answers per (system, function) | **0** |
| orphan rows carried forward | **0** |
| `FY2027 Q1` | declined, `reason=target_name_not_fy2026`, fell through
to `findPreviousDataCall` |

Against unpatched `main` on the same data:

| | main | this PR |
|---|---|---|
| CMS systems fed the HHS imported view | 226 | **0** |
| Systems covered | 957 | 969 |
| Systems rolled forward empty | 21 | 9 |
| Systems properly scored (not exactly 1.00) | 196 | 272 |

The committed fixture cannot exercise this — `_test_data_empire.sql` has
`FY25 ZTM` but no `FY2025 Q3`, so the override is inert there and no
existing test path changes behavior.

**Note for anyone modifying the copy SQL:** the `::int` casts on the
CASE branches are load-bearing and cannot be validated in psql. Literal
ids and `PREPARE p(int,…)` both supply the type information pgx omits,
so a psql harness will certify SQL that fails at runtime with `integer =
text`. It needs a real POST.

## Before creating the prod cycle

1. Confirm the exact names — `SELECT datacallid, datacall, deadline FROM
datacalls ORDER BY deadline DESC;`. A mismatch means a **silent no-op**:
FY26 would roll from HHS exactly as today, with only a `status=inactive`
log line to show for it.
2. Name the FY2026 call starting `FY2026` or `FY26`, deadline after
2025-09-30.
3. **Decide the seven CMS-tagged HHS arrivals** — see the review
comment; identifiers are in the internal handoff doc. They carry forward
today and roll forward empty under exclusion. Retagging them to their
true OpDiv makes this a clean win.
4. Check the log: `from_cms_source > 0` and `other_stranded=0`.
`cms_stranded` is expected to be **9** on current data, from three known
causes — not a reason to abort, but confirm the breakdown matches.

The copy runs **once**, at creation, with no re-run path (#411). If the
log is wrong, recovery is deleting the data call —
`scores_datacallid_fkey` cascades — and recreating it. Verify before
anyone begins answering.

## Removal

Delete the block between `TEMPORARY HARD-CODE - ztmf#500` and `END
TEMPORARY HARD-CODE`, plus the 4-line call site in `copyPreviousScores`,
and optionally `TestMatchesRolloverHardcodeTarget`. No schema change, no
migration, nothing to unwind — the rows written are ordinary `scores`
rows identical in shape to a normal rollover.

From FY2026 onward there is a single global predecessor again containing
every system, so #500 goes dormant on its own. The durable per-OpDiv
design (#500 decisions 2 and 3, and the missing cycle-ownership column
on `datacalls`) is still open and only matters if genuinely divergent
per-OpDiv cadences return.
… score write authorization (#517)

Combines #511, #514, #515 and #516 into one merge so the FY2026 work deploys once instead of four times.

Admin-merged without a formal approval on the combined head. This is a deliberate call, not a missed gate: it is an emergency patch during the live FY2026 data call and the team expected it tonight. #511, #515 and #516 were each approved individually; #514 was reviewed with no defect found, its only outstanding item being the rebase this branch performs.

Verified on the combined head before merge: all four PRs' distinctive changes confirmed present, openapi regenerates byte-identical, unit and integration suites green, emberfall 197/197 with both stacks' cases checked by content rather than assumed.
…d) (#521)

Backend counterpart to the frontend freeze batch in
CMS-Enterprise/ztmf-ui#674. Running batch of approved work, draft until
the remaining approvals land, then converted manually.

Closes #513

## What is in it

| Source | Author | Change | Closes |
| --- | --- | --- | --- |
| #519 | @voidspooks | Read only in-app edits for score-progress
last-updated | #513 |

@voidspooks' commit is carried unchanged from his fork: same content,
his authorship, and the signature still verifies. Nothing was rewritten
or re-signed.

This supersedes #520, which was opened as a standalone rehome before the
batch approach was settled. Same branch content, renamed so the freeze
artifacts match across the two repositories.

## Why this is more than the ticket described

`LastUpdatedAt` took the newest `events` row of any action, while the
status backfill in `0048scoresstatus.go` counts only `created` and
`updated`. So a row whose only provenance was an out-of-band load
reported the load's timestamp while `questionsupdated` stayed 0.

Review surfaced a second case nobody had stated: because the filter was
absent entirely, a row with a **genuine in-app edit** could have its
displayed timestamp overwritten by a **later** import. An ISSO's real
edit could be relabelled with an ETL run's timestamp. That case is not
bounded to closed cycles the way the reported symptom is, so the fix is
worth more than its description claims.

The allowlist is applied in the `WHERE`, so the descent picks the newest
matching event regardless of how imports interleave. Verified in both
orderings rather than by example.

## CI proof, which is the point of bringing this in-repo

A fork PR cannot reach org secrets, so on #519 the Snyk jobs, the
deploy, and the integration tests never ran. In-repo they do, and all
eleven checks pass on the identical commit (run `30941730744`):

| Check | Result |
| --- | --- |
| Analysis / snyk test | pass, 22s |
| Analysis / snyk code test | pass, 37s |
| Analysis / snyk iac test | pass, 34s |
| Analysis / openapi spec up to date | pass, 41s |
| Analysis / lint go | pass, 1m8s |
| Analysis / lint terraform | pass, 7s |
| Analysis / detect backend changes | pass, 5s |
| Check for Changes | pass, 7s |
| Backend / Integration Tests | pass, 1m36s |
| Backend / Build and Smoke Test | pass, 2m24s |
| Infrastructure / Deploy | pass, 3m15s |

The openapi drift check passing matters specifically: `openapi.yaml`
here is a regeneration, since swag sources the `lastupdatedat`
description from the struct comment. Two independent reviews confirmed
it regenerates byte-for-byte identical to what is committed, and CI now
agrees.

## Review already done, recorded because it happened off the PR

Reviewed against live data and cleared. Confirmed rather than assumed:
the action allowlist exactly matches the `0048` backfill, the audit
index keys only on scoreid and createdat so the filter rides on top of
the index descent as the author noted, the spec regenerates identically,
and the new integration test genuinely fails with the filter removed and
passes with it.

**Blast radius is historical only.** The affected rows are imported-only
ones in closed cycles, and no system in the open FY2026 data call is
affected, so nothing changes for anyone mid-data-call. Exact figures
were kept off this public PR and can be recorded internally.

A second independent pass reproduced all of it, including emberfall at
`Ran: 197 / Failed: 0`, and confirmed the cross-repo claim: a nil
last-updated feeds the `-1` tie-break in ztmf-ui's `aggregateScores.ts`,
which is already pinned there by a passing test naming the imported-call
case explicitly.

## Non-blocking notes carried forward

For an imported-only row the lateral now matches nothing and scans that
row's events rather than short-circuiting at `LIMIT 1`. Bounded per row,
but worth eyeballing an archives-year dashboard load rather than
assuming it is free.

`('created', 'updated')` is now written literally in four places: the
migration, this query, and two raw SQL fragments in the integration
test. Two independent SQL fragments disagreeing about which actions
count is exactly what produced the original bug, so a shared Go constant
would close that off. Can follow separately.

## How this branch is maintained

Members are reviewed work. **The approval lives on this batch, not on
the member PRs**, and it is collected once the queue is final rather
than per addition, because any push here dismisses an approval already
given. So a member PR sitting at `REVIEW_REQUIRED` is expected and is
not a gap: during the freeze the batch is the thing that gets approved
and merged, and the member is the thing that got reviewed. Each member's
review is recorded on this PR so the trail does not depend on where the
conversation happened.

Additions are appended, never reordered, which keeps the push a
fast-forward, avoids re-hashing contributors' commits, and preserves the
record of what joined when.

This PR stays a draft while the queue fills. That is deliberate, not an
oversight: it means no CI has run here yet, since draft PRs skip both
the analysis and deploy jobs.

After merge, #519 closes without merging, so its own `Closes #513` never
fires. That is why the keyword is declared here.

Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
…l-questionnaire export (#529)

Backend release train for the code freeze. Approved PRs only,
cherry-picked so each contributor's authorship and signature survive.
Frontend counterpart is CMS-Enterprise/ztmf-ui#682.

Closes #445
Closes #526

## What is in it

| Source | Author | Change | Closes |
| --- | --- | --- | --- |
| #522 | @danielbowne | Expose `last_seen` on the users list, plus a
login event so it means last sign-in | — |
| #524 | @danielbowne | Single home for event-action and score-status
values | #445 |
| #528 | @MackOverflow, commits by @voidspooks | Export the full
questionnaire per system rather than only answered rows | #526 |

#522 declares no closing issue by design: it is the backend piece, and
presentation is deliberately left open on CMS-Enterprise/ztmf-ui#675.

Ordering is by approval. #522 and #524 both touch `events.go` and
`users.go`, and they merged without conflict because the edits sit in
disjoint regions. #528 is disjoint from both.

One thing dropped on purpose: #522's tip was an empty `chore(ci):
retrigger DEV deploy under a fresh image tag` commit, pushed to force a
fresh image tag past the immutable ECR repo. It has no meaning in a
batch that gets its own SHA, so it was skipped rather than carried.

## Verification on the combined branch

Verified the combination rather than trusting the parts, since three
separately green PRs can still interact:

- `go build ./...` and `go vet ./...` clean
- `go test -short ./...` — 13 packages ok
- Full integration suite against a seeded database — 13 packages ok,
zero failures, including the three tests that are sometimes
date-dependent
- **`make generate-openapi` produces no drift**: the committed spec
matches the generated one byte-for-byte. Worth doing here specifically
because #522 regenerated the spec and #524 changed models independently,
so the combination is the first time those two meet.

## Deploy note, and it is a real one

**Do not stack deploys on this branch.** #528's own SHA has two dev
deployments two minutes apart: the first succeeded, the second failed
with `waiting for ECS Service update: timeout while waiting for state to
become 'tfSTABLE' (last state: 'tfPENDING', timeout: 20m0s)`. The second
run raced the first while ECS was still rolling out. #524 lost a deploy
the same way earlier in the day.

So this PR was opened ready rather than draft-then-ready, because that
transition is what produced the double run. If the deploy here fails on
a stabilise timeout, let ECS settle and re-run **one** deploy; do not
push again or trigger a second in parallel.

## Merge order

**This merges first.** The frontend batch follows at least five minutes
later, which is both a courtesy to the prod pipeline and a correctness
requirement: #528's export change has to be live before the frontend's
select-all makes never-started systems selectable, or a user can select
them and download a header-only file.

## What is not in it

- #525 and #492 are drafts.
- #423 is a dependency bump with no approval.

---------

Co-authored-by: Daniel Bowne <daniel.bowne1@cms.hhs.gov>
Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
…#537)

Closes #534.

## Problem

`functions.pillarid` and `questions.pillarid` store the same fact
independently — two separate FKs to `pillars`, with nothing forcing them
to agree. `Function.Save()` wrote the function's copy straight from
caller input rather than deriving it from the function's question, so an
admin edit or a direct API call could create a disagreement at any time.

Nothing user-facing reads the function's copy today: #528 re-anchored
the export's Pillar column through the question, matching the
questionnaire and `/scores/progress`, and #530 verified zero drift in
production (360/360 agree). So this was latent rather than live — but
the stale copy sat there waiting to mislead the next query that read it
directly.

## Approach

Option 1 from the issue: close the write path rather than drop the
column.

`Save()` now resolves the function's question and writes that question's
`pillarid`, discarding whatever the caller sent. `questionid` becomes
required — a function with no question reaches no questionnaire
(`FindQuestionsByFismaSystem` inner-joins `functions` on `questionid`)
and has no pillar to derive from, so it is invalid input. With
`pillarid` always derived, the `pillarid` check in `validate()` is dead
for every valid input and comes out.

An unknown `questionid` returns `ErrNoReference`, which is the same 400
the caller already got — today that input reaches Postgres and comes
back as a 23503 that `trapError` maps to `ErrNoReference` anyway — just
detected before the insert.

### Requiring questionid also closes an accidental-NULL path

Worth calling out separately, because it is a second bug rather than a
consequence of the first. The UPDATE in `Save()` sets every column
unconditionally, so pre-PR a `PUT /functions/{id}` that omitted
`questionid` — an ordinary partial-update shape — wrote NULL to the
column and silently orphaned the function from every questionnaire,
since `FindQuestionsByFismaSystem` inner-joins `functions` on
`questionid`. The function would simply stop appearing for every system
in its data center environment, with no error to the caller. Requiring
`questionid` makes that request a 400 instead.

**No migration.** #530 showed production already agrees across all 360
functions, and writes are now self-correcting.

### Scope of the guarantee

This closes the `/functions` write path only: a caller-supplied
`pillarid` can no longer put a function out of agreement with its
question. It does **not** make the invariant total. `PUT
/api/v1/questions/{id}` writes `questions.pillarid` with no cascade to
dependent functions, so moving a question to a different pillar leaves
its functions stale until each is next saved (at which point this change
re-derives and self-heals it). That path is pre-existing and untouched
here — see the follow-up below.

## Acceptance criteria

- [x] `POST`/`PUT /api/v1/functions` writes the question's `pillarid`
regardless of the `pillarid` in the request body, on both insert and
update.
- [x] A request whose `pillarid` disagrees with its question returns
201/200 with the corrected value, rather than persisting the
disagreement.
- [x] `questionid` is required; a request without one returns 400 with
`questionid` in the error payload, and nothing is written.
- [x] An unknown `questionid` returns 400 (`ErrNoReference`) and nothing
is written.
- [x] A `PUT` omitting `questionid` returns 400 rather than NULLing the
column and orphaning the function from every questionnaire.
- [x] No schema change and no data migration.
- [x] Existing readers are unaffected: the questionnaire,
`/scores/progress`, the scoring aggregate and the export all already
join `questions.pillarid`.

## Testing

- `internal/model/functions_pillarderive_integration_test.go` (new) —
insert and update each correct a deliberately wrong `pillarid`; missing
`questionid` → `InvalidInputError` carrying `questionid`; unknown
`questionid` → `ErrNoReference`. Fixtures use the `AWS` environment,
which no seeded system maps to, so they are never visible to a
questionnaire while the test runs.
- `emberfall_tests.yml` — `functionData` / `updatedFunctionData` gained
`questionid: 8001`; added a drift case (claims pillar 6 against a
pillar-1 question → 201 with `pillarid: 1`), a questionless `POST` →
400, and a questionless `PUT` → 400 followed by a `GET` asserting the
whole prior state, so a partial write of any field fails the case.
- `make test-unit` — pass
- `make test-integration` — pass, including all 4 new subtests
- `make test-e2e` — 201 ran, 0 failed
- `openapi.yaml` regenerated: `pillarid` is now `readOnly: true` with a
description. Redocly lint is unchanged at 8 warnings / 2 ignored,
verified against the baseline.

## Reviewer notes

- **Behavior change:** `POST`/`PUT /api/v1/functions` without a
`questionid` now returns 400 where it previously succeeded. The frontend
never writes to this endpoint — its only `functions` calls are `GET
functions/{id}/options` in `QuestionnairePage` and `QuestionnareModal`,
and there is no admin "manage functions" view — so the only affected
callers would be hand-rolled scripts against the admin API.
- `Save()` repeats the nil check on `QuestionID` immediately before
dereferencing it. `validate()` already rejects nil, so the branch is
unreachable today; it is there so the deref does not silently depend on
a guard sixteen lines up in another method.
- Worth confirming before merge that production has no `functions` rows
with a NULL `questionid` (`SELECT count(*) FROM functions WHERE
questionid IS NULL;`). Such rows are already orphaned from every
questionnaire, but they would no longer be editable through the API
without supplying a question. #530's 360/360 count only covered rows
that join to a question, so it would not have surfaced them.

## Follow-up (post data call)

Deferring the remaining work to a single change after the current data
call, rather than splitting it across cycles:

- Cascade or eliminate the question-edit path described above, so a
question moving pillars cannot leave its functions stale.
- Option 2 from #534: drop `functions.pillarid` entirely and read
through the question everywhere. This subsumes the cascade — with no
second copy there is nothing to keep in sync. It needs
`functions.questionid` made NOT NULL, plus updates to the `PillarID`
filter in `FindFunctions`, the Empire seed's score generator, and the
OpenAPI schema.

Doing both at once avoids writing a cascade that the column drop would
immediately delete. Not scheduled during the data call because it
touches the questionnaire's schema while systems are actively being
scored.

Refs #528, #530.
…login alarm tuning, DB secret resolution (#541)

One batch carrying the approved backend PRs so they take a single dev
deploy and a single prod deploy instead of three. The dev lane is shared
and serialises, and three separate deploys tonight would have raced each
other; that race has already cost three deploys today.

Closes #531
Closes #484

## What is folded in

| PR | Author | Approvals before folding |
| --- | --- | --- |
| #539 questionnaire ordering end to end, plus the export index | Daniel
Bowne | costacalvin, jsos3-cms |
| #525 require 3-of-6 windows on the login auth-failure alarm | Daniel
Bowne | jsos3-cms |
| #540 move DB secret resolution into config, rehome of #538 | Cameron
Testerman | none |

Appended in approval order, then #540 last since it has no approval to
order by. Nothing was reordered, so the record of what joined when
survives.

**Please give the #540 commit real attention when reviewing.** It is the
only part of this batch that has never had an independent approval. Its
content was reviewed and came back clean, and it is a verbatim
relocation rather than a rewrite, but a review verdict is not a recorded
approval and I authored the rehome so I cannot approve it. Approving
this batch is what puts a second pair of eyes on that commit.

## How it was built

Started from #539's head rather than cherry-picking its range, because
#539 contains a merge commit (folding in `chore/functionoptions-index`)
that a range pick cannot replay. #525's single commit and #540's single
commit were then appended.

#539 carries two `chore(ci)` retrigger commits of its own. They were
pushed to get past the immutable ECR tag on its original branch and are
meaningless here, but removing them would have rewritten Daniel's
commits, so they ride along.

This batch has since needed one of its own, for a reason worth writing
down: the API image tag is derived from the commit, and ECR has tag
immutability enabled, so a DEV deploy can never be re-run for a SHA that
has already pushed an image. Refreshing the deployment requires a new
commit. That is the mechanical reason the retrigger-commit pattern
exists in this repo rather than a habit. Re-running only a *failed*
deploy job does work, since the Docker push step has already succeeded
and is skipped.

## Verification

Files are disjoint across the three changes, splitting into
`internal/config` and `internal/db`, `internal/model` and the
migrations, and `infrastructure`. Checked on the combined branch rather
than trusting the individual runs:

- `go build` and `go vet` clean
- Full backend suite passes 13 of 13 packages against a freshly seeded
database
- `internal/config` and `internal/db` clean under `-race`, which matters
because #540 relocates a `sync.Once`
- `make generate-openapi` produces no drift, which #539 edits
- `terraform fmt -check` clean on the alarm file #525 edits

The suite run is the check worth having here: it exercises #540's
relocated credential path and #539's new ordering and migration tests in
the same process, which neither PR could do alone.

Each constituent also had its own green DEV deploy before being folded,
so each piece is known to apply against real infrastructure. #539's
deploy is the first time migration 0056 has run against real data, since
the local seed's function names never match the ordering map and leave
every rank at zero.

---------

Co-authored-by: Daniel Bowne <daniel.bowne1@cms.hhs.gov>
Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
Part of CMS-Enterprise/ztmf-misc#289 — phase 1 of 2. **Deploy before
ztmf-ui#690.**

## Problem

Devices and Applications do not apply to a SaaS-hosted system, so the
consumer answers 25 questions, not 40. That is program-approved, but it
was enforced only in the frontend (ztmf-ui#430) — every backend query
still resolved 40. So the dashboard showed a permanent `25/40` that
could never reach Complete (the chip is gated on `answered >= expected`,
and the other 15 questions are unreachable in the UI), those systems
stayed ranked as laggards by `progressSortValue` and matched the "Not
updated" facet, and the export returned 15 questions nobody was asked.

## Approach

One predicate, `saasPillarScopeSQL` (`internal/model/pillarscope.go`),
applied to **both** `buildScoreProgressSQL` CTEs and to `FindAnswers` as
a top-level `WHERE`.

In scope from the FY26 cycle onward only: a cycle qualifies when it is
named FY26 or its deadline falls after every FY26 cycle. Anchored on
deadline, not `datacallid`, because ids are not chronological —
`findPreviousDataCall` documents the same trap. Closed cycles were
collected and reported against 40 and keep reading 40.

Three details are load-bearing rather than stylistic:

- **Both progress CTEs.** In `expected` alone, a denominator of 25 would
face a numerator of 40 from carried-forward answers — breaking the
documented answered-cannot-exceed-expected invariant and reporting a
false "Complete" on a closed call.
- **Top-level `WHERE` in the export.** That functions join is
applicable-OR-answered (#528), so filtering one branch would export 40
rows for a system whose excluded answers were carried forward and 25 for
a freshly created one.
- **`COALESCE` on the scoring key.** It is nullable (`DECOMMISSIONED`
maps to NULL) and `NOT(NULL AND …)` is NULL, which a `WHERE` reads as
false. Without it, a decommissioned system carrying FY26 answers — which
`FindAnswers` deliberately still exports — silently lost 15 rows, on a
**non-SaaS** system.

**Scoring (`scores.go`) and the questionnaire endpoint (`questions.go`)
are untouched, so no score moves.** Those, plus seeding the rule as data
instead of a literal, are #545 — the issue's remaining two acceptance
criteria (SaaS presenting exactly 25 questions from the API, and not
being scored for the excluded pillars) belong to that PR, not this one.
No rows are deleted here either: the retained answers stay in the
database and stop counting.

## Acceptance criteria

- [x] Progress/completion calculates against 25 for SaaS systems — 25
for every SaaS system on FY2026, with the numerators drawn from the same
set so `answered`/`updated` can never exceed it. A fully-answered closed
call now reaches Complete instead of a permanent 25/40.
- [x] All other environments unchanged (40) — every other environment
value, and non-SaaS export row counts byte-identical.
- [x] Closed historical calls unchanged — FY25 ZTM and FY2025 Q3 report
40 and export identical counts. The gate anchors on the *last* FY26
deadline rather than the first precisely so a mis-dated FY26 call cannot
restate a closed cycle.
- [x] The export returns only the in-scope questions for FY26 onward
(post-issue decision from Mack/Allie). See the row-count note below.

## Testing

- `make test-unit` — pass. `make test-integration` — pass.
- Against a production-shaped database: on FY2026 every SaaS system
reports 25 and every other environment 40; on FY25 ZTM and FY2025 Q3
every system still reports 40; **no** row on any cycle has
`answered`/`updated` exceeding `expected`; the FY2026 export drop
matches the excluded-pillar count exactly, per system, with prior-cycle
counts byte-identical; `/scores/aggregate` output unchanged.
- New: `pillarscope_test.go`; `pillarscope_integration_test.go`, which
evaluates the predicate in Postgres because its correctness rests on
three-valued logic no string assertion can pin — verified to fail when
the `COALESCE` is removed; progress and export integration subtests
covering the carried-forward case, a prior cycle, and a non-SaaS
control.
- `EXPLAIN ANALYZE`: the `MAX(deadline)` and `EXISTS` plan as InitPlans
with `loops=1`, evaluated once per query rather than per row. Export
1.79s vs 2.13s baseline (faster — fewer rows); whole-dashboard progress
57ms vs 25ms. No new indexes.

## Reviewer notes

- **CMS is deliberately not exempt.** The reduced scope is understood to
be an HHS-OpDiv rule with CMS answering all 40, but that is unconfirmed
— and exempting CMS here alone would give CMS SaaS systems a 40
denominator with 15 questions their ISSOs cannot see, since the frontend
filter is OpDiv-blind. All SaaS systems read 25 for now; the clause to
add is in `pillarscope.go` and the current behavior is pinned by
`CMSAlsoReducedWhileExemptionIsUndecided` so re-adding it has to change
a test.
…the questionnaire (#549)

Closes #545. Phase 2 of CMS-Enterprise/ztmf-misc#289.

## What

The reduced-pillar rule moves from a name-matching SQL literal into a
seeded table, and the two consumers #546 deliberately left untouched now
honor it. Migration 0057 creates `reducedpillarscopes` (scoring_key,
pillarid, effective_datacallid): a row excludes that pillar for systems
scored under that key, on every data call whose deadline is on or after
the anchor call's. The shared predicate now reads the table, so the
progress counts, the export, the scoring aggregate and the questionnaire
resolve the same rule from the same rows, and a future reduced-scope
environment is an INSERT, not a code change.

Scoring: the aggregate's expected CTE applies the predicate, so an
excluded pillar produces no pillar score and the system score averages
the pillars that remain (the divisor already followed the actual pillar
count). Carried-forward answers on excluded pillars stop counting
because the answers CTE is only read through the join from expected.

Questionnaire: `GET /fismasystems/{id}/questions` accepts an optional
`datacallid` and serves the reduced set for in-scope cycles, so the
frontend can delete its client-side pillar filter (tracked separately in
ztmf-ui). Without the param the response is byte-identical to today,
which keeps the current frontend correct until it passes the param.

Seeding: in deployed environments migration 0057 locates the FY26 anchor
by the same name prefixes the interim predicate matched, once, at
migration time; after that the rule is pure data. The ephemeral
local/test databases get their rule rows from the empire seed, which
owns the fixture cycles and runs after migrations. A deployed
environment with no FY26-named call at migration time seeds nothing and
keeps the full set until a rule row is inserted alongside the cycle that
needs it; that environment-dependence is inherent to rule-as-data and
deliberate.

Per the decision recorded on ztmf-misc#289 (2026-08-11), CMS is not
exempt and there is no OpDiv awareness anywhere in this change. The
interim `CMSAlsoReducedWhileExemptionIsUndecided` pin is renamed to
assert the now-permanent behavior.

## Visible behavior changes

`/scores/aggregate` returns four pillar entries and a four-pillar system
score for SaaS systems on FY26-onward cycles. Roughly 113 SaaS systems'
current scores and tiers move when this deploys mid-call, so it needs a
low-traffic window and a heads-up before it ships (flagged on #545). The
questionnaire endpoint changes only when the new param is passed.
Progress and export behavior is unchanged, now sourced from the seeded
table instead of name matching.

## Acceptance criteria

- [x] The reduced-pillar rule is seeded data, not a SQL literal or a
frontend constant. No cycle name, environment, or pillar is compiled in;
pinned by the shape tests.
- [x] FY26-onward reduced-scope systems carry no Devices or Applications
pillar scores. Integration-pinned for the SaaS fixture and its CMS twin,
with carried-forward excluded answers present.
- [x] The questionnaire endpoint returns the reduced set for
reduced-scope systems and the full set for everything else, keyed by the
new optional `datacallid` param.
- [x] Closed data calls are unchanged: pillar scores, system scores,
tiers, exports, and question sets all pinned on a pre-anchor cycle.
- [x] Emberfall updated for the changed API contracts (five new pinned
cases).

## Testing

`make test-unit`, `make test-integration`, and `make test-e2e` all green
locally (e2e: 206 ran, 0 failed, 0 skipped). New integration coverage:
aggregate reduction, questionnaire reduction with and without the param
plus a non-reduced control system, and a pin that the seed wiring
produced exactly the two SaaS rule rows. `openapi.yaml` regenerated via
`make generate-openapi`; redocly lint valid.
…ams redirecting scope (#547)

Fixes two authorization defects in `GetDatacallExport`, both filed
against the same handler, and retires the copy-pasted scope pattern that
let the first one happen.

## The defects

**CMS-Enterprise/ztmf-misc#267** (OpDiv scope): the export used a
two-way `HasAdminRead()` branch that dropped both OpDiv-scoped tiers
into the unscoped path, so an OPDIV_ADMIN or OPDIV_READONLY_ADMIN
exported every OpDiv's answers. Now scoped by the same three-way tier
split the rest of the codebase uses, with a fail-closed OpDiv predicate
added to `FindAnswers`: an OpDiv-restricted caller with no grants gets
no rows.

**CMS-Enterprise/ztmf-misc#268** (query-param scope override): scope was
set before the client query string was decoded onto the same struct, so
`?UserID=<other>` redirected the export to another user's answers, and
the scope pointer aimed at the session `User` meant decode mutated the
authenticated identity itself. Now decode-then-scope (the ordering every
other list handler documents), server-owned fields tagged `schema:"-"`
so they cannot be bound from the query at all, and `UserID` copied off
the session rather than pointed at its field. The path is now
authoritative for the data call id.

## Retiring the pattern

#267 happened because the export predated the tier-scoping pattern and
never adopted it. The same two snippets were hand-copied across the
codebase: the tier-classification switch (six controllers) and the
fail-closed OpDiv predicate (nine model queries). This PR extracts both
into an embedded `OpDivScope` type:

- `ApplyTier(user)` owns the role classification (unscoped / OpDiv /
self-scope). The self-scope default stays at the call site because it
legitimately differs per endpoint (by UserID, by assigned systems, or a
403).
- `OpDivWhere` / `AppendRawFilter` own the fail-closed decision
(restricted-and-empty means no rows) for squirrel and hand-built queries
respectively. The grants predicate stays at the call site because the
path from each query's base table to `opdiv_id` differs (direct column,
`fismasystemid` subquery, `EXISTS` against `users_opdivs`).

The fields carry `schema:"-"`, so a server-owned scope field supplied in
any list endpoint's query is now a 400 rather than silently honored. A
new list endpoint can no longer mis-classify a tier or forget the
fail-closed predicate.

The per-resource access guards (`usersopdivs.go`, `users.go`,
single-system checks) are a different pattern and are left for
#427.

## Behavior changes

- OpDiv-tier exports are now scoped to granted OpDivs; a zero-grant
OpDiv caller gets an empty export.
- `?UserID=` and `?DataCallID=` on the export return 400 instead of
being honored; the path is authoritative.
- The `schema:"-"` hardening is codebase-wide: server-owned scope fields
on the scores, progress, diff, insights, fismasystems, and users list
endpoints are likewise no longer bindable from the query (they were set
from the session already; this makes an attempt a 400 rather than a
silent no-op).
- Unscoped admin (OWNER, HHS_ADMIN, HHS_READONLY_ADMIN) and ISSO/ISSM
behavior is unchanged.

## Tests

- `datacalls_export_authz_test.go`: export role matrix and the
query-override attempt as an explicit negative case.
- `answers_integration_test.go`: OpDiv scoping, empty-grant fail-closed,
and cross-OpDiv `fsids` returning nothing.
- Emberfall cases for an OpDiv-scoped export and the 400 rejection.
- The existing per-query OpDiv and progress tests are repointed to the
embedded struct and still pass.
- `make test-unit`, `make test-integration`, `make test-e2e` green;
staticcheck clean; OpenAPI spec unchanged.

## Sequencing

These are scope fixes and should land before #427
(the ACL centralization), whose "preserve existing authorization
behavior" criterion would otherwise carry #268 forward. Touches the same
`FindAnswers` function as #546, so whichever merges second takes a small
rebase.
…ndable (#552)

## What

Adds `schema:"-"` to the two server-owned fields on `FindScoresInput`,
and pins the boundary with tests.

```diff
 type FindScoresInput struct {
 	input
 	FismaSystemID  *int32   `schema:"fismasystemid"`
-	FismaSystemIDs []*int32
+	FismaSystemIDs []*int32 `schema:"-"`
 	DataCallID     *int32   `schema:"datacallid"`
-	UserID         *string
+	UserID         *string  `schema:"-"`
 	IncludePillars *bool    `schema:"include_pillars"`
 	OpDivScope
 }
```

## Why

`FindScoresInput` is the one query-input struct without this tagging.
Its siblings — `FindAnswersInput`, `FindFismaSystemsInput`,
`FindScoreDiffInput`, `FindScoreProgressInput`,
`FindSystemInsightsInput` — all carry it.

Both fields are populated by the server, never by a caller: `UserID`
from the session via `ApplyTier`, and `FismaSystemIDs` from
`user.AssignedFismaSystems` (`controller/scores.go:322`, its only
assignment anywhere). Since neither is a parameter callers are meant to
send, decode should reject them rather than accept them silently.
`ApplyTier` assigns `UserID` only for the tiers needing self-scope, so
for any other tier a decoded value would otherwise persist into the
query.

## Tests

Three cases, mirroring the shape `datacalls_export_authz_test.go`
already uses for the export input:

- **Negative** — the two tagged fields fail to decode from the query
string.
- **Positive** — `fismasystemid`, `datacallid` and `include_pillars`
still decode normally, so the tagging doesn't cost callers anything they
legitimately send.
- **Handler** — `ListScores` answers 400 for a request naming a
server-owned field, rather than proceeding. Decode failing
short-circuits before the query runs, so this needs no database.

## Verification

- `go build ./...` and `go vet ./...` clean
- `go test -short ./...` — **630 passed across 19 packages**, up from
622 by exactly the eight added here
- Confirmed `FismaSystemIDs` has no caller that sets it from a request,
so the tag breaks nothing

Small and self-contained; no behavior change for any legitimate request.
## What

Declares `price_class` on the CloudFront distribution and adds a
`lifecycle` postcondition asserting it. Two additions, twelve lines, no
other change.

## Why

`price_class` was inherited from the AWS provider's schema default
rather than declared. That default is not stated in the provider's
published argument reference — it exists only in the SDK schema — so the
effective value was implicit and dependent on an upstream choice, while
`terraform.tf` permits any 5.x minor.

Declared as a literal rather than a variable so the value is identical
in every environment. Placed adjacent to `restrictions` to match the
canonical example in the registry docs.

The postcondition evaluates against refreshed state during `plan`, so it
catches an out-of-band change as well as a bad diff, and it fails at
plan time rather than at apply.

## Verification

- `terraform plan` against dev reports `No changes. Your infrastructure
matches the configuration.` both before and after this commit. This is a
no-op against live state.
- `price_class` carries no `ForceNew` in the provider schema, so it
cannot trigger distribution replacement under any value change. No
recreate, no new distribution ID or domain name, no propagation event.
- Postcondition negative-tested by falsifying the assertion — Terraform
correctly fails the plan, confirming the guard is live rather than
inert.
- `terraform fmt -check` clean.

## Scope

The `restrictions` block is untouched and unasserted. An earlier
revision of this PR also asserted the geo restriction; that was dropped
deliberately. Geo enforcement interacts with the WAF layer in ways that
an assertion here would prematurely lock in, so the distribution's geo
posture is left free to change independently of this PR.

Tracked in CMS-Enterprise/ztmf-misc#293.

Co-authored-by: Calvin Costa <108481161+costacalvin@users.noreply.github.com>
## Summary

Closes #346.

`ARRAY_AGG` over zero rows is SQL `NULL`, which scans into a nil slice
and serializes as JSON `null`. A user holding no OpDiv grants was
therefore indistinguishable from a response that omitted the field.
Wraps the subquery in `COALESCE(..., '{}')` and lifts it to a shared
const so the paths cannot drift.

The spec was already on the fixed side of this. `backend/openapi.yaml`
has always declared the field as a plain array:

```yaml
assignedopdivids:
  items:
    type: integer
  type: array
```

No `nullable: true`. So the API was returning `null` for a field its own
published contract declared non-nullable. This brings the implementation
into conformance with the schema rather than the other way round — which
also means the spec needs no regeneration (verified: regenerating is
byte-identical).

## Changes

* **`internal/model/users.go`:** `assignedOpDivIDsSubquery` const,
applied to the `/users` paths — `FindUsers`, `findUser`, `Save` (insert
and update), and `RestoreUser`. Scans realigned where the `RETURNING`
list grew. The delegate paths deliberately do not select it; see below.
* **`internal/model/users_opdivarray_integration_test.go` (new):** pins
the contract on both the scanned slice and the marshalled JSON.
* **`emberfall_tests.yml`:** assertions on create, get, list, update,
restore, and `/users/current`, plus a delegates-roster assertion pinning
the field as `null` there.

## Why this stops at the /users resource

The delegate paths deliberately do **not** select the field, because
populating it there would leak data an authorization gate already
withholds:

* An ISSO is `403`'d on `GET /users` — user management is restricted,
and that is where OpDiv membership is otherwise exposed.
* That same ISSO **can** read their own system's delegate roster:
`ListDelegates` gates only on `CanAccessFismaSystem`.
* A delegate is granted the OpDiv of every system they are attached to
(`AddSystemDelegate` inserts into `users_opdivs`), so a shared
delegate's other-OpDiv memberships would become visible to a
system-scoped viewer.

The counter-argument — that these responses already carry
`assignedopdivids` as `null`, so selecting it merely corrects a value —
is about schema conformance, not confidentiality. `null` discloses
nothing; a populated array discloses real membership. Those are separate
questions, and the confidentiality one wins.

So the delegate responses keep spelling the field `null`. The
consequence is that a client's `?? []` stays load-bearing off the
`/users` resource, which is the right trade. The emberfall roster
assertion pins the `null`, so adding the subquery to those paths fails
loudly — verified by adding it and watching the assertion go red.

## Acceptance criteria

* [x] `assignedopdivids` is always a JSON array (`[]` when no grants),
never `null`
* [x] Applied to both `FindUsers` (list) and `findUser` (detail)
* [x] Implemented with `COALESCE(ARRAY_AGG(opdiv_id), '{}')` in the
subquery
* [x] Emberfall assertion added so the shape cannot regress

## Testing

`go build`, `go vet`, `go test -short ./...`, `make test-integration`,
and `make test-e2e` (211 cases) all clean.

Both layers verified non-vacuous by reverting the COALESCE:

| Check | Result |
| --- | --- |
| Go integration, COALESCE reverted | 4 subtests red |
| emberfall, COALESCE reverted | 23 red |
| emberfall, subquery re-added to a delegate path | roster assertion red
|

The Go test resolves a grantless user by query rather than hardcoding an
id, so it cannot pass vacuously against a fixture where everyone is
granted, and `GrantsAreNotFlattened` cross-checks every seeded user's
array length against the `users_opdivs` counts — so a COALESCE that
flattened everyone to `[]` would fail too.
The dependency scan is red across the whole backend queue on a Go standard-library advisory against 1.25.11, the version pinned in `backend/go.mod`. Nothing in any of the open PRs causes it, but because the backend and infrastructure jobs are gated on `!failure()` they skip while that scan is red, so no backend PR can reach the dev deploy main's ruleset requires. #558 and #559 are both sitting behind it.

This moves the module directive and the builder image in`backend/Dockerfile` together, so the binary that gets scanned is built with the same toolchain the scan is checking. 1.25.14 rather than the minimum 1.25.13: both exist upstream, and taking the current patch in the series avoids landing on a release that carries its own open advisory.

Verified before opening: `go build ./...` clean, and the full DB-free unit suite green at 630 tests across 19 packages.
…e Terraform timeout (#558)

When a backend image starts against a database whose migration version
is ahead of the image's registry, `migrations.Run()` hits tern's
`BadVersionError` and `log.Fatal`s into CloudWatch, the task
crash-loops, and the only symptom in Actions is the tfSTABLE timeout
after 20 minutes, the same string a contention timeout produces.
Grepping a failing run for the migration error finds nothing because the
fatal never reaches the job output (ztmf-misc#298).

Three changes, per the ticket's two candidate shapes:

1. `migrations.Run()` now classifies tern's `BadVersionError` and fatals
with a greppable `MIGRATION_VERSION_MISMATCH` message naming the version
gap and the remedy (rebase onto latest main). Only this error class is
reachable in a deployed environment since `Migrate()` always targets the
registry length; other errors pass through unchanged.
2. `infrastructure.yml` gains a `Deploy failure diagnostics` step (`if:
failure()`) that prints the ECS service events, stopped-task reasons and
exit codes, and any `MIGRATION_VERSION_MISMATCH` lines from the last 30
minutes of the log group. This repo is public, so raw application logs
are never dumped into job output; anything beyond the controlled token
points at CloudWatch by name. A crash-looped startup fatal and a genuine
contention timeout now look different in the job output.
3. `orchestration-dev.yml` fails fast, in the existing Check-for-Changes
job, when main carries migration files the branch lacks, naming the
mismatch and the remedy in seconds instead of after a 20-minute burn.
This is a git approximation: it cannot see a migration another open PR
deployed to dev without merging, which is the case changes 1 and 2
cover.

Gate: `go build`, unit, integration, and emberfall e2e all green
locally. The fail-fast check was simulated from the parent of the newest
migration-bearing commit (flags the missing migration and fails) and
from HEAD (passes clean). No API contract change, so no `openapi.yaml`
or `emberfall_tests.yml` changes.
Bumps `github.com/xuri/excelize/v2` from v2.9.0 to v2.11.0, resolving
Dependabot alert 44 (GHSA-h69g-9hx6-f3v4, high): unbounded row-index
allocation in the worksheet parser that can OOM or panic when opening a
crafted xlsx.

The vulnerable code path is not reachable in this codebase - our only
excelize usage is the write-only data-call export (`NewFile()` +
`SetCellValue` in
`backend/cmd/api/internal/spreadsheet/spreadsheet.go`); we never parse
untrusted spreadsheets. This is hygiene to close the alert, not an
urgent patch.

Both APIs we call are signature-identical between v2.9.0 and v2.11.0;
the documented breaking changes in this range are chart/shape APIs we
don't touch. Transitive upgrades (mscfb, msoleps, efp, nfp, x/crypto,
x/net, and the mohae/deepcopy to tiendc/go-deepcopy swap) are excelize's
own upstream dependency changes. The `go 1.25.14` directive already
satisfies v2.11.0's Go 1.25 floor, so no toolchain or Dockerfile change
is needed.

## Testing

- `go build ./...` clean
- `make test-unit` green
- `go mod tidy -diff` produces no diff (go.mod/go.sum are canonical)
- `go vet` on the spreadsheet package clean
- No OpenAPI, migration, or emberfall changes required: dependency-only
bump with no request/response surface change
…filters (#566)

Closes #564. First slice of the admin events page epic
(CMS-Enterprise/ztmf-ui#183).

`GET /events` returned the entire events table unordered: no ORDER BY,
no limit, no time filter, roughly 200k rows in prod. This makes it a
paginated contract: results order by `createdat` DESC with `eventid` as
tiebreaker, `limit` (default 50, capped at 500) and `offset` page
through, and `from`/`to` bound `createdat` inclusively as RFC3339. The
response becomes a page object, `{events, total, limit, offset}`,
echoing the values actually applied so a client pager never re-derives
the defaulting rules. No frontend consumer exists yet, so nothing
breaks; the page in CMS-Enterprise/ztmf-ui#711 builds on this contract.

Two deviations from the ticket text, worked out in scoping. Offset
pagination instead of a cursor, because the consumer is MUI DataGrid
server-side paging, which wants page and pageSize with arbitrary jumps.
And a migration (0058) the ticket didn't call for: the events table has
no primary key and bulk writers stamp identical createdat values, so
createdat alone is not a total order and tied rows could swap across
page boundaries; 0058 adds an `eventid` identity column as tiebreaker
plus a `(createdat DESC, eventid DESC)` index, since the 0037/0054
indexes both need their leading column in the predicate and cannot serve
the unfiltered scan. Event rows now expose `eventid`, which the UI can
use as a row key.

Also folded in: `sanitizeErr` now maps `ErrInvalidQueryParam` to 400
when a handler returns it directly. Previously only the schema-decode
branch produced it; the new from/to parse path returns it, and unmapped
it fell through to 500.

Gate: `make test-unit`, `make test-integration`, and `make test-e2e` all
green locally (emberfall 217 ran, 0 failed, including seven new /events
cases covering the page shape, echo and clamp behavior, and 400s for bad
params). New unit tests pin the limit defaulting and clamping and the
inverted-range 400; a new integration test writes tied timestamps and
proves paging covers every row exactly once, with the range bounds
inclusive. `openapi.yaml` regenerated via `make generate-openapi`;
redocly lint is clean with the same 8 pre-existing warnings as main.
…the events response (#569)

Closes #565. Part of the admin events page epic
(CMS-Enterprise/ztmf-ui#183, backend half).

Event rows carried only the initiating user's uuid, forcing the client
to join against the users list to show who acted. GET /events now
resolves the user server-side: each row carries userfullname, useremail,
and userdeleted, populated by an inner join to users in FindEvents. The
join cannot drop rows because events.userid is NOT NULL with an FK to
users (migration 0006), which is also why the count query stays
join-free and Total cannot drift from the page.

Soft-deleted users still own historical events, so the contract is:
their identity is returned as it stands today, with userdeleted true as
the client's cue to mark a retired account. Rendering that state is the
frontend's call.

The change is additive; nothing in the existing response moves.
openapi.yaml is regenerated (model.Event becomes model.EventWithUser in
the page schema) and emberfall gains a deterministic assertion using the
seeded archives service account, which is the only writer of
action=imported. Integration coverage asserts identity resolution on
every paged row and the soft-deleted case end to end.

Gate: make test-unit, make test-integration, and make test-e2e all pass
locally.
…#568)

Requires PRs that touch the migrations package to be current with the
head of main, and fixes the missing-migration fail-fast from #558 so it
compares against the PR head instead of the merge-ref checkout.

Migrations are globally ordered state. A migration authored against a
stale schema can collide on version number at merge, and an edited
migration or schema change merged behind an open branch never shows up
as a missing file, so the existing check could not see it. The new step
fails the Check for Changes job with a rebase instruction whenever the
PR diff touches backend/cmd/api/internal/migrations and the branch is
behind main. PRs that do not touch migrations are unaffected.

The #558 fix: on pull_request events the default checkout is
refs/pull/N/merge, whose main side diffs empty by construction, so the
missing-migration step could only fire if main advanced in the seconds
between the merge ref being computed and the job's fetch. Diffing from
github.event.pull_request.head.sha (the merge commit's second parent,
already present in the full-history checkout) makes it do what its
comment says. Verified both behaviors in a scratch repo: the head-sha
diff reports main's migration missing from a stale branch where the
merge-ref diff returns nothing.

Known limit, recorded not solved: these steps run on PR events only, so
a migration merging to main does not re-trigger open PRs. Making the
rule binding at merge time is a branch-protection question (require
branches up to date), not a workflow one.

Gate: unit, integration, and emberfall e2e suites pass locally;
actionlint clean on the changed steps (one pre-existing SC2086 info on
the untouched backend-gating step).

Closes CMS-Enterprise/ztmf-misc#303.
Rehomes #556 onto an in-repo branch, with the original commit's
authorship preserved.

Dependabot-triggered runs read the Dependabot secret store rather than
the repo and environment secrets, so `SNYK_TOKEN` was empty on #556 and
all three Snyk jobs failed with a 401 rather than on anything in the
diff. That PR could never go green, and so could never satisfy the
required dev deployment on main. Pushing the same commit from an in-repo
branch gives those jobs real credentials. #556 is closed in favor of
this one.

The bump itself is unchanged: nine action updates across seven
workflows, moving `actions/checkout` and `actions/setup-go` onto majors
that run on Node 24. That part is not cosmetic - current runs already
warn that `actions/checkout@v4` and `actions/setup-go@v5` are being
forced onto Node 24 because Node 20 is deprecated.

The second commit corrects two provenance comments. `snyk/actions` and
`aquia-inc/emberfall` are pinned by SHA because neither publishes tags
the workflow can track, and each carries a trailing comment naming the
date of the pinned commit. Both SHAs moved in this bump while the
comments still named the old date, so each described a commit that is no
longer the one pinned. They now read 2026-08-03 and 2026-08-12
respectively.

Verified before opening: the first commit's tree is identical to #556's
head, and the branch as a whole differs from it only in those two
comment lines. The residual mix of bare tags in `analysis.yml` and SHA
pins elsewhere is untouched and remains the subject of #317.

Expect the Go scan to be red here for the same reason it is red on #558
- a Go standard-library advisory against the version pinned in
`backend/go.mod`, unrelated to this diff.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…efs, and replace staticcheck-action (#571)

Stacked on #559: this branch sits on top of it, so the diff here
includes #559's two commits until it merges. Once it does I will rebase
onto main so only the commit below remains, and mark this ready for
review. Draft until then.

Finishes ztmf#317 on top of #559. That PR moved the remaining actions to
their Node.js 24 majors; this one converts every ref it left as a
floating tag to a commit SHA with a version comment, matching the format
#349 established in backend.yml, and closes out the rest of the ticket's
acceptance criteria. Also closes CMS-Enterprise/ztmf-misc#173.

The two snyk/actions refs in analysis.yml were still on @master. All
three snyk/actions uses now pin the v1.0.0 tag (9adf32b, 2025-10-03);
comparing that tag to master head shows no changes under setup/,
golang/, or docker/, so this is a provenance change only, and it retires
the stale "no tagged releases" comment. aquia-inc/emberfall moves from a
main commit to the v0.5.0 tag, whose action.yml is byte-identical to the
previous pin; the binary stays at 0.4.1 via the version input.
docker/setup-buildx-action goes to v4.3.0 for parity across backend.yml
and ops-image.yml. The two setup-go blocks in analysis.yml gain
cache-dependency-path: backend/go.sum, which #349 only did for
backend.yml, so the openapi and snyk test jobs stop missing the cache on
every run.

The lint go job no longer uses dominikh/staticcheck-action. The
composite pins actions/cache to a Node.js 20 build (upstream
dominikh/staticcheck-action#22 and #23, master unchanged since March
2026), which was the one deprecation warning left after every other
bump. The job now installs staticcheck with go install after a setup-go
on Go stable, mirroring the composite's install-go default, since
staticcheck's latest release requires a newer Go than the go.mod floor
and setup-go v6+ sets GOTOOLCHAIN=local. The action's problem matcher is
checked in under .github/matchers/staticcheck.json so PR annotations
behave as before. The same commands run clean locally against backend/
on Go 1.26.5 with staticcheck 2026.2.1.

Verification: a script resolved every pinned SHA back to its tag through
the GitHub API and confirmed each action's runtime is node24 (or
composite/docker); no @vn, @master, or @main refs remain under
.github/workflows. actionlint reports the same 16 pre-existing
shellcheck notes as main and nothing new. ops-image.yml and
nightly-impl-sync.yml have no pull_request trigger, so their pins are
verified by the resolver rather than a run. .github/dependabot.yml
already carries the github-actions group from #349 and is unchanged.

Closes #317

The red Check for Changes is the #568 migration-currency guard: this
branch inherits #559's base, which is five commits behind main and
predates 0058eventspagination.go. It clears with the rebase once #559
merges; as a draft this PR does not deploy to dev, so the guard's
crash-loop scenario cannot occur here.
… their OpDivs (#574)

Closes CMS-Enterprise/ztmf-misc#356.

An OpDiv-scoped admin could not set FIPS or the other system attributes
on their own systems. `SaveFismaSystem` nilled `hva`, `fips`,
`system_type`, `cloud_system`, `cloud_service_model`, `cloud_vendor`,
`system_operator`, `goco_coco_gogo`, and `legacy` on POST and restored
the stored values on PUT for any caller without unscoped read, then
returned success. The frontend has let every write admin edit those
fields since ztmf-ui#459, so an HRSA admin saw an editable field whose
value never persisted (reported by HRSA on 2026-08-28).

The gate's stated purpose was partial-PUT protection for fields the form
could not edit. `Save` already provides that for everyone: an omitted or
null field leaves the stored value untouched. So this removes
`clearUnscopedOnlyFields` and `preserveUnscopedOnlyFields` and their
branch. The update path keeps `guardManageFismaSystem` for callers
without unscoped read, so an OPDIV_ADMIN still gets 403 on a system
outside their granted OpDivs and 404 on a missing one. OWNER and
HHS_ADMIN paths are unchanged, and read-only tiers are still rejected at
`IsAdmin()`.

`fismasystems_metadata_test.go` is deleted; its four tests only
exercised the removed helpers. Emberfall Test B is rewritten: the OpDiv
admin's create now asserts every attribute persisted, the update sends a
different value for each attribute and contact field and the GET asserts
they landed, and a new case pins the cross-OpDiv PUT 403. `cloud_system`
stays true in the update because `Save` rejects a service model or
vendor when it is No.

Gate: `make test-unit` green, `make test-integration` green, `make
test-e2e` 219/219 (218 on main), `make generate-openapi` no drift. No
frontend change is needed; ztmf-ui already sends these fields.
…emails.group (#580)

Closes CMS-Enterprise/ztmf-misc#297. Closes CMS-Enterprise/ztmf-ui#653,
which reports the same 400 from the frontend side.

The EmailModal offers READONLY_ADMIN as a recipient group but
massEmailGroups had no such key, so the request failed validation.
Mack's call on the ticket was to add the backend group rather than drop
the option, since read-only admins are a small but distinct cohort at
CMS and likely at other OpDivs.

This adds a READONLY_ADMIN selector covering HHS_READONLY_ADMIN and
OPDIV_READONLY_ADMIN, mirroring how ADMIN covers the write tiers. ALL
still excludes every admin tier.

While wiring it up I found that massemails."group" was still the
VARCHAR(5) from migration 0011, so Save rejects any key longer than five
characters before recipients are resolved. SYSTEM_DELEGATE (15 chars)
hits this today and returns 500; I confirmed it against a clean dev
stack. Migration 0059 widens the column to VARCHAR(30), the same width
users.role got in 0035, so READONLY_ADMIN and SYSTEM_DELEGATE both save.
I also ran 0059 against a populated dev DB to check the upgrade path,
not just a fresh schema.

Behavior changes: POST /massemails with group READONLY_ADMIN goes from
400 to 201 and emails the read-only admin tiers. Group SYSTEM_DELEGATE
goes from 500 to 201. No API shape changes, openapi.yaml regenerates
with no drift.

Tests: unit tests pin that every group key fits the widened column and
that the READONLY_ADMIN query names exactly the two read-only roles.
Integration tests round-trip every key through Save (the check that
would have caught the width cap) and assert the READONLY_ADMIN audience
against the empire seed. Emberfall gains 201 cases for READONLY_ADMIN
and SYSTEM_DELEGATE; the test stack has no SMTP so the send goroutine
logs a dial error and nothing leaves.

Gate: make test-unit, make test-integration, and make test-e2e (221
passed, 0 failed) all green locally.

Not changed here: none of the role-based recipient queries filter
deleted users, so a deactivated admin or delegate still receives mail.
That predates this change and applies to every group, so I will file it
separately. Any service account carrying HHS_READONLY_ADMIN will be in
the READONLY_ADMIN audience.
…js (#575)

## What

Updates `make frontend-env` for CMS-Enterprise/ztmf-ui#722
(ztmf-misc#351): the local auth-bypass token now lands in
`ztmf-ui/public/config.js`, read by the app at runtime, instead of
`VITE_AUTH_TOKEN3` in `.env.development.local`, which nothing reads once
#722 is in. The env file keeps the vite proxy settings
(`VITE_CF_DOMAIN`, `VITE_IDP_ENABLED`); the retired `VITE_LOCAL_DEV`
flag is no longer written. Token generation is unchanged.

## Merge order

Landed after CMS-Enterprise/ztmf-ui#722 (merged 2026-09-08). The order
mattered for safety, not convenience: #722 is what gitignores
`public/config.js`, and `public/` is tracked, so on the old ui main this
target would have left a live admin JWT untracked and unignored, one
`git add -A` from a public commit. It would also have broken local-dev
auth for anyone still on the env token.

## Verification

`make frontend-env` emits both files, and the generated token
authenticates against the local dev API (200 on `/api/v1/users/current`,
401 without it). No Go changes; the full local suite runs via the
pre-push hook.
Bumps the github-actions group with 2 updates:
[terraform-linters/setup-tflint](https://github.com/terraform-linters/setup-tflint)
and
[aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials).

Updates `terraform-linters/setup-tflint` from 6.3.0 to 6.3.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/terraform-linters/setup-tflint/releases">terraform-linters/setup-tflint's
releases</a>.</em></p>
<blockquote>
<h2>v6.3.1</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Features</h3>
<ul>
<li>Build dist on release instead of committing it to master by <a
href="https://github.com/bendrucker"><code>@​bendrucker</code></a> in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/447">terraform-linters/setup-tflint#447</a></li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>build(deps-dev): Bump the eslint group across 1 directory with 7
updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/457">terraform-linters/setup-tflint#457</a></li>
<li>build(deps): Bump actions/setup-node from 6.4.0 to 7.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/458">terraform-linters/setup-tflint#458</a></li>
<li>build(deps): Bump actions/checkout from 6.0.2 to 7.0.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/459">terraform-linters/setup-tflint#459</a></li>
<li>build(deps-dev): Bump <code>@​humanfs/node</code> from 0.16.6 to
0.16.8 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/463">terraform-linters/setup-tflint#463</a></li>
<li>build(deps-dev): Bump browserslist from 4.28.2 to 4.28.8 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/464">terraform-linters/setup-tflint#464</a></li>
<li>build(deps-dev): Bump prettier from 3.8.3 to 3.9.6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/455">terraform-linters/setup-tflint#455</a></li>
<li>build(deps-dev): Bump globals from 17.5.0 to 17.11.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/454">terraform-linters/setup-tflint#454</a></li>
<li>build(deps): Bump actions/cache from 5.0.5 to 6.1.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/450">terraform-linters/setup-tflint#450</a></li>
<li>build(deps): Bump undici from 6.24.0 to 6.28.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/456">terraform-linters/setup-tflint#456</a></li>
<li>build(deps-dev): Bump brace-expansion from 1.1.13 to 1.1.18 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/465">terraform-linters/setup-tflint#465</a></li>
<li>build(deps): Bump <code>@​actions/cache</code> from 6.0.1 to 6.2.0
in the actions group across 1 directory by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/terraform-linters/setup-tflint/pull/451">terraform-linters/setup-tflint#451</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/terraform-linters/setup-tflint/compare/v6.3.0...v6.3.1">https://github.com/terraform-linters/setup-tflint/compare/v6.3.0...v6.3.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/1cf010d3c7aef302051ccdb68c14c5dc2efa34ef"><code>1cf010d</code></a>
Release v6.3.1</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/7d1a4fffc61fa34a7448ad8d91a6aa64cccf6764"><code>7d1a4ff</code></a>
build(deps): Bump <code>@​actions/cache</code> in the actions group
across 1 directory (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/451">#451</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/1db284159cbed99dacdbd13359fb79057a572d91"><code>1db2841</code></a>
build(deps-dev): Bump brace-expansion from 1.1.13 to 1.1.18 (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/465">#465</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/a87802ed8b3d0085978a2a1e8fdaf903a8eeb216"><code>a87802e</code></a>
build(deps): Bump undici from 6.24.0 to 6.28.0 (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/456">#456</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/bf78bc1c4058e8c7ac027596aa0bca02ad8b0a9b"><code>bf78bc1</code></a>
build(deps): Bump actions/cache from 5.0.5 to 6.1.0 (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/450">#450</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/0e90682785121c1789d948a6ea8da941beecbadd"><code>0e90682</code></a>
build(deps-dev): Bump globals from 17.5.0 to 17.11.0 (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/454">#454</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/2e3e73f9f031d87cee574f2e82973a38a33fc369"><code>2e3e73f</code></a>
build(deps-dev): Bump prettier from 3.8.3 to 3.9.6 (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/455">#455</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/c2c09245628fd3cf3790ea5b5ce1eddd15ca261e"><code>c2c0924</code></a>
Build dist on release instead of committing it to master (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/447">#447</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/cd824714eaa30e35352ff29ab7e24a4c50089cea"><code>cd82471</code></a>
build(deps-dev): Bump browserslist from 4.28.2 to 4.28.8 (<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/464">#464</a>)</li>
<li><a
href="https://github.com/terraform-linters/setup-tflint/commit/b1e96471b81b9e8bdabe7df3f214b344c99844f6"><code>b1e9647</code></a>
build(deps-dev): Bump <code>@​humanfs/node</code> from 0.16.6 to 0.16.8
(<a
href="https://redirect.github.com/terraform-linters/setup-tflint/issues/463">#463</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/terraform-linters/setup-tflint/compare/6e1e0642c0289bd619021bf6b34e3c08ed1e005a...1cf010d3c7aef302051ccdb68c14c5dc2efa34ef">compare
view</a></li>
</ul>
</details>
<br />

Updates `aws-actions/configure-aws-credentials` from 6.2.3 to 6.2.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/aws-actions/configure-aws-credentials/releases">aws-actions/configure-aws-credentials's
releases</a>.</em></p>
<blockquote>
<h2>v6.2.4</h2>
<h2><a
href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.3...v6.2.4">6.2.4</a>
(2026-08-31)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>account-ids handling, mask proxy as secret in logs (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1943">#1943</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/aa6526434b08748f8776b29964e3f1f5d90e7b63">aa65264</a>)</li>
<li>skip backoff sleep after the final retryAndBackoff attempt (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1937">#1937</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/3852440c21363386b7b790605685d08a7c1a4876">3852440</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md">aws-actions/configure-aws-credentials's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this project will be documented in this file.
See <a
href="https://github.com/conventional-changelog/standard-version">standard-version</a>
for commit guidelines.</p>
<h2><a
href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.3...v6.2.4">6.2.4</a>
(2026-08-31)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>account-ids handling, mask proxy as secret in logs (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1943">#1943</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/aa6526434b08748f8776b29964e3f1f5d90e7b63">aa65264</a>)</li>
<li>skip backoff sleep after the final retryAndBackoff attempt (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1937">#1937</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/3852440c21363386b7b790605685d08a7c1a4876">3852440</a>)</li>
</ul>
<h2><a
href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.2...v6.2.3">6.2.3</a>
(2026-07-22)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>attach git credentials before Tag Major Version push (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1877">#1877</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/9ae780b171afa8c5a3a6a2d154a765b709492482">9ae780b</a>)</li>
<li>PackedPolicyTooLarge detection in STS tags (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1899">#1899</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/fa8d6a57bbf44b34439fb080bbdadc7c92c285eb">fa8d6a5</a>)</li>
</ul>
<h2><a
href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.1...v6.2.2">6.2.2</a>
(2026-07-07)</h2>
<h3>Miscellaneous Chores</h3>
<ul>
<li>release 6.2.2 (<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/d01d678e65d6d2bd9d5ca7a95d6f07b00e25f2c2">d01d678</a>)</li>
</ul>
<h2><a
href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.0...v6.2.1">6.2.1</a>
(2026-06-26)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>enforce allowed-account-ids on all auth paths (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1847">#1847</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/4d281fbc56a82e63c3fc14f2cc22361f34c97493">4d281fb</a>)</li>
</ul>
<h2><a
href="https://github.com/aws-actions/configure-aws-credentials/compare/v6.1.3...v6.2.0">6.2.0</a>
(2026-06-01)</h2>
<h3>Features</h3>
<ul>
<li>add additional session tags by default (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1775">#1775</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/e0ba7685077379a14a82d01fefd511490344ebfc">e0ba768</a>)</li>
<li>add more retry logic and better logging (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1764">#1764</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/540d0c13aedb8d55501d220bd2f0b3cdedfe84e8">540d0c1</a>)</li>
<li>add regex validation to role-session-name (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1765">#1765</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/e35449909c6ede5083a48ba4b8bbfaaa1cf09ba1">e354499</a>)</li>
<li>Allow custom session tags to be passed when assuming a role (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1759">#1759</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/61f50f630f383628add73c1eab3f1935ba07da2b">61f50f6</a>)</li>
<li>expose run id in STS client user-agent (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1774">#1774</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/29d1be30273e7ef371d59fccf6ec54572c64ec89">29d1be3</a>)</li>
<li>support custom STS endpoints (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1762">#1762</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/8d52d05d7a4521fa52b39de50cb6114b12e5c332">8d52d05</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>skip credential check on output-env-credentials: false (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1778">#1778</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/58e7c47adf77846879008deadfeeef8a6969fe6c">58e7c47</a>)</li>
<li>assumeRole failing from session tag size too large (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1808">#1808</a>)
(<a
href="https://github.com/aws-actions/configure-aws-credentials/commit/d6f5dc331b44474b19a52caaf85fa4d637b13c8e">d6f5dc3</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/cbe3b392738ccf3f987d68400dafcf4b0624a56c"><code>cbe3b39</code></a>
chore(main): release 6.2.4 (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1942">#1942</a>)</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/58065db07c99675fc21675188b003b7f0b167004"><code>58065db</code></a>
chore(deps): bump js-yaml (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1944">#1944</a>)</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/609df23709e359dc01a42b4c5183ba71167ac38c"><code>609df23</code></a>
chore: Update dist</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/aa6526434b08748f8776b29964e3f1f5d90e7b63"><code>aa65264</code></a>
fix: account-ids handling, mask proxy as secret in logs (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1943">#1943</a>)</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/7fdbbb8968c49fb55011ace47efc7b0ccfc9a28f"><code>7fdbbb8</code></a>
chore: Update dist</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/3852440c21363386b7b790605685d08a7c1a4876"><code>3852440</code></a>
fix: skip backoff sleep after the final retryAndBackoff attempt (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1937">#1937</a>)</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/c16f89bdf4cd065448ea7bde8b96a1dad4c77e41"><code>c16f89b</code></a>
mention renamed repos use the new immutable identifiers (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1941">#1941</a>)</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/9c362eeba7ac7d0419073a8b4af5a83e49e2afaf"><code>9c362ee</code></a>
chore: Update dist</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/d5f8da8822f961cd3016c2cf87aad7e80b40558e"><code>d5f8da8</code></a>
chore(deps): bump <code>@​aws-sdk/client-sts</code> from 3.1111.0 to
3.1116.0 (<a
href="https://redirect.github.com/aws-actions/configure-aws-credentials/issues/1935">#1935</a>)</li>
<li><a
href="https://github.com/aws-actions/configure-aws-credentials/commit/2db24970cf129d7ff6fc04639072bcbe35f8c288"><code>2db2497</code></a>
chore: Update dist</li>
<li>Additional commits viewable in <a
href="https://github.com/aws-actions/configure-aws-credentials/compare/e6de054238d6b7531b4efff3b6587d9aade6a06c...cbe3b392738ccf3f987d68400dafcf4b0624a56c">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

4 participants