HOMELAB-2479: fix(zitadel): match user config to state (Nuila; zero-drift users) #916

Merged
pi-landing merged 1 commit from plane/HOMELAB-2479-zitadel-user-drift into live 2026-11-10 20:39:36 +00:00
Collaborator

Config only. Makes the additional_users object able to carry the profile fields
Zitadel already holds, so a plan stops rewriting those users to the proto
defaults. No apply, no plan run, no landing — nothing is merged or applied
without a later go.

Recorded drift (ground truth — status line 904 ROUTE-I PLAN-GATE-FAIL, 2026-11-10T12:24:45Z)

The targeted-apply plan gate for #904 recorded pre-existing drift on two human
users (direction: state → planned, i.e. the code is what would change them):

  • zitadel_human_user.additional["enuila1989@gmail.com"]: gender MALE→UNSPECIFIED, last_name Nuila→Unuila, nick_name enuila→null, preferred_language en→und
  • zitadel_human_user.additional["william@reynoza.org"]: gender MALE→UNSPECIFIED, nick_name wdiaz→null

Aaron, verbatim (via El Director, 14:57:11Z): "Manuel es Nuila." The code's
Unuila is the typo.

Cause: variable "additional_users" declared only email, first_name,
last_name, roles, so the resource could not express gender, nick_name or
preferred_language and the provider rewrote them to the defaults on the next
apply.

What this PR changes (2 files, both in core/terraform/live/zitadel/)

variables.tf — three optional attributes on the additional_users object type:

attribute type provider spelling
gender optional(string) GENDER_UNSPECIFIED | GENDER_FEMALE | GENDER_MALE | GENDER_DIVERSE
nick_name optional(string) free string
preferred_language optional(string) ISO code, e.g. en

main.tf — zitadel_human_user.additional passes them through
(gender / nick_name / preferred_language). All are null by default,
which is exactly what an omitted attribute meant before, so no user gains a diff
from this PR alone.

Attribute names and the gender enum were read from the installed provider
(terraform init -backend=false → terraform providers schema -json,
zitadel/zitadel v3.8.7 — the version this root resolves today), not from
memory. Nothing is hand-spelled: terraform fmt -check clean, terraform validate → Success! The configuration is valid. No credentials, no backend, no
state access.

HONEST SCOPE NOTE — this PR alone does NOT reach 0-diff

zitadel_human_user.additional takes its values from additional_users, which
is set in prod zitadel/terraform.tfvars — the file the plan/apply
workflow passes as -var-file (.forgejo/workflows/infra-apply.yaml:
-var-file="$GITHUB_WORKSPACE/zitadel/terraform.tfvars", workspace root = the
prod checkout; the tfvars is gitignored in infra-core). The six values therefore
cannot be set from infra-core; they are a companion prod-tfvars change:

  { email = "william@reynoza.org",  gender = "GENDER_MALE", nick_name = "wdiaz",   ... }
  { email = "enuila1989@gmail.com", gender = "GENDER_MALE", nick_name = "enuila",
    last_name = "Nuila",            preferred_language = "en",                    ... }

(plus the # HOMELAB-2191: Manuel Unuila comment → Nuila). Merge order matters:
this schema must be on live before prod's tfvars carries the new keys, or the
next plan fails with "Unsupported argument".

Flagged, not improvised: no prod file is touched by this PR.

No terraform run

No apply, no plan, no state read — no credentials are named for a plan proof;
the plan proof awaits El Director naming
. Verification here is by inspection
against the recorded 12:24Z plan output plus fmt/validate.

Seats: 2. NO landing without go.

Config only. Makes the `additional_users` object able to carry the profile fields Zitadel already holds, so a plan stops rewriting those users to the proto defaults. **No apply, no plan run, no landing** — nothing is merged or applied without a later go. ## Recorded drift (ground truth — status line `904 ROUTE-I PLAN-GATE-FAIL`, 2026-11-10T12:24:45Z) The targeted-apply plan gate for #904 recorded pre-existing drift on two human users (direction: state → planned, i.e. the *code* is what would change them): - `zitadel_human_user.additional["enuila1989@gmail.com"]`: gender `MALE`→`UNSPECIFIED`, last_name `Nuila`→`Unuila`, nick_name `enuila`→`null`, preferred_language `en`→`und` - `zitadel_human_user.additional["william@reynoza.org"]`: gender `MALE`→`UNSPECIFIED`, nick_name `wdiaz`→`null` Aaron, verbatim (via El Director, 14:57:11Z): **"Manuel es Nuila."** The code's `Unuila` is the typo. Cause: `variable "additional_users"` declared only `email`, `first_name`, `last_name`, `roles`, so the resource could not express `gender`, `nick_name` or `preferred_language` and the provider rewrote them to the defaults on the next apply. ## What this PR changes (2 files, both in `core/terraform/live/zitadel/`) `variables.tf` — three optional attributes on the `additional_users` object type: | attribute | type | provider spelling | |---|---|---| | `gender` | `optional(string)` | `GENDER_UNSPECIFIED` \| `GENDER_FEMALE` \| `GENDER_MALE` \| `GENDER_DIVERSE` | | `nick_name` | `optional(string)` | free string | | `preferred_language` | `optional(string)` | ISO code, e.g. `en` | `main.tf` — `zitadel_human_user.additional` passes them through (`gender` / `nick_name` / `preferred_language`). All are `null` by default, which is exactly what an omitted attribute meant before, so no user gains a diff from this PR alone. Attribute names and the gender enum were read from the installed provider (`terraform init -backend=false` → `terraform providers schema -json`, `zitadel/zitadel` **v3.8.7** — the version this root resolves today), not from memory. Nothing is hand-spelled: `terraform fmt -check` clean, `terraform validate` → `Success! The configuration is valid.` No credentials, no backend, no state access. ## HONEST SCOPE NOTE — this PR alone does NOT reach 0-diff `zitadel_human_user.additional` takes its *values* from `additional_users`, which is set in **`prod` `zitadel/terraform.tfvars`** — the file the plan/apply workflow passes as `-var-file` (`.forgejo/workflows/infra-apply.yaml`: `-var-file="$GITHUB_WORKSPACE/zitadel/terraform.tfvars"`, workspace root = the prod checkout; the tfvars is gitignored in infra-core). The six values therefore cannot be set from infra-core; they are a companion prod-tfvars change: ```hcl { email = "william@reynoza.org", gender = "GENDER_MALE", nick_name = "wdiaz", ... } { email = "enuila1989@gmail.com", gender = "GENDER_MALE", nick_name = "enuila", last_name = "Nuila", preferred_language = "en", ... } ``` (plus the `# HOMELAB-2191: Manuel Unuila` comment → `Nuila`). Merge order matters: this schema must be on `live` before prod's tfvars carries the new keys, or the next plan fails with "Unsupported argument". Flagged, not improvised: no prod file is touched by this PR. ## No terraform run No apply, no plan, no state read — **no credentials are named for a plan proof; the plan proof awaits El Director naming**. Verification here is by inspection against the recorded 12:24Z plan output plus `fmt`/`validate`. **Seats: 2. NO landing without go.**
HOMELAB-2479: fix(zitadel): match user config to state (Nuila; zero-drift users)
All checks were successful
CI Review / runner-manifest-test (pull_request) Successful in 4s
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 10s
Lint & Validate / shellcheck (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 21s
Lint & Validate / yaml-lint (pull_request) Successful in 23s
CI Review / record-sql-test (pull_request) Successful in 49s
Lint & Validate / terraform-validate (pull_request) Successful in 2m9s
CI Review / cockpit-test (pull_request) Successful in 3m35s
CI Review / cockpit-e2e (pull_request) Successful in 3m40s
CI Review / dispatcher-test (pull_request) Successful in 4m11s
f308cdb23d
The 12:24Z targeted-apply plan gate recorded pre-existing drift on the two
zitadel_human_user.additional entries: gender MALE->UNSPECIFIED, nick_name
->null, preferred_language en->und. Cause: the additional_users object type had
no profile fields, so the provider rewrote every user to the proto defaults on
the next apply.

Add them to the variable (optional, null default = previous behaviour) and wire
them through the resource, with the provider's own spellings
(GENDER_UNSPECIFIED|GENDER_FEMALE|GENDER_MALE|GENDER_DIVERSE; preferred_language
an ISO code). No apply, no plan run here.
ld-reviewer approved these changes 2026-11-10 20:26:00 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: f308cdb23d
session: seat-916-A-a91c
tier: worker

bridge: sbx-lab verdict-cli, prerequisite 4b failing
model=omniroute/deepseek-flash session=seat-916-A-a91c author=ld-reviewer
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#916

Independent review — seat 916 (fresh context, own measurement)

Verdict: APPROVE at head f308cdb23d3a395306dfa3ce72ff72e597d7000d.

model: omniroute/deepseek-flash

Head

git ls-remote on refs/pull/916/head as ld-reviewer, through the credential
helper the mandated refs helper writes (its sha256 f795d0fd… is byte-identical
in my sandbox, stderr empty), returned f308cdb2… — the pin I was given.
refs/heads/live is 96f2f658…, which is the PR's base. I read 0 reviews on the
PR when I started, so nothing in this verdict is inherited.

Exactly the two files the body claims, and nothing else

The PR's own diff is two files, +19 / −0: variables.tf +12, main.tf +7, both
under core/terraform/live/zitadel/.

I did not stop at the hunk list. I fetched both files whole at the head and at the
base and diffed them: variables.tf differs by exactly those 12 added lines and
versions.tf is byte-identical (614 bytes at both shas), so no provider block, no
required_providers constraint and no other root moved. main.tf's only change in
the changed region is the seven added lines inside zitadel_human_user.additional.

One thing a reader should not misread, and I checked it rather than assumed: the
head is behind live (merge-base 2b17d62, live tip 96f2f6…), so a naive
head-versus-live-tip diff reads as a 114-line deletion of the
invite-list-reader machine-user block. That block lives on live and not on the
branch; it is not part of this PR. I fetched both refs into a scratch clone and
ran the actual no-commit three-way merge: "Automatic merge went well", 0
conflicts, the block still present, and the merged main.tf differs from live by
exactly the 6 non-blank added lines (3 comment + 3 attribute) with variables.tf
= live + 12 lines. So landing this is +19 lines on live and deletes nobody's work.
A rebase before landing is tidiness, not a fix.

Attribute names, defaults and the enum, from provider SOURCE at the tag

Read at zitadel/terraform-provider-zitadel v3.8.7, not from memory:

  • zitadel/human_user/const.go: nickNameVar = "nick_name",
    preferredLanguageVar = "preferred_language", genderVar = "gender" — the
    three names in the diff, spelled exactly as the provider spells them.
  • resource.go: gender is Optional with Default: defaultGenderString and a
    ValidateDiagFunc over user.Gender_value; preferred_language is Optional
    with Default: defaultPreferredLanguage; nick_name is Optional with no
    default.
  • const.go: defaultGenderString = "GENDER_UNSPECIFIED",
    defaultPreferredLanguage = "und" — precisely the two values the 12:24Z drift
    record shows the plan writing (MALE→UNSPECIFIED, en→und). The record and the
    provider agree, which is what makes the mechanism in the body credible.
  • The enum spelling in the variables.tf comment is right: Zitadel's proto
    Gender is GENDER_UNSPECIFIED = 0, GENDER_FEMALE = 1, GENDER_MALE = 2,
    GENDER_DIVERSE = 3 — all four, no hand-spelling.

No force-new, and I do not cite schema -json for it (that report says
force_new=false for every SDKv2 attribute). resource.go puts no ForceNew on
any of the three, and update() gates on
d.HasChanges(first_name, last_name, nick_name, display_name, preferred_language, gender)
and then sends only the changed profile fields through UpdateUser. Setting a
value therefore updates the human user in place — no replacement, so no
destroy/create of a user and no cascade onto its zitadel_user_grant.

Null costs nobody a diff — and the PR still does not reach 0-diff alone

optional(string) with no default yields null. For gender and
preferred_language, null takes the provider's own default, which is exactly what
an omitted attribute meant before — that is why the 12:24Z plan wanted to rewrite
them, and it is why this PR adds no new diff anywhere. For nick_name, null is
"", also identical to omission.

So the body's "no user gains a diff from this PR alone" holds, and so does its
honest scope note: for a user whose live nick_name is non-null and whose tfvars
does not set it, the plan still wants to clear it until the companion prod tfvars
change lands. The direction is the one required here — code carries the live
value, state is not rewritten by this PR, and nothing in it changes a live profile
on its own.

Claim honesty and reproducibility

Every claim the body makes that I can measure without credentials survives: two
files, the scope note about prod zitadel/terraform.tfvars, the merge-order
warning ("Unsupported argument" if the tfvars lands first), no plan, no apply, no
state read. terraform validate is genuinely reproducible credential-free here:
CI's Lint & Validate / terraform-validate job runs terraform init -backend=false
and terraform validate across every core/terraform directory, and it is green
at this head (11 contexts, all success). terraform fmt -check is not CI-enforced
and my sandbox has no terraform binary, so I checked the new attribute lines by
inspection instead: they align as their own group against preferred_language,
the lines above them are untouched, and I see no formatting churn — non-blocking,
and I am not asserting the author's own fmt run.

What would still be wrong if merged today

Nothing in this PR. The one residual exposure is outside it and correctly flagged
in the body: the schema must be on live before prod's tfvars carries the three
new keys, and the six values themselves are that companion change. Whoever lands
this should not treat it as the fix, only as the precondition for it.

Notes to orchestrator, posted nowhere:

## Verdict: APPROVED model: omniroute/deepseek-flash head: f308cdb23d3a395306dfa3ce72ff72e597d7000d session: seat-916-A-a91c tier: worker bridge: sbx-lab verdict-cli, prerequisite 4b failing model=omniroute/deepseek-flash session=seat-916-A-a91c author=ld-reviewer LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#916 ## Independent review — seat 916 (fresh context, own measurement) Verdict: APPROVE at head `f308cdb23d3a395306dfa3ce72ff72e597d7000d`. model: omniroute/deepseek-flash ### Head `git ls-remote` on `refs/pull/916/head` as `ld-reviewer`, through the credential helper the mandated refs helper writes (its sha256 `f795d0fd…` is byte-identical in my sandbox, stderr empty), returned `f308cdb2…` — the pin I was given. `refs/heads/live` is `96f2f658…`, which is the PR's base. I read 0 reviews on the PR when I started, so nothing in this verdict is inherited. ### Exactly the two files the body claims, and nothing else The PR's own diff is two files, +19 / −0: `variables.tf` +12, `main.tf` +7, both under `core/terraform/live/zitadel/`. I did not stop at the hunk list. I fetched both files whole at the head and at the base and diffed them: `variables.tf` differs by exactly those 12 added lines and `versions.tf` is byte-identical (614 bytes at both shas), so no provider block, no required_providers constraint and no other root moved. `main.tf`'s only change in the changed region is the seven added lines inside `zitadel_human_user.additional`. One thing a reader should not misread, and I checked it rather than assumed: the head is behind live (merge-base `2b17d62`, live tip `96f2f6…`), so a naive head-versus-live-tip diff reads as a 114-line deletion of the `invite-list-reader` machine-user block. That block lives on `live` and not on the branch; it is not part of this PR. I fetched both refs into a scratch clone and ran the actual no-commit three-way merge: "Automatic merge went well", 0 conflicts, the block still present, and the merged `main.tf` differs from live by exactly the 6 non-blank added lines (3 comment + 3 attribute) with `variables.tf` = live + 12 lines. So landing this is +19 lines on live and deletes nobody's work. A rebase before landing is tidiness, not a fix. ### Attribute names, defaults and the enum, from provider SOURCE at the tag Read at `zitadel/terraform-provider-zitadel` `v3.8.7`, not from memory: - `zitadel/human_user/const.go`: `nickNameVar = "nick_name"`, `preferredLanguageVar = "preferred_language"`, `genderVar = "gender"` — the three names in the diff, spelled exactly as the provider spells them. - `resource.go`: `gender` is Optional with `Default: defaultGenderString` and a `ValidateDiagFunc` over `user.Gender_value`; `preferred_language` is Optional with `Default: defaultPreferredLanguage`; `nick_name` is Optional with no default. - `const.go`: `defaultGenderString = "GENDER_UNSPECIFIED"`, `defaultPreferredLanguage = "und"` — precisely the two values the 12:24Z drift record shows the plan writing (`MALE→UNSPECIFIED`, `en→und`). The record and the provider agree, which is what makes the mechanism in the body credible. - The enum spelling in the `variables.tf` comment is right: Zitadel's proto `Gender` is `GENDER_UNSPECIFIED = 0`, `GENDER_FEMALE = 1`, `GENDER_MALE = 2`, `GENDER_DIVERSE = 3` — all four, no hand-spelling. **No force-new, and I do not cite `schema -json` for it** (that report says `force_new=false` for every SDKv2 attribute). `resource.go` puts no `ForceNew` on any of the three, and `update()` gates on `d.HasChanges(first_name, last_name, nick_name, display_name, preferred_language, gender)` and then sends only the changed profile fields through `UpdateUser`. Setting a value therefore updates the human user in place — no replacement, so no destroy/create of a user and no cascade onto its `zitadel_user_grant`. ### Null costs nobody a diff — and the PR still does not reach 0-diff alone `optional(string)` with no default yields null. For `gender` and `preferred_language`, null takes the provider's own default, which is exactly what an omitted attribute meant before — that is why the 12:24Z plan wanted to rewrite them, and it is why this PR adds no new diff anywhere. For `nick_name`, null is `""`, also identical to omission. So the body's "no user gains a diff from this PR alone" holds, and so does its honest scope note: for a user whose live `nick_name` is non-null and whose tfvars does not set it, the plan still wants to clear it until the companion prod tfvars change lands. The direction is the one required here — code carries the live value, state is not rewritten by this PR, and nothing in it changes a live profile on its own. ### Claim honesty and reproducibility Every claim the body makes that I can measure without credentials survives: two files, the scope note about `prod` `zitadel/terraform.tfvars`, the merge-order warning ("Unsupported argument" if the tfvars lands first), no plan, no apply, no state read. `terraform validate` is genuinely reproducible credential-free here: CI's `Lint & Validate / terraform-validate` job runs `terraform init -backend=false` and `terraform validate` across every `core/terraform` directory, and it is green at this head (11 contexts, all success). `terraform fmt -check` is not CI-enforced and my sandbox has no terraform binary, so I checked the new attribute lines by inspection instead: they align as their own group against `preferred_language`, the lines above them are untouched, and I see no formatting churn — non-blocking, and I am not asserting the author's own fmt run. ### What would still be wrong if merged today Nothing in this PR. The one residual exposure is outside it and correctly flagged in the body: the schema must be on `live` before prod's tfvars carries the three new keys, and the six values themselves are that companion change. Whoever lands this should not treat it as the fix, only as the precondition for it. Notes to orchestrator, posted nowhere:
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: f308cdb23d
session: seat-916-B-b2c7
tier: worker

Independent dangerous-class seat B review of Homelab/infra-core#916, measured from primary sources only (PR diff at the pinned head, the root's own files, and zitadel/terraform-provider-zitadel source at the tag the root resolves). This is my own measurement, formed on primary sources before seeing the earlier seat's verdict.

What I verified, and how

Head. git ls-remote <forgejo>/Homelab/infra-core.git refs/pull/916/head as ld-reviewer (mandated credential-helper route) returned f308cdb23d3a395306dfa3ce72ff72e597d7000d, equal to the API's head.sha; base refs/heads/live = 96f2f65865c68cefaec6731135f405f63b5e2e56, equal to the PR's base.sha. The verdict is pinned to that 40-hex head.

Diff hygiene. pulls/916/files lists exactly two paths, both under core/terraform/live/zitadel/: main.tf (+7), variables.tf (+12), 0 deletions, 19 additions total. No provider/versions.tf edit, no lockfile, no other root, no resource block rewritten — the three added arguments are the only resource-level change, appended inside resource "zitadel_human_user" "additional".

Provider truth at the installed version (not from memory, not from schema -json for ForceNew). terraform init -backend=false on the head tree resolves zitadel/zitadel v3.8.7 — so the body's version claim is true today, measured, not asserted. At tag v3.8.7, zitadel/human_user/const.go declares genderVar = "gender", nickNameVar = "nick_name", preferredLanguageVar = "preferred_language", and resource.go carries one schema entry per name. The gender validator is EnumValueValidation(..., user.Gender_value) and the generated doc for that tag lists GENDER_UNSPECIFIED, GENDER_FEMALE, GENDER_MALE, GENDER_DIVERSE — the four spellings the variables.tf comment names. Names and enum match the installed provider exactly.

ForceNew, anchored to source. None of the three attributes carries ForceNew in resource.go at v3.8.7 (the ForceNew fields there are user_id, login_names, preferred_login_name, idp_links, metadata). So carrying these values later cannot destroy-and-recreate a human user; the companion prod tfvars change will update profiles in place.

Convergence, anchored to source. update() gates on d.HasChanges(first_name, last_name, nick_name, display_name, preferred_language, gender) and sends NickName, Gender and the preferred language for exactly those three fields; readFunc writes them back to state (profile.NickName, profile.PreferredLanguage, profile.Gender.String()). Values set in the companion PR will therefore converge instead of producing a permanent diff.

Default-null no-diff property. optional(string) with no explicit default yields null. For gender and preferred_language the schema carries Default: "GENDER_UNSPECIFIED" / Default: "und", and nick_name has no default (zero value). A null in config for an SDKv2 attribute with a Default resolves to that default, which is the same value an omitted attribute produced before this PR; null for nick_name is the same as the omission that the 12:24Z plan recorded as enuila -> null. So users that never set these keys gain no diff from this PR: the change is capability only, and I agree with the direction — it makes code able to carry what Zitadel already holds, and it changes no live profile.

Corroboration of the drift record. The two defaults above are exactly GENDER_UNSPECIFIED and und, i.e. the planned side of the recorded MALE->UNSPECIFIED and en->und drift, and nick_name has no default, i.e. the recorded ->null. last_name Nuila->Unuila is untouched by this diff because last_name was already in the type; that half of the record is the prod-tfvars companion's job, as the body says.

Blast radius inside the root. additional_users is consumed by exactly two for_each expressions at the head: the human-user resource (which is what gains the fields) and zitadel_user_grant.additional_project, which reads only each.value.roles and is unaffected by three extra object attributes. outputs.tf does not expose the variable and terraform.tfvars.example still declares [].

fmt / validate, reproduced without credentials. terraform fmt -check -diff on the head files: clean, exit 0. terraform init -backend=false + terraform validate on the head tree with the real v3.8.7 provider: Success! The configuration is valid. — an independently run check, not a restatement of the body. CI at this exact head is green on all 11 statuses, including Lint & Validate / terraform-validate (which runs terraform init -backend=false then terraform validate across every core/terraform directory).

Claim honesty. Body vs diff agree: two files, three attributes, additions only; the scope note (values live in prod zitadel/terraform.tfvars, companion PR queued, schema must land first or Unsupported argument) is accurate and is why this PR alone does not reach 0-diff — stated plainly rather than hidden. No plan/apply/state claim is made and none is needed.

Blockers

None.

Notes (non-blocking, not this PR's scope)

  • This root has no committed .terraform.lock.hcl (404 at the root, at core/terraform/live/, and at the repo root) and constrains the provider as >= 2.10.0. "v3.8.7" is therefore today's resolution, not a pinned fact; a future 3.x major could tighten validation under the same config. Worth pinning separately.
  • At v3.8.7 the human_user resource is described as using the user/v2 API and requiring ZITADEL 4.x. The resource already plans against the live instance today (the 12:24Z gate read those two users), so this is not a delta introduced here — noted for whoever tracks the provider bump.

bridge: sbx-lab verdict-cli, prerequisite 4b failing
model=omniroute/deepseek-flash session=seat-916-B-b2c7 author=ld-reviewer
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#916
Notes to orchestrator, posted nowhere: (1) 4b leg re-measured on this head: ld-reviewer GET /repos/Homelab/infra-core/branch_protections returns 403 "user should be an owner or a collaborator with admin write of a repository" — required-ci stays UNKNOWN and the formal-review write itself is the documented route. (2) This seat brief's Verdict paragraph says to declare session: seat-916-A-<yoursuffix> while its own seat line says seat-916-B-<suffix>; that is a copy of seat A's brief. I declared B (this seat) — an A-line from a second independent seat would have collided with seat A's session and weakened the dangerous-class independence the lock reads. (3) The two decisions the companion prod PR must get right are the values themselves (last_name = "Nuila", gender = "GENDER_MALE", nick_name, preferred_language = "en" for enuila1989; gender/nick_name for william) and merge order — schema on live first, otherwise the next plan dies with Unsupported argument. (4) I ran fmt/validate/init -backend=false locally against the head tree with no credentials and no backend; nothing in the repo was written.

## Verdict: APPROVED model: omniroute/deepseek-flash head: f308cdb23d3a395306dfa3ce72ff72e597d7000d session: seat-916-B-b2c7 tier: worker Independent dangerous-class seat B review of Homelab/infra-core#916, measured from primary sources only (PR diff at the pinned head, the root's own files, and `zitadel/terraform-provider-zitadel` source at the tag the root resolves). This is my own measurement, formed on primary sources before seeing the earlier seat's verdict. ## What I verified, and how **Head.** `git ls-remote <forgejo>/Homelab/infra-core.git refs/pull/916/head` as `ld-reviewer` (mandated credential-helper route) returned `f308cdb23d3a395306dfa3ce72ff72e597d7000d`, equal to the API's `head.sha`; base `refs/heads/live` = `96f2f65865c68cefaec6731135f405f63b5e2e56`, equal to the PR's `base.sha`. The verdict is pinned to that 40-hex head. **Diff hygiene.** `pulls/916/files` lists exactly two paths, both under `core/terraform/live/zitadel/`: `main.tf` (+7), `variables.tf` (+12), 0 deletions, 19 additions total. No provider/`versions.tf` edit, no lockfile, no other root, no resource block rewritten — the three added arguments are the only resource-level change, appended inside `resource "zitadel_human_user" "additional"`. **Provider truth at the installed version (not from memory, not from `schema -json` for ForceNew).** `terraform init -backend=false` on the head tree resolves `zitadel/zitadel v3.8.7` — so the body's version claim is true today, measured, not asserted. At tag `v3.8.7`, `zitadel/human_user/const.go` declares `genderVar = "gender"`, `nickNameVar = "nick_name"`, `preferredLanguageVar = "preferred_language"`, and `resource.go` carries one schema entry per name. The `gender` validator is `EnumValueValidation(..., user.Gender_value)` and the generated doc for that tag lists `GENDER_UNSPECIFIED, GENDER_FEMALE, GENDER_MALE, GENDER_DIVERSE` — the four spellings the `variables.tf` comment names. Names and enum match the installed provider exactly. **ForceNew, anchored to source.** None of the three attributes carries `ForceNew` in `resource.go` at v3.8.7 (the `ForceNew` fields there are `user_id`, `login_names`, `preferred_login_name`, `idp_links`, `metadata`). So carrying these values later cannot destroy-and-recreate a human user; the companion prod tfvars change will update profiles in place. **Convergence, anchored to source.** `update()` gates on `d.HasChanges(first_name, last_name, nick_name, display_name, preferred_language, gender)` and sends `NickName`, `Gender` and the preferred language for exactly those three fields; `readFunc` writes them back to state (`profile.NickName`, `profile.PreferredLanguage`, `profile.Gender.String()`). Values set in the companion PR will therefore converge instead of producing a permanent diff. **Default-null no-diff property.** `optional(string)` with no explicit default yields `null`. For `gender` and `preferred_language` the schema carries `Default: "GENDER_UNSPECIFIED"` / `Default: "und"`, and `nick_name` has no default (zero value). A `null` in config for an SDKv2 attribute with a `Default` resolves to that default, which is the same value an omitted attribute produced before this PR; `null` for `nick_name` is the same as the omission that the 12:24Z plan recorded as `enuila -> null`. So users that never set these keys gain no diff from this PR: the change is capability only, and I agree with the direction — it makes code able to carry what Zitadel already holds, and it changes no live profile. **Corroboration of the drift record.** The two defaults above are exactly `GENDER_UNSPECIFIED` and `und`, i.e. the planned side of the recorded `MALE->UNSPECIFIED` and `en->und` drift, and `nick_name` has no default, i.e. the recorded `->null`. `last_name Nuila->Unuila` is untouched by this diff because `last_name` was already in the type; that half of the record is the prod-tfvars companion's job, as the body says. **Blast radius inside the root.** `additional_users` is consumed by exactly two `for_each` expressions at the head: the human-user resource (which is what gains the fields) and `zitadel_user_grant.additional_project`, which reads only `each.value.roles` and is unaffected by three extra object attributes. `outputs.tf` does not expose the variable and `terraform.tfvars.example` still declares `[]`. **fmt / validate, reproduced without credentials.** `terraform fmt -check -diff` on the head files: clean, exit 0. `terraform init -backend=false` + `terraform validate` on the head tree with the real v3.8.7 provider: `Success! The configuration is valid.` — an independently run check, not a restatement of the body. CI at this exact head is green on all 11 statuses, including `Lint & Validate / terraform-validate` (which runs `terraform init -backend=false` then `terraform validate` across every `core/terraform` directory). **Claim honesty.** Body vs diff agree: two files, three attributes, additions only; the scope note (values live in prod `zitadel/terraform.tfvars`, companion PR queued, schema must land first or `Unsupported argument`) is accurate and is why this PR alone does not reach 0-diff — stated plainly rather than hidden. No plan/apply/state claim is made and none is needed. ## Blockers None. ## Notes (non-blocking, not this PR's scope) - This root has no committed `.terraform.lock.hcl` (404 at the root, at `core/terraform/live/`, and at the repo root) and constrains the provider as `>= 2.10.0`. "v3.8.7" is therefore today's resolution, not a pinned fact; a future 3.x major could tighten validation under the same config. Worth pinning separately. - At v3.8.7 the `human_user` resource is described as using the user/v2 API and requiring ZITADEL 4.x. The resource already plans against the live instance today (the 12:24Z gate read those two users), so this is not a delta introduced here — noted for whoever tracks the provider bump. bridge: sbx-lab verdict-cli, prerequisite 4b failing model=omniroute/deepseek-flash session=seat-916-B-b2c7 author=ld-reviewer LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#916 Notes to orchestrator, posted nowhere: (1) 4b leg re-measured on this head: `ld-reviewer` GET `/repos/Homelab/infra-core/branch_protections` returns 403 "user should be an owner or a collaborator with admin write of a repository" — required-ci stays UNKNOWN and the formal-review write itself is the documented route. (2) This seat brief's Verdict paragraph says to declare `session: seat-916-A-<yoursuffix>` while its own seat line says `seat-916-B-<suffix>`; that is a copy of seat A's brief. I declared B (this seat) — an A-line from a second independent seat would have collided with seat A's session and weakened the dangerous-class independence the lock reads. (3) The two decisions the companion prod PR must get right are the values themselves (`last_name = "Nuila"`, `gender = "GENDER_MALE"`, `nick_name`, `preferred_language = "en"` for enuila1989; `gender`/`nick_name` for william) and merge order — schema on `live` first, otherwise the next plan dies with `Unsupported argument`. (4) I ran `fmt`/`validate`/`init -backend=false` locally against the head tree with no credentials and no backend; nothing in the repo was written.
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!916
No description provided.