Skip to content

Release 4.0.0 - #177

Merged
jgruberf5 merged 66 commits into
mainfrom
staging
Aug 19, 2026
Merged

Release 4.0.0#177
jgruberf5 merged 66 commits into
mainfrom
staging

Conversation

@jgruberf5

@jgruberf5 jgruberf5 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Promotes stagingmain to cut the 4.0.0 release.

Version: 4.0.0 (major) — corrected from 3.1.7

The initial description said 3.1.7 (patch). That was wrong, per @mwiget's review: 7ece9b04 (container-runner hardening, #2) declares a BREAKING CHANGE in its body — the non-root gate now refuses named users, so USER nonroot must become USER 65532. compute_version_bump.sh only scanned commit subjects, so it couldn't see the footer and silently derived a patch.

Depends on #178, which fixes the derivation to read bodies (→ major / 4.0.0, verified) and surfaces the breaking change + migration in the release notes and CHANGELOG. Merge #178 to staging first; then merging this promotes it and release.yml cuts v4.0.0 with the migration documented.

Breaking change in this release

  • Container runner non-root gate now rejects named users. An image using the distroless-standard USER nonroot is refused on 4.0.0 — change it to USER 65532. (The Kubernetes path already enforced the numeric-uid check; the two backends now agree.)

Contents

65 commits since the v3.1.6 seed — the full staging integration line, including this session's severity:medium fixes (#164#171, #173, #174) and the earlier critical/high and container-runner hardening (#2, which carries the break above).

Cutting 4.0.0 per maintainer decision.

bonnyr-f5 and others added 30 commits August 10, 2026 12:11
…, real cancel, step output

Four defects reported from live 3.1.6 use of the ibm-roks-new-bnk-roksbnkctl
blueprint. They are one theme: Forge could not be told to act on part of a
project and be trusted to stop there.

#525 — destroying a module destroyed its dependencies

submit_destroy created its Task with no destroy_scope, so the reverse-DAG event
chain fell through to the stack_instance_id heuristic and treated a single-module
destroy as a stack teardown. Destroying bnk-install therefore tore down
cluster-create: a ~48-minute ROKS cluster, its VPC and its Transit Gateway,
removed by a request that named only the application layer. There was no safe
alternative — the downstream content repo warns customers about this in three
places, carrying a product bug as documentation.

submit_destroy now stamps destroy_scope="module" and the chain stops there: no
dependency dispatch, and no parent-entity finalization (there is no stack or
project teardown in flight to finalize). The scope lookup moved into one
_destroy_scope_for() helper — the trigger and terminal-detection paths each had
their own copy and would otherwise have drifted.

#527 part 1 — a disabled module was dispatched anyway

Neither _trigger_next_stack_module nor _dispatch_first_wave consulted
module.enabled, so a downstream module ran the moment its predecessor finished
even when explicitly disabled. It was impossible to build a ROKS cluster without
BNK landing on it. This also silently voided the `optional: true` blueprint
guarantee, documented as "created but DISABLED".

Both dispatch paths now skip disabled modules, and _validate_for_operation
rejects one so a direct plan/apply cannot defeat the flag either.
_check_missing_variables already skipped disabled modules, so dispatching one
also meant deploying a module whose required variables were never validated.

#527 part 2 / #462 — cancel was cosmetic for container modules

revoke(terminate=True) SIGKILLs the worker-side client; the detached step
container survives on the host daemon and keeps driving the vendor CLI against
live infrastructure while Forge reports "cancelled" and force-releases the
module lock. A user who cancelled and re-applied raced a live container over the
same workspace with no lock held. Separately, cancel matched only in_progress
tasks, so a task still queued was never revoked and the worker ran the full
apply after the user was told it stopped.

kill_task_containers() kills by the bnkforge.task ownership label the runner
already stamps for the reaper (#517), so a cancel and a reap agree on which
container belongs to which task. The kill happens before the lock is released.
Cancel matches queued and pending as well as in_progress and marks all of them
cancelled.

The stuck-status branch (a running module with no cancellable task row) does not
kill: that is a lost worker, and distinguishing its orphan from a live sibling's
step container is exactly what the reaper already does on the workspace label.

#526 — no endpoint returned step output

The output was never missing, only unreachable: Deployment.stdout has always
held the full log, as a deferred() column no endpoint selected. Thirteen
plausible paths 404'd and /api/logs returned an empty list, so a failed
container deploy could only be diagnosed by opening the UI.

Adds GET /{module_id}/deployments/{deployment_id}/output, scoped to the module
so a deployment is not readable through another module's path, truncating from
the tail because that is where the failure is.

Verification: each fix was re-broken and confirmed to fail with a legible
message — the cascade guard, the scope stamp, both enabled gates, the
queued-task match, the kill-before-unlock ordering, the ownership-label
selection, the tail truncation, and the module scoping on the lookup.

Closes #525
Closes #526
Closes #527
Closes #462

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
… regression, truncate on bytes

From the self-review on this PR, plus a sweep the review itself missed.

Blocker — the enabled gate went into _validate_for_operation, which only
submit_plan and submit_apply call. The review caught deploy_module; auditing
every method that dispatches work found two more: retry_deployment and
submit_action. POST /deploy is what the UI's Deploy button calls, so the
headline fix was defeated by the most likely route a user takes.

Gated now: deploy_module, retry_deployment, submit_action (an action runs the
artifact's image against live infrastructure).

Deliberately NOT gated, and now covered by a test that says so:
  - submit_destroy — a disabled module may still hold infrastructure, and
    disabling it must not strand what it already built.
  - cancel_operation — a running operation must always be cancellable.
  - submit_init — prepares a workspace only; dispatch_init is called without
    auto_apply, so it cannot chain into a deploy.

Blocker — widening the cancel match to queued/pending broke cancel in the window
where a newer row has no celery_task_id yet. create_task commits before dispatch
stamps the id, and _trigger_next_stack_module commits its pending row before
calling dispatch_apply, so a newer id-less row can shadow the running task. The
guard keyed on cancellable[0], so the whole path was skipped: nothing revoked,
no container killed, and the caller told "Reset stuck deployment status" with
success=True. The guard now keys on whether ANY cancellable task carries an id.
This was a regression this PR introduced — matching only in_progress never hit
it, because the running task always has an id.

Should-fix — the output endpoint truncated on characters while advertising
max_bytes. Artifact logs are full of non-ASCII, so a scripted caller could get
up to 4x the cap. Now encodes, slices bytes, and decodes with errors="ignore"
to drop a partial code point at the cut.

All four fixes verified by reverting them and confirming the intended test fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…x the rules that dropped them

The public repository was created from a working tree rather than from git
history, so three .gitignore rules silently excluded files that are tracked
upstream. The result was a repo whose frontend could not build and whose CI
could not pass — for any PR, not just a particular one.

Root cause, one rule at a time:

1. `.gitignore:189  package-lock.json`

   Pre-existing in both repos. Upstream keeps the lockfile tracked via
   `git add -f`, which does not survive an export. Without it,
   actions/setup-node fails its cache step ("Some specified paths were not
   resolved") and `npm ci` has nothing to install from, taking out Lint
   Frontend, TypeCheck Frontend and Unit Tests Frontend on every PR.

   The rule is simply wrong: a committed lockfile is what makes `npm ci`
   reproducible. Removed, with a comment saying why it must stay tracked.

2. `.gitignore  secrets/`   (added by the public-release block)

   Unanchored, so it matched ANY directory named `secrets` at any depth —
   including `frontend-v2/src/components/secrets/`. That removed
   ProjectSecretsManager.tsx, a shipped component still imported at
   ProjectDetailV2.tsx:84 and rendered at :648. The frontend therefore could
   not typecheck or build at all.

   Anchored to `/secrets/*` with `!/secrets/README.md`, since git will not
   descend into a fully-excluded directory and the placeholder README is
   tracked upstream. Verified both directions: the restored files are no
   longer ignored, and secrets/id_rsa, secrets/creds.json, .env,
   backend/.env.prod and dist/secrets/tls.key all remain ignored.

3. `dist/.gitignore  *`

   dist/ is a build-output directory, but a specific set of release artifacts
   is deliberately tracked — the installer and its templates, referenced by
   README.md, the Makefile, .github/workflows/release.yml and
   scripts/ibm_cloud_bnk_forge.sh (which embeds dist/docker-compose.yml into
   the IBM Cloud cloud-init user-data). Upstream keeps them by `git add -f`;
   the export lost all eleven.

   Replaced the force-add convention with explicit negations, so the tracked
   set is reproducible from the rules alone and the next mirror cannot drop it
   again. The restored set matches upstream file-for-file.

Also: MIN_UPGRADE_FROM named v3.0.1, a tag that does not exist here — this
repository's history begins at the public release — so the P2 migration-upgrade
gate failed with "names 'v3.0.1', which is not a commit in this repo". Set to
v3.1.6, which resolves to 6682529 (the initial public release commit) and is an
ancestor of staging, making it the honest upgrade floor for this repo. Inventing
a v3.0.1 tag would have been the alternative, and a false one.

Deliberately NOT restored, as these look like intentional scrub targets rather
than accidents: AUDIT.md, CONTEXT.md, CLAUDE.md, the *.bnk backup blobs, and the
internal audit documents under docs/ and tests/e2e/.

Verified: npm ci succeeds and tsc --noEmit passes on the restored tree; every
restored path scanned for credential patterns (clean); dist/ tracked set diffed
against upstream (identical).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…files

Restore files the public export dropped, and fix the .gitignore rules that dropped them
…egistries.name

v2_152 drops ix_container_registries_name guarded only on existence. That
name means opposite things on the two provisioning paths:

  * chain-built — the redundant PLAIN index v2_138 created alongside the
    UniqueConstraint; uniqueness lives on container_registries_name_key.
  * create_all — the ONE index the model's `unique=True, index=True`
    renders to, and it is UNIQUE. It is the only thing enforcing
    uniqueness on name; there is no separate constraint.

if_exists cannot tell them apart, so on the fresh-install path the drop
removes the unique index and leaves duplicate registry names insertable.
That path is the ordinary one: init_db.py is create_all + stamp head, and
v3.1.6 stamps at v2_141 — below this revision — so every install upgrading
from the current floor runs it.

Guard on uniqueness instead of existence.

Verified locally against Postgres 16, both CI shapes:

  * Migration Upgrade From Released Version — provision at v3.1.6 via its
    init_db.py (stamps v2_141, builds ix_container_registries_name UNIQUE),
    upgrade to HEAD, then check-schema-parity. Before: the index is gone
    and parity reports "Declared by the ORM, not created by the chain:
    CREATE UNIQUE INDEX ... (name)". After: it survives as UNIQUE and
    parity reports agreement on every table, column and index.
  * Migration Round-Trip — init_db.py at HEAD, alembic upgrade head, drift
    gate: unaffected and passing (it stamps at head, so v2_152 never runs).

This is what unmasked it: the job asserts parity only after resolving
MIN_UPGRADE_FROM, and that resolution failed on every run until #121
corrected the floor to a tag the public repo actually has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd5JuGQYqT2mcjwbgMziDc
Stop v2_152 dropping the unique index on container_registries.name
…n-scalar action inputs, secret_files collision

Second hardening pass, covering the items left open by the first.

#79 item 3 — repointing a registry no longer carries its credential

Registries are global, and update_registry permits changing registry_host
WITHOUT re-supplying the token. _test_basic_v2 then decrypts the stored token
and sends it as HTTP Basic auth to whatever host is now on the record — so
operator A could repoint operator B's registry at a host they control, press
Test, and collect B's PAT, defeating the write-only-secrets model.

Changing the host now clears the stored credential (and the cached test status)
unless a new token is supplied in the same request.

I first implemented this as the issue's other suggestion — enforce
container.registry_host_allowlist plus a private-address check — and the full
suite caught it: test_harbor_self_hosted_uses_basic_v2 failed, because a
self-hosted Harbor at harbor.internal is a SUPPORTED configuration that both
checks reject. That allowlist governs which registries artifact MANIFESTS may
reference, which is a different policy question. Clearing on host change closes
the exfil precisely without constraining which hosts an operator may configure.

allow_redirects=False is kept on the test call regardless: a 302 off the
configured host would otherwise carry the Authorization header wherever it
points.

#79 item 5 — the runner NetworkPolicy denied its own DNS

policy_types=["Ingress","Egress"] with egress=[] denies ALL egress including
DNS, while the docstring claimed egress reached the cloud control plane. Both
outcomes were bad: on an enforcing CNI (Calico/Cilium) a provisioning artifact
could not resolve or reach a cloud API at all, and on a non-enforcing CNI the
advertised isolation was fictional. The E2E ran against the Docker backend, so
this path was under-exercised.

Ingress stays a blanket deny. Egress now allows DNS (udp/tcp 53) and public
destinations via 0.0.0.0/0 with RFC1918, loopback and link-local in `except` —
which is the isolation that actually matters: it keeps a third-party artifact
image away from cluster-internal services and the metadata endpoint
(169.254.169.254). Verified the client serializes `_except` to the `except`
key the API expects.

#96 N2 — a dict or list passed for a `type: string` action input

validate_cli_arg saw the Python repr (one token, no leading dash, so not
flag-injection) and the raw value was stored and templated. Not exploitable as
argv, but it silently accepts a shape the manifest never declared and hands the
step a Python repr it cannot parse. Non-scalars are now rejected.

#102 — a secret_files path that collides with a step's target directory

Materialization creates each secret file's parent directories before any step
runs, so a step that then tries to CREATE one of those directories fails on its
first run — unrecoverably, because materialization recreates it ahead of every
retry, and with run_once the retry skips the failing step and dies further
downstream pointing at the wrong place.

The general form is undecidable (step args are opaque argv). The common case is
not: both fields template off the same input, so the secret path's first
segment equals a bare argv token. That is what this rejects, at validation time
rather than at deploy time. Deliberately narrow — bare tokens only, no flags and
no path-like values — because a false positive blocks a legitimate manifest.

Verification: all four fixes re-broken and confirmed to fail with the intended
message. Contrast tests throughout (an allowlisted flow still works, a
non-colliding manifest still validates, scalars are still accepted, ingress is
still denied). One pre-existing test updated rather than deleted:
test_network_policy_is_deny_by_default still asserts the intent, now expressed
as allow-DNS + allow-public instead of egress==[].

7650 unit+component tests pass. ruff clean.

Refs #79
Closes #96
Closes #102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
Caught by running ruff after the commit rather than before it — the lint gate
would have failed CI on the previous push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…not just basic-auth

Self-review finding on this PR. _clear_off_family_credentials deliberately
PRESERVES the current type's credential, so clearing only token_encrypted left:

  * a FAR registry's far_service_account_encrypted — and _test_far sends it as
    Basic auth to the same registry_host (line 477), so the exfil this PR closes
    was still open for that family;
  * a derived registry's credential_template_id, same shape.

Now clears username, token, FAR service account and template id together, and
treats a supplied FAR service account or template id as a new credential just
as it already treated a token.

Verified by reverting to the token-only version: the new FAR test fails with the
message it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…LED modules

Re-review finding on this PR. The deploy paths gained an `enabled` gate; the
destroy wave deliberately did not. That asymmetry is easy to 'tidy up' later
into a data-loss bug — a disabled module can still hold live infrastructure, so
skipping it in a project destroy strands what it already built with no UI
affordance left to reach it.

Verified by adding the gate to _dispatch_first_destroy_wave: the new test fails
with the message it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…se collision check, scalar check at the render point

Addresses bonnyr-f5's review.

BLOCKER — the credential guard was bypassable by an off-family value

`supplied_new_credential` was a disjunction over all three credential families
gating the block that protects the ONE family the record reads. Sending any
other family satisfied it: `far_service_account: "{}"` (any parseable JSON is
accepted) preserved a harbor record's token; a token preserved a FAR record's
service account.

The clearing is now unconditional on host change; the existing re-apply blocks
put back only what was supplied in the same request, which was already the
ordering. Also fixed the falsy `old_host` conflation (an empty-string host is
reachable — no min_length at create) and cleared `last_test_at`, which was
leaving a stale success timestamp beside a null status.

On the reviewer's third variant — replaying the record's own template id — I
went the other way, and the reasoning is worth recording. Blocking it looked
right, but `create_registry` already accepts ANY template id on a NEW registry
at ANY host with no ownership check, so refusing it on update buys nothing (the
same outcome is one POST away) while making a derived registry's host
unchangeable, since the derived-type invariant then rejects the update. The real
gap is that credential templates carry no per-operator authorisation on either
path. Filed separately; explicitly NOT closed here.

MAJOR — the DNS egress rule had no `to`, so `except` was decorative

A rule with ports and no peers permits ALL destinations on those ports, and an
ipBlock `except` binds only to its own rule. So 169.254.169.254:53 and every
RFC1918 host on 53 stayed reachable — TCP included, which is a bidirectional
channel out of a pod holding cloud credentials. DNS is now scoped by
namespaceSelector to the resolver namespace (overridable via
CONTAINER_RUNNER_DNS_NAMESPACE), and the builder now REFUSES to emit an egress
rule without `to`, so the semantics can't be reintroduced by review oversight.

MAJOR — the policy never reconciled and failed open

_ensure_network_policy swallowed 409, so an existing policy — possibly one from
an older build that denied DNS, or the unscoped version above — was left in
place; and any other failure logged a warning and ran the step anyway. It now
replaces on 409 and raises otherwise: this is the isolation boundary for a
third-party image that receives cloud credentials via env_from, so running
without it is the outcome the policy exists to prevent.

MAJOR — the scalar check covered one of two surfaces

validate_action_inputs guards only the action path. Lifecycle steps render from
ctx.variables (module.variables + variable_overrides, both JSON columns), so a
dict still reached step argv as a Python repr. Moved to ContainerEngine._render_str
— the single point where a value becomes part of an argv token.

Minor — collision-check precision and coverage

`--name poc` was rejected: the token after a flag is that flag's VALUE, not a
directory the step creates, which is exactly the false positive the docstring
disclaims. Inline `--name=poc` handled too. The check now also walks
actions[*].steps, since materialize_secret_files runs on the action path and the
unrecoverable #102 failure recurs there verbatim.

Verification: each fix re-broken and confirmed to fail with its intended
message. 7667 unit+component tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…closed on unknown scope, gate at the chokepoint

Addresses bonnyr-f5's review.

BLOCKER — the container kill could not execute in the shipped image

cancel_operation is reached only from a FastAPI route, i.e. the `backend`
service, built from the `api` Dockerfile stage — which never copies the docker
CLI (only the `worker` stage does), and compose sets DOCKER_HOST only on the
celery services. Confirmed by inspection: the api stage contains no docker
binary. So `docker ps` raised FileNotFoundError, the runner swallowed it into
`[]`, and cancel force-released the module lock and returned success while the
detached container kept driving the vendor CLI. tasks/container_reaper.py
already documents this constraint in prose.

Two changes, both needed:

  * The kill is dispatched as a Celery task on the worker queue, where the CLI
    and DOCKER_HOST exist — the reaper's own pattern.
  * kill_task_containers now RAISES ContainerKillUnavailableError instead of
    returning [] when the endpoint is unreachable, so "I killed nothing" and "I
    could not look" are different answers. The module lock is released only on a
    CONFIRMED kill; otherwise it is retained and the response says so instead of
    claiming success. Unconfirmed leaves the container to the reaper and the
    lock to the janitor, which is the safe direction.

BLOCKER — a module-scope destroy shadowed an in-flight project teardown

_destroy_scope_for read the newest destroy Task for the MODULE, not the task
executing. Destroy leaf M (module scope) → user clicks Destroy All → the wave
skips M because it has a non-terminal destroy task → M completes → the newest
row is still module-scoped → the chain returns before chaining AND before
terminal detection. Dependencies never queued, entity stuck in DESTROYING
forever. Scope is a property of the run, so the executing task_id is now
threaded from all 28 call sites (they all had it in scope).

The janitor is the one deliberate exception, now commented: it re-drives after a
worker death, so there is no executing task and "the module's newest destroy
Task" is exactly the interrupted run being resumed.

BLOCKER — unknown scope failed OPEN into cascade

Returning None for an unstamped row let callers fall through to the
stack_instance_id heuristic, which cascades. Every destroy Task written before
the stamp has meta_data = NULL, so a single-module destroy enqueued by the old
code and still QUEUED across the rollout would have completed under the
heuristic and deleted its dependency — the original data-loss bug, live during
the deploy window.

Unknown now resolves to "module", i.e. do not chain, using run_handle as the
discriminator that exists on legacy rows: both wave dispatchers set it,
create_task never does. Verified both halves in the code before relying on it.

MAJOR — the enabled gate missed several dispatch paths

Gating at the callers missed stack_service.run_deploy (Deploy All for a stack —
and _apply_topology_module_filter is the tree's main producer of disabled
modules, so that path matters most), submit_init, and the worker auto-apply
chains in container_tasks/opentofu_tasks, which gate only on can_execute() — a
dependency check that never looks at enabled.

Moved to _assert_module_runnable in task_dispatch, the chokepoint all five
entry points cross (init/plan/apply/action/apply_signature). Destroy stays
ungated: a disabled module can still hold live infrastructure.

Test-contract updates, not weakenings: the worker-death fixture now carries the
run_handle a real project destroy stamps (it previously described a row the
wave cannot produce); the unreachable-daemon test now asserts the raise, which
is the fix; and the inline-kill test asserts worker dispatch plus
kill-before-unlock ordering.

Verification: each fix re-broken and confirmed to fail with its intended
message. 7674 unit+component tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
CI's Lint Backend caught two I001s my local run missed: stdlib must precede
first-party (unittest.mock before services.*), and third-party needs a blank
line before first-party (kubernetes / services).

Root cause of the miss is worth recording: the verification container had a
NEWER ruff than the pinned ruff==0.15.2 in backend/requirements-dev.txt, so the
local gate was not the CI gate. Pinned the image to 0.15.2 and re-linted all
three branches; only this one was affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…e comment

#122 fixed a real data-integrity bug — v2_152 dropped
ix_container_registries_name guarded only on EXISTENCE, and that name means
opposite things on the two provisioning paths, so on a create_all database it
removed the one index enforcing uniqueness on container_registries.name. It
merged with no review and no test.

The fix is correct. I verified it independently against Postgres 16 rather than
taking the PR body's word for it, running the revision in isolation on both
shapes:

  create_all (UNIQUE):  before/after both UNIQUE, duplicates still rejected
  chain-built (plain):  plain index dropped, uniqueness still on the constraint

What was missing is a test. The only detector for this class was the
whole-schema parity job — which is path-filtered, and which had been failing to
reach its assertion for unrelated reasons until #121 fixed MIN_UPGRADE_FROM.
That is how a bug in the ordinary fresh-install path reached staging.

Adds backend/tests/migrations/test_v2_152_index_guard.py: provisions both real
shapes, runs the revision, and asserts the unique index survives on one and the
redundant plain index is dropped on the other — plus, in both cases, that
duplicate names are still actually rejected, which is the property that matters
and which an index-name assertion alone would not catch. Also asserts
idempotency on a re-run.

Verified by reverting v2_152 to the if_exists guard: the test fails with
"v2_152 dropped the UNIQUE index ... duplicate registry names become
insertable".

Requires Postgres (UNIQUE vs plain is the whole distinction), so it skips
cleanly when TEST_POSTGRES_URL is unset and is wired into the existing
Migration-Upgrade job, which already has a database.

Also corrects v2_152's downgrade comment, which claimed the revision "only ADDS
objects". That stopped being true when #122 made it drop the redundant index.
No functional change — v2_138 owns that index and drops it with if_exists=True,
so a downgrade through it tolerates the absence — but the comment misdescribed
the code.

7639 unit+component tests pass; 3 migration tests pass against Postgres 16;
ruff clean (0.15.2, matching CI).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…sposable database

Self-review finding on this PR. The tests DROP AND RECREATE
container_registries. CI targets a dedicated scratch DB (bnkforge_orm_ci) and
the step is last in its job, so CI was never at risk — but nothing stopped
someone pointing TEST_POSTGRES_URL at a real database and losing the table,
and 'the docstring says so' is not a guard.

The fixture now fails loudly unless the database name looks disposable.
Verified both directions: bnkforge_orm_ci runs, bnkforge refuses with an
actionable message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…he CI step skipping silently

Addresses bonnyr-f5's review.

MAJOR + MAJOR — the disposability guard was a name substring, and the test
mutated a database another gate reads

Both findings have one root fix, so they get one. The name check admitted
`bnk_forge_test` — THIS REPO'S OWN docker-compose.test.yml database — and
`bnkforge_orm_ci`, the artifact the parity gate in the same job reads. Dropping
container_registries in the former destroys token_encrypted /
far_service_account_encrypted, which are unrecoverable secrets rather than
regenerable schema; in the latter it silently breaks a sibling gate, and only
undocumented step ordering kept that from happening today.

The fixture now CREATEs its own `bnkforge_migtest_<uuid>` per run and DROPs it in
teardown. Nothing pre-existing is touched, which makes the naming question moot
rather than merely constrained. Verified: three tests pass, no scratch database
survives the run, and bnkforge_orm_ci ends with zero container_registries tables
— it is never written to at all now.

MAJOR — the CI step was silently skippable and still reported green

GitHub Actions substitutes an unresolvable env.X with the empty string without
comment, so a rename of ORM_DATABASE_URL would leave PG_URL falsy, skip all
three tests, exit 0, and pass the gate — which explicitly treats `skipped` as
acceptable. pytest's exit-5 catches zero-collected but not all-skipped.

Two belts: the workflow step now fails if TEST_POSTGRES_URL is empty, and the
fixture fails rather than skips when CI is set. Verified the latter fires.

Minor — the rewritten v2_152 downgrade comment presented a two-bucket inventory
(ADDS / DROPS) and omitted upgrade()'s alter_column on stack_instances.
template_id, which is neither. Harmless in effect, but a comment reading as
complete and not being so is the exact fault it was correcting. Now accounted
for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
… derived-host constraint

Addresses bonnyr-f5's review pass 2.

BLOCKER — execution.steps was a shadow step-set no validator walked

_resolve_steps read execution.steps first and every validator read top-level
steps, so a manifest could present benign argv for review while execution.steps
ran a shell — bypassing the denylist, the shell-token check, the argv-strings
check and this PR's own collision check simultaneously, in a step pod holding
secret_files material and cloud credentials with public egress. The same
asymmetry silently broke lifecycle: execution.steps without a destroy key
returned [] rather than falling back.

module_metadata.canonical_step_sets is now the single resolver, imported by both
the validator and the engine, and declaring steps in BOTH locations is rejected
outright rather than silently resolved. A test asserts the engine and validator
agree on the same list.

MAJOR — the host comparison was raw, so a no-op edit destroyed credentials

Every consumer canonicalizes (.strip().lower()), so "harbor.internal" ->
"Harbor.Internal" is a no-op for matching, DNS and the /v2/ URL — yet it tripped
the clearing and destroyed write-only credentials that may be impossible to
re-obtain. Reachable from the UI: the edit dialog's type dropdown writes
DEFAULT_HOSTS[type], which is '' for four types, and '' is not nullish so ?? does
not fall back.

canonical_host (strip, lower, trailing slash, default port) is used for the
comparison. Case/whitespace/slash/:443 edits now preserve the credential; a real
host change still clears.

MAJOR — the derived family could send a live cloud token to any host

credential_template_id is public — serialized to every viewer — so replaying it
re-attaches the victim's template in the same request as a host change, and
_test_derived then mints a live ECR authorization token and POSTs it to the new
host.

I argued last round that blocking the replay is theatre, because create_registry
accepts any template id at any host anyway. That reasoning was right about the
replay and wrong about the exposure: constraining the DESTINATION closes it
without touching the template question. Unlike a self-hosted Harbor — the reason
a general host allowlist was rejected — ECR and ICR hosts are provider-shaped, so
a derived registry may now only point at its own provider. allow_redirects=False
added here too, since this path carries a minted credential.

The underlying gap (templates carry no per-operator authorisation on any path)
is still open and still not closed by this PR.

Two regressions my own changes introduced, caught by the full suite and fixed:
`or None` collapsed an empty steps dict and degraded "steps.apply" to a generic
message; and a 12-digit account-id anchor rejected a legitimate ECR host — the
provider suffix is the security property, not the digit count.

7682 unit+component tests pass; ruff clean (0.15.2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…he directory from the legacy suite

My own CI-skip guard broke three jobs. Keying the hard-fail on CI generally was
wrong: only ONE job provisions Postgres for these tests, and `make
test-backend-legacy` runs `tests/` with --ignore for unit/component/integration/
contract but not migrations — so it collected the new directory, found no
TEST_POSTGRES_URL, and failed with the very message meant to protect the gate.

Two fixes, both needed:

  * The guard now keys on BNK_REQUIRE_MIGRATION_TESTS, which only the migration
    job sets alongside the URL. The protection stays exactly where it belongs —
    that job cannot silently skip — while every other job, which legitimately
    has no database, skips quietly.
  * The legacy suite ignores tests/migrations, since those have a dedicated job.

Verified all three: the legacy invocation passes (769 tests) with the directory
excluded; the guard still fires when the owning job has no URL; and CI-without-
the-flag skips quietly.

Worth recording the shape of the mistake — a guard against a silent-skip that
itself assumed one job's environment applied everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…trate, filter rather than raise

Addresses bonnyr-f5's review pass 2.

BLOCKER 1 — the destroy-scope guard still stranded infrastructure

Passing the executing task_id did not help, because the executing run IS the
module-scoped one: the wave's idempotency guard skips a module that already has
a non-terminal destroy task, so no project-scoped row is ever created for it.
When that task completed, the guard read destroy_scope="module" off its own row
and returned before chaining AND before terminal detection — dependencies never
queued, and the entity left DESTROYING forever since "applied" is not a terminal
destroy status.

_dispatch_first_destroy_wave now ADOPTS the in-flight task into the run,
stamping destroy_scope="project" and the run_handle onto it instead of walking
away. Scope belongs to the teardown run, not to whichever request created the
row.

My regression test pinned the inverse — it built a project-scoped row first and
asserted it won, a state the skip makes unreachable. Replaced with the real
sequence: module destroy in flight, Destroy All clicked, assert the executing
task is adopted.

BLOCKER 2 — cancel confirmed a kill it never attempted, on Kubernetes

The gate resolved the DISPATCH FAMILY (get_engine_type -> "container"), not the
substrate, which is chosen separately from execution.container_runner.backend or
deploy_model. A container artifact on Kubernetes therefore took the docker
branch; `docker ps --filter label=bnkforge.task=<id>` returned zero rows with
exit 0; that read as a CONFIRMED kill, so the lock was released and the user told
the deployment stopped while the Job ran on against live infrastructure — and a
re-apply reused the same PVC.

_container_substrate now mirrors the task layer's precedence, and a non-docker
substrate returns UNCONFIRMED, which retains the lock. That is the honest answer:
the Kubernetes runner stamps no bnkforge.task label, so nothing could select the
Job even if a deletion path existed, and the reaper is DockerRunner-only — the
fallback named in the old comment does not exist there.

BLOCKER 3 — the gate raised into loops that commit before dispatching

stack_service.run_deploy commits a queued Task row before dispatch_init and has
no try/except, so a raise abandoned every later module, left the stack DEPLOYING,
and left an orphan queued row that makes _has_active_task true forever —
permanently skipping that module on re-runs. The topology filter is the tree's
main producer of disabled modules and they deploy through exactly this path.

Disabled modules are now filtered out of the dispatch SET. submit_init and the
rerun route validate BEFORE any mutation — the latter nulls outputs and
plan_output and commits, so a rejected request was destroying an applied
module's outputs. _assert_module_runnable stays as a backstop no caller relies
on.

One test of mine passed against the broken code and was rewritten: removing the
substrate check still returned unconfirmed, because the unpatched dispatch fails
on a missing broker — the right answer by accident. It now asserts the docker
kill is never attempted.

7683 unit+component tests pass; ruff clean (0.15.2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…quired-tests guard

I reintroduced the exact hole the review raised, one layer down. The
missing-URL branch fails when BNK_REQUIRE_MIGRATION_TESTS is set, but the
CREATE DATABASE failure path called pytest.skip unconditionally — so an
unreachable database, or a role without CREATEDB, skipped all three tests and
the gate reported green while asserting nothing.

Verified: unreachable URL + the flag now errors (non-zero exit); without the
flag it still skips quietly, so local runs and the other CI jobs are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…oints

Probing my own fix found it too tight — a false positive here blocks a working
registry, which is the same class of mistake as the allowlist that would have
broken self-hosted Harbor.

Refused before this change, all legitimate:
  *.dkr.ecr-fips.<region>.amazonaws.com   FIPS endpoints (govcloud/regulated)
  public.ecr.aws                          ECR Public
  private.<region>.icr.io                 IBM private endpoints

Patterns widened to cover them while still refusing the exfil shapes —
attacker.example.com, suffix-appended lookalikes like
1.dkr.ecr.us-east-1.amazonaws.com.evil.net, and icr.io.evil.net. Both
directions asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
…ified mitigation

Self-review of the adoption fix found a window it does not cover: if the worker
completes between the wave's SELECT and its flush, the trigger has already run
under the old module scope and the dependencies stay unqueued. The janitor does
not recover it — reset_stale_tasks only touches NON-terminal tasks, and this one
is terminal — so the entity stays DESTROYING.

I implemented a commit-refresh-and-re-trigger mitigation and could not
demonstrate it firing under test: the check reads an expired ORM instance, and
each attempt to model the race in a test either failed to reach the branch or
passed for the wrong reason. Rather than ship an unverifiable change inside a
destroy path — the same fault I was corrected for twice this round — the code
now documents the window and names the shape of a proper fix (an atomic
conditional adopt-while-non-terminal UPDATE, re-triggering on zero rows
matched), which changes how the wave claims work rather than patching here.

Adoption itself is unchanged and covers every other ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5
Module lifecycle control: destroy blast radius, disabled gating, real cancel, step output
…ing-2

Container runner hardening (2): registry credential exfil, deny-all egress, non-scalar action inputs, secret_files collision
Pin the v2_152 unique-index invariant with a test, and correct its downgrade comment
…sal, secret leaks, destroy/action time limits (#2)

* fix(container-runner): non-root gate bypass, outputs_file traversal, secret leaks, destroy/action time limits

Hardening pass over the container runner, from the deferred review follow-ups.

#408.1 — the non-root gate was bypassable

is_root_user() did exact-string membership against
{"", "0", "root", "0:0", "root:root"}. Docker's USER is `<user>[:<group>]`, so
an image declaring `USER 0:100` or `USER root:wheel` runs as uid 0 and matched
nothing in that set: it passed the gate and ran as root over the host-mounted
workspace, defeating the boundary the authoring guide documents as *the*
protection. Now only the uid half decides, compared numerically, so 0, 00 and
0:anything are all caught. The Kubernetes path was never affected —
run_as_non_root=True is kubelet-enforced on the resolved numeric uid.

#408.2 — state.outputs_file escaped the workspace

The manifest value reached os.path.join with only a .strip(), so an absolute
path or a ../ climb read a file outside the workspace; its contents were then
normalized into module.outputs and shown to the user, disclosing worker files
such as /app/keys and /app/secrets. The manifest validator never checked the
field. Escaping values are now rejected (with a warning) and fall back to the
default, mirroring the containment the sibling _step_marker_path gets by
sanitising its input.

#408.4 — install script leaked the registry PAT and generated passwords

cloud-init runs the script under `set -euxo pipefail` with all output redirected
to /var/log/bnk-forge-install.log — root-owned but mode 644. With xtrace on, the
generated Postgres/Redis/MCP passwords and the `docker login` PAT were echoed in
cleartext, readable by any local user on a public-IP VSI. Both blocks now run
with xtrace off via `{ set +x; } 2>/dev/null`.

#408.6 — sensitive step args were echoed unredacted

secret_values was built from cloud-credential and pull values only, but a step's
argv is echoed verbatim to the task log and the module-log WebSocket. An artifact
declaring `args: [..., "--token", "{{inputs.api_token}}"]` leaked that token in
cleartext. Manifest inputs that is_sensitive_input() marks (explicit
`sensitive: true`, or a well-known credential name) now feed the redactor too.
The shipped roksbnkctl artifact was unaffected — its secret rides in a redacted
-e env var — which is why this stayed latent.

#463 F5 — destroy and actions had no derived Celery time limit

The apply path already derives a per-task limit from Σ(step timeout × attempts)
when the manifest budget exceeds the global ceiling. Destroy and actions did not,
yet a long teardown or e2e/scenario run outlives the global limit exactly as an
apply can — and being hard-killed mid-run leaves the module lock behind for the
reclaim sweep. The derivation is now phase-aware and wired into destroy and
action dispatch, including the signature variants used by chained dispatch.

#470 — report readback nits

`reports.dir` renders from an input, so a value clean in the manifest need not
stay clean; the manifest-time validator rejects `..` on the declared value but
nothing rejected it on the rendered one. realpath containment still held, so this
is defence in depth against a future refactor rather than a known bypass. And a
non-UTF8 report was served as errors="replace" mojibake, which reads as a corrupt
report rather than as "this is not text" — it is now refused with a clear error.

Verification: each fix was re-broken and confirmed to fail with a legible message
— the USER 0:100 bypass, the outputs_file climb, the rendered '..' rejection, the
non-UTF8 refusal, and the derived destroy time limit.

Refs #408
Refs #463
Closes #470

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5

* review fixes: pin #408.6 with tests, scope the root-gate claim, make xtrace restore safe

From the self-review on this PR.

Blocker — #408.6 shipped with no test. Its failure mode is invisible by
construction: the shipped roksbnkctl artifact passes its secret via a redacted
-e env var, so neither a live deploy nor any existing test would notice if
_sensitive_input_values started returning []. Adds tests/unit/
test_container_secret_redaction.py covering both manifest shapes (grouped
required/optional and flat list), the explicit sensitive flag, the name
heuristic, and the empty-value case — an unset secret must not put "" into the
redaction list, which would make the redactor rewrite every boundary in the log.
Verified by reverting the fix: 3 of 8 fail with the intended message.

Should-fix — is_root_user's docstring implied the uid-0 question was settled.
It is not: a named user aliased to uid 0 in the image's own /etc/passwd (USER
toor) still passes, because resolving it needs the image's passwd file. #408.1
closed the numeric bypass; the named-alias case is now documented as a KNOWN GAP
rather than left for the next reader to infer.

Should-fix — the install script SET xtrace rather than restoring it. Correct
today, since the cloud-init user-data starts with 'set -euxo pipefail', but it
would silently enable tracing (taking the passwords with it) if that header
changed or the block were copied. Now saves $- and restores conditionally.

Minor — the action time-limit path was unverified. Adds dispatch tests for a
long action (derives a limit) and a short one (keeps global defaults). The
function is dispatch_container_action, not dispatch_action — caught by the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5

* review fixes: symlink-proof workspace containment, fail-closed root gate, action-input redaction

Addresses bonnyr-f5's review.

BLOCKER — outputs_file containment was lexical, so a symlink walked through it

_is_workspace_relative did pure string analysis; _read_outputs_file then did
join → isfile → open(), all of which follow symlinks. The workspace is writable
by the artifact's own container, so the bypass needed no manifest trickery:

    ln -sf /app/keys/encryption.key /state/outputs.json

reads the master encryption key as the worker uid and normalises it into
module.outputs, which the state viewer and project-variables routes then serve.
The same primitive existed in the WRITE direction via _write_step_marker, where
a planted symlink is an arbitrary-file truncate to "done\n".

Both now route through one helper that does realpath containment under the
workspace root and opens with O_NOFOLLOW — the pattern module_reports_service
already uses, and for the reason its docstring already states. Adds the symlink
fixtures the previous six escape cases lacked, including a symlinked directory
COMPONENT, which realpath catches and an O_NOFOLLOW-only check would not.

MAJOR — the non-root gate failed OPEN on uid spellings isdigit() rejects

`if uid.isdigit(): return int(uid)==0` then `return False` classified everything
unparseable as non-root. runc falls back to strconv.Atoi, which accepts a sign,
so USER +0 and -0 resolve to uid 0 and were cleared. USER ² was worse:
"²".isdigit() is True but int("²") raises, and it only failed closed by accident
because ContainerEngine._run_step swallows Exception.

Polarity inverted: anything not provably a non-zero DECIMAL uid is refused.

BREAKING CHANGE, called out deliberately. This also refuses named users —
USER nonroot, the distroless convention, must become USER 65532. That is the
point: a name cannot be resolved to a uid without the image's own /etc/passwd,
so USER toor mapped to uid 0 previously sailed through the gate that exists to
stop exactly that, and it was documented as a known gap rather than fixed. The
Kubernetes substrate already enforces this (runAsNonRoot is kubelet-checked
against a resolved numeric uid), so the two backends now agree instead of
disagreeing on the same security property. The rejection message says what to
change. A pre-existing test asserting named users pass was rewritten to assert
the new behaviour, with the cost recorded in its docstring rather than deleted.

MAJOR — redaction missed action inputs

_sensitive_input_values read only the top-level manifest["inputs"], but actions
declare their own under manifest["actions"][<name>]["inputs"], and the VALUES
arrive at invocation time — _build_engine_and_ctx built secret_values before and
independently of action_inputs. So a sensitive action input was echoed verbatim
in the `$ docker run ...` line into task.logs, the module-log WebSocket and
OperationResult.stdout. Both halves fixed: the declaration sweep now walks
actions, and run_container_action passes action_inputs into the builder.

Verification: each fix re-broken and confirmed to fail with its intended
message. 7700 unit+component tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5

* review fixes: bound the uid to the representable non-root range, and contain every path component

Addresses bonnyr-f5's review pass 2.

BLOCKER — the root gate accepted a uid the daemon truncates to 0

The gate proved the uid was decimal and non-zero, and stopped there. runc's
strconv.Atoi yields a 64-bit Go int and moby narrows it with
uint32(execUser.Uid), so any decimal uid whose low 32 bits are zero becomes uid
0 in the container. Reproduced: USER 4294967296, 8589934592 and 999999999999 all
returned refused_as_root=False and were cleared to run as root over the
host-mounted workspace.

Now proves the value is in the runtime's REPRESENTABLE non-root range —
0 < uid < 2**31 — rather than merely != 0. That also turns USER 999999999999
into the actionable refusal instead of an opaque runc error later. Fixtures
extended past 65535, which nothing previously covered.

I have NOT independently verified moby's uint32 cast against a running daemon;
it is read from source, same as the strconv.Atoi reasoning. The fix is correct
either way — refusing what cannot be proven representable is the right posture
for this gate — but the exploit claim itself remains unverified by me.

MAJOR — O_NOFOLLOW constrained only the final component

Validation resolved the path, isfile() re-resolved it, open() resolved it a
third time; swapping a PARENT between those steps escaped containment. Not a
contrived shape: the shipped artifact declares a nested outputs_file, and
state.scope=deployment shares one workspace across blueprint modules dispatched
concurrently, so a sibling module's step container can swap the directory
mid-read.

_open_contained now walks the components, opening each directory with
O_NOFOLLOW|O_DIRECTORY relative to the previous fd and the leaf relative to the
last — no name is resolved twice and no component may be a symlink. The
isfile() precheck is gone with it, since that was itself one of the
re-resolutions.

The test for this is worth calling out. My first version planted the symlink
statically and PASSED against the broken implementation — realpath catches that
case in either design, so it proved nothing. The committed test performs the
swap from inside a hooked os.open, i.e. after every validation resolution and
before the first open, which is the actual window. Verified it fails against the
final-component-only version with the intended message.

7710 unit+component tests pass; ruff clean (0.15.2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5

* self-review fix: close the leaf fd if fdopen fails

os.fdopen takes ownership only on success, so a failure leaked the raw
descriptor. This path runs per step on a long-lived worker, so the leak
accumulates rather than being bounded by process lifetime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BDw71NbXZjH25q7FyMwo5

---------

Co-authored-by: John Gruber <john.t.gruber@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: run the 746 integration tests that were never executed

"P3 · Integration Tests · Backend" ran only `make test-integration-full`,
which passes `-m full`. pyproject's addopts carry `-m 'not full'`, so the two
selectors are exact complements: the job collected 206 of 952 integration
tests and left 746 unexecuted on every branch. No other job covered them --
test-backend-legacy explicitly passes --ignore=tests/integration.

The green check read as "integration tests pass" when it meant "the 22% of
them carrying the full marker pass".

Changes:

- Add a `make test-integration` target for the default marker set, and run
  both targets in the P3 job so tests/integration/ is covered completely.
- Give test-integration-full SUITE = integration-full. Artifact filenames are
  derived from SUITE, and both targets now run in one job, so sharing the
  value would have silently overwritten the first run's junit/coverage XML.
  Upload both pairs.
- Repair the five tests that had rotted unnoticed. All five failed with
  ResponseValidationError on a missing `is_default`. They are stale fixtures,
  not a product defect: both files @patch their service class and hand-build
  the response dict, and those dicts were never updated when is_default was
  added to ModuleSourceResponse / BlueprintSourceResponse. The real
  serializers do emit it (module_source_service.py:240,
  blueprint_catalog_service.py:503), so the live endpoints were fine.
- Validate each fixture against its response model inside the helper, so the
  next required field added to a schema fails at the fixture with the field
  named, rather than as a 500 buried in a FastAPI ExceptionGroup.

Verified: 742 passed in the newly-enabled set, 187 passed / 19 skipped in the
full set, ruff 0.15.2 clean, ci.yml parses as valid YAML.

Closes #130

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KxvfrjQMigLtgt35REbQH

* review fix: spell out the non-full selector instead of inheriting it

test-integration relied on pyproject's addopts (-m 'not full') to select the
complement of test-integration-full. That is the same implicit coupling that
produced #130: the two selectors could stop being complements -- by an addopts
edit alone -- and the only symptom would be a quietly smaller test run behind a
green check.

Both targets now state their marker expression, so the pairing is visible in
one place and a change to either is a change someone has to write down.

Verified the set is unchanged: 742 collected with the explicit selector,
206 with -m full, 948 total in tests/integration/ -- an exact partition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KxvfrjQMigLtgt35REbQH

* fix: a bad maintenance-mode value must not 500 every request

Enabling the 746 tests immediately found one. CI ran them and two health
checks failed with

  TypeError: the JSON object must be str, bytes or bytearray, not MagicMock

raised from core/maintenance.py:64 by way of maintenance_middleware.

get_maintenance_status() catches (RuntimeError, ConnectionError, RedisError)
around its json.loads(), so anything unparseable under the maintenance key
escapes instead. That call runs from middleware on EVERY request, which turns
one bad value into a 500 for the whole API -- while the function's own
docstring promises to degrade gracefully when it cannot get an answer.

It now catches TypeError/ValueError too (JSONDecodeError and UnicodeDecodeError
are ValueError subclasses, so decoding failures are covered) and logs the key
it ignored. This is the only json.loads() on the request path; the other two
middlewares have none.

Why local runs missed it: settings.REDIS_URL is unset in the test container, so
_get_redis() raised RuntimeError and was caught before reaching json.loads.
ci.yml sets REDIS_URL globally, so CI got past it. Reproduced locally by
exporting CI's env, which failed the same two tests, then confirmed the fix.

The MagicMock stub in the health tests is deliberately left as it is. Making it
a faithful redis double would let those tests pass without this fix and remove
the only coverage that exercises the path.

Verified with ci.yml's environment: 742 passed in tests/integration -m 'not
full' (was 740 passed / 2 failed), 43 passed across the maintenance, backup and
restore unit suites, ruff 0.15.2 clean. The new parametrised test was
break-tested -- narrowing the except back to TypeError alone fails 3 of its 4
cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KxvfrjQMigLtgt35REbQH

---------

Co-authored-by: John Gruber <john.t.gruber@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jgruberf5
jgruberf5 requested a review from bonnyr-f5 August 19, 2026 22:19

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A 65-commit promotion isn't reviewable line by line, and every commit in it came through a reviewed PR — I checked the contents claim and all ten of #164#171, #173, #174 are present, with #172 correctly absent since it's still open. So I reviewed the things that are actually specific to a release PR: the version derivation and what's being shipped under it.

Blocking — I think 3.1.7 is the wrong number, and the tooling can't see why.

scripts/compute_version_bump.sh:117 says:

# BREAKING CHANGE in footer (multi-line) or ! in type
if echo "$subject" | grep -qiE '(\bBREAKING[[:space:]]+CHANGE\b|^[a-z]+(\([^)]*\))?!:)'; then

But $subject comes from line 95/97:

COMMITS=$(git log "${SINCE_TAG}..HEAD" --pretty=format:"%s")

%s is the subject line only — the body is never read. A BREAKING CHANGE footer lives in the body by definition, so the footer branch that comment describes is unreachable. Only type!: in a subject can ever produce a major bump.

That matters here rather than theoretically. 7ece9b0 — "Container runner hardening: non-root gate bypass, outputs_file traversal, secret leaks, destroy/action time limits (#2)", new in this range, not already in main — carries this in its body:

BREAKING CHANGE, called out deliberately. This also refuses named users — USER nonroot, the distroless convention, must become USER 65532.

That's a real behavioural break: an image that ran fine on 3.1.6 with the distroless-standard USER nonroot is refused after upgrading. Shipping it as a patch tells operators the opposite — patch is the release people apply without reading anything.

The script's regex has no colon requirement (\bBREAKING[[:space:]]+CHANGE\b), so had it read bodies it would have matched this exact wording and computed major → 4.0.0. The prose form is BREAKING CHANGE, with a comma rather than the spec's BREAKING CHANGE: footer, so a stricter parser might legitimately skip it — but that's a separate question from the one the description asserts, which is that there are 0 breaking changes. There is one, and its author labelled it as such.

Three ways forward, and the choice is yours rather than mine:

  1. Cut 4.0.0 if the non-root gate change is meant to be breaking — which its commit message says it is.
  2. Keep 3.1.7 as a deliberate decision that this break is acceptable un-flagged (it's a security hardening fix, and there's an argument that refusing an unresolvable uid was always the intent). If so, say that explicitly in the release notes with the USER nonrootUSER 65532 migration step spelled out, because nothing else will tell an operator.
  3. Fix the derivation first%s%B in compute_version_bump.sh — and re-run it, so the number is derived rather than asserted.

Either way the script bug outlives this release: until it reads bodies, every future footer-declared breaking change ships silently as a patch or minor, and the comment on line 117 will keep telling the next reader it's handled.

I'd also drop or qualify "0 breaking" in the description — it's the script's output, and the script can't answer that question.

CI is still running (P2 component, legacy, migration round-trip). Happy to re-review promptly once you've decided on the version; nothing else in the promotion looks wrong to me.

@jgruberf5 jgruberf5 changed the title Release 3.1.7 Release 4.0.0 Aug 19, 2026
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Decision: cut 4.0.0, per your Option 1 — the non-root gate change is breaking, exactly as 7ece9b04's author labelled it.

Rather than assert the number, I fixed the tooling so it's derived correctly (your Option 3 too — the bug outlives this release): #178 makes compute_version_bump.sh read commit bodies, so it now computes major → 4.0.0 from 7ece9b04's BREAKING CHANGE footer. Verified against v3.1.6..staging, and a new self-test pins the footer-in-body case (non-vacuous — it fails against the subject-only script).

#178 also fixes the same %s-only blindness you'd have hit next: the release-notes and CHANGELOG generation. A new extract-breaking-changes.sh surfaces a ⚠️ Breaking Changes block carrying the USER nonroot → USER 65532 migration into both, so operators actually see it — the thing you flagged nothing else would tell them.

Sequence: #178 → staging first (it'll cut a v4.0.0-rc.*, validating the derivation in the real pipeline), then this PR → main cuts the final v4.0.0. I've retitled/reframed this PR to 4.0.0 and dropped the "0 breaking" claim — that was the script's output, and the script couldn't answer the question. Ready for re-review once #178 lands.

@bonnyr-f5 bonnyr-f5 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — Release 4.0.0 (stagingmain)

Run as a full review-discipline pass: invariant sweeps across the whole surface, class sweeps on the declared breaking change, an independent cold audit of the complete 368-file diff, and verification of every audit claim against the repo.

Headline: this PR does not cut the version it claims, and the cause is a race rather than a merge-ordering mistake. #178 is a necessary fix but not a sufficient one.


BLOCKER 1 — the release version is decided non-deterministically

The description says merge #178 first and this promotes to 4.0.0. #178 is not yet in staging, so I ran the derivation both ways, 20 runs each:

Merge strategy script at this HEAD with #178 applied
Merge commit 3.1.7 4.0.0 (20/20)
Squash 3.1.7 3.1.7 in 14/20, 4.0.0 in 6/20

Same repo, same script, no stderr. The mechanism:

printf '%s\n%s\n' "$subject" "$body" | grep -qE '\bBREAKING[[:space:] -]+CHANGE\b'

grep -q exits the instant it matches. Under a squash merge the body is the concatenation of all 65 commit messages (~105 KB) and the marker sits at byte 50,702, so printf still has ~54 KB to write and takes SIGPIPE (141). With set -euo pipefail (scripts/compute_version_bump.sh:37) that becomes a failed pipeline — so finding the marker makes the test evaluate false and the bump stays patch.

Reproduction:

subject=$(git log -1 --format=%s HEAD); body=$(git log -1 --format=%b HEAD)
st=0; printf '%s\n%s\n' "$subject" "$body" | grep -qE 'BREAKING CHANGE' || st=$?
echo "$st"          # 141, not 0

The merge-commit path is safe only by size: the largest body in the range is 7ece9b04 at 12,167 bytes, under the 64 KB pipe buffer, so printf never blocks. That is a coincidence, not a guarantee — any future commit with a long body reintroduces the flake on the merge path too.

This is a class, not an instance — the same construct appears twice, and #178 adds the second occurrence:

  • scripts/compute_version_bump.sh:124 — decides the released version
  • scripts/extract-breaking-changes.sh:22 — generates the breaking-change release note

So the release notes can silently omit the USER nonrootUSER 65532 migration by the same race, independently of the version. Suggested fix for both: drop the pipe entirely, e.g. [[ "$subject"$'\n'"$body" =~ BREAKING[[:space:]-]+CHANGE ]], or grep -qE '…' <<< "$text".

Why nothing catches it: no v3.1.7 final tag exists, so the "tag doesn't already exist" check (release.yml:210-218) passes on the wrong version. release.yml:200 consumes TARGET_VERSION directly and there is no assertion anywhere that the derived version matches an intended one. A breaking security-gate change would publish as a patch.

BLOCKER 2 — the Helm chart pins an image tag this release will never publish

helm/bnk-forge/values.yaml:19 sets tag: "3.0.1" and Chart.yaml:6 appVersion: "3.0.1". Every per-service tag is tag: "", which is falsy to Helm's default, so all seven services resolve to ghcr.io/f5devcentral/bnk-forge-*:3.0.1. release-publish pushes only :${VERSION} and :latest, and :3.0.1 has never existed → helm install yields ImagePullBackOff across api, worker, beat, frontend, proxy, mcp, operator.

frontend-v2/package.json:4 is still 2.12.0. The release job bumps exactly VERSION, dist/VERSION, and CHANGELOG.md; every other version-bearing artifact drifts silently. Suggested class fix: derive the chart tag from VERSION/appVersion and add a CI assertion that all version-bearing files agree — ci.yml:912-926 already does this for the images.

BLOCKER 3 — main is locked, and there is no republish path

main has branch protection lock_branch: enabled. release-final does git commitgit taggit push origin maingit push origin <tag>, so the branch push is rejected and the release aborts before tagging.

Separately, if release-publish fails after release-final succeeds (bake error, ghcr 5xx, Sigstore outage, the 90-minute timeout), the state is: tag exists, GitHub Release exists, no images — and there is no recovery. Re-running on main hits the release: loop guard (release.yml:77), and workflow_dispatch bumps to the next version rather than republishing the existing tag. Consider a publish_only: <tag> dispatch input, or pushing immutable :${VERSION} tags and signing before the tag and Release are created, moving :latest last.


Major

  • ~60% of this release is one opaque commit. f9e4389 "chore: integration branch snapshot (internal origin/staging)" is 246 files, +33,917 / −2,960 of the PR's +56,915. The description presents the range as the reviewed staging integration line; that provenance doesn't hold for the majority of the diff.
  • Both claimed hygiene gates are absent from CI. No gitleaks and no shellcheck runner in any workflow — .gitleaks.toml is a config nothing executes, and make shellcheck has no caller. So compute_version_bump.sh and publish-signed-images.sh are statically unchecked, and compute_version_bump.sh's own SELF_TEST harness is never invoked by CI. For a +56,915-line promotion into a public repo, the secret-scanning gate is unwired.
  • Public docs point at a third-party registry. ghcr.io/jlcode-tech appears in user-pack/install-guide.html (4×), docs/DOCKER.md (3×), scripts/publish-signed-images.sh, docs/ROADMAP.md, docs/roadmap.yaml, while publishing targets ghcr.io/f5devcentral. Relatedly, the documented cosign verify invocation uses --certificate-oidc-issuer https://github.com/login/oauth; keyless signing from Actions produces https://token.actions.githubusercontent.com with a workflow-ref SAN, so the documented command will not verify CI-published images.
  • The authoring guide still teaches the form the new gate refuses. docs/How to write CI container runner modules and blueprints for BNK Forge.md:651-659 says "put USER <non-root> in your Dockerfile. uid 1000 … is the safe choice" — a named placeholder. container_runner.py:585 now refuses anything that is not a bare decimal uid. The correct sample (USER 1000) is 80 lines later at :737-740, so the document contradicts itself, and there is no migration note in CHANGELOG.md or an upgrade doc.
  • MIN_UPGRADE_FROM is v3.1.6 — the newest final tag, which is exactly what ci.yml:633-637 says it must not be ("deliberately not the newest tag"). The 12 new revisions are validated only from a v3.1.6 stamp; revisions v2_001–v2_141 execute in no CI job.
  • CHANGELOG.md has no 3.x entries at all (newest is v2.10.74). Since release-final inserts after the first ---, the published changelog will read ## v4.0.0 directly above ## v2.10.74, with ~13 releases missing.

Minor / nits

  • backend/services/execution/container_runner.py:553 — the docstring says "Closes the numeric bypass only — see the KNOWN GAP note in the body." No KNOWN GAP note remains, and the body states the named-alias gap is now subsumed. Stale self-reference that understates what the function does.
  • docs/ROADMAP.md:51 and docs/roadmap.yaml:360 still list the #408 items this release ships as "⚪ Planned".
  • .env.example:61# HOST_REPO_PATH=/home/jarrodl/bnk-forge-v2 puts a developer's home path in a public example.
  • .trivyignore:35CVE-2026-7598's suppression says "escalate to pin-from-sid if no fix by 2026-08-12"; that date has passed and this release carries it.
  • release.yml:248-257 — RC numbering is count-based (wc -l), not max-based; deleting any RC tag makes the next staging push compute an existing tag and fail. Prefer sort -V | tail -1.
  • release.yml:70-82 — the loop guard reports success when it suppresses a release; a merge subject beginning release: or ending [skip ci] silently skips publication. Worth a ::warning::.
  • helm/bnk-forge/values.yaml:29mcpPassword: changeme as a shipped default now that the chart is the public distribution path.

Verified clean

  • Migration chain: 152 revisions, single head v2_153, no duplicate revision ids, no branch points. The cross-writer id check against the other open PRs is clean.
  • v2_138 is a mutation of an already-applied revision, but in the safe direction — it only adds has_table / if_not_exists / if_exists guards, so it changes behaviour only for stacks stamped below it, and the docstring states the rationale explicitly. v2_152 and v2_153 are inspection-guarded rather than revision-guarded, which is the right shape.
  • alembic==1.14.1 does support if_not_exists= / if_exists= on create_index / drop_index — confirmed against the installed signature, so v2_016/v2_017/v2_018/v2_138/v2_151/v2_152/v2_153 are safe on that API even though CI never executes the older ones.
  • main has no commits absent from staging, so the promotion loses nothing.
  • The breaking change is well tested. is_root_user covers +0, -0, ², 00, 0:100, root:wheel, toor, nonroot, 4294967296, 8589934592, ROOT — including the low-32-bits wraparound case. The polarity inversion (fail closed on anything not provably a non-zero decimal uid) is correct.
  • Class sweep for the breaking change found no affected artifact image in-repo. Forge's own USER bnkforge / nginx / operator / mcpuser / forgeagent images are not executed through ContainerRunner, so none are refused. The only gap is documentation (above).
  • No harness files tracked or present in the diff (.claude/, .agent/, .opencode/, CLAUDE*.md): zero.
  • Secrets sweep found no live credential material — matches are synthetic test fixtures or redaction-logic string constants.
  • One correction worth pre-empting: the two backends do now agree on rejecting a named USER. The kubelet's verifyRunAsNonRoot fails a container with "image has non-numeric user (…), cannot verify user is non-root" when runAsNonRoot=True and the image user is not numeric, so container_runner.py's comment is substantively accurate. The two paths differ only in error surface: an actionable exit-126 refusal versus a CreateContainerConfigError at pod start.

Review Assessment

  • Verdict: BLOCK
  • Audit SHA: 009840ae6029b1924493fc5e8f52e48d75aa4499
  • Cold Audit Performed: Yes — independent full-diff audit, every claim re-verified against the repo before inclusion here
  • Invariants Verified: INV-4 (shared-namespace ids checked across all concurrent writers — clean) · INV-7 (applied revisions immutable — one documented, safe exception in v2_138) · INV-9 (shell static analysis — not applicable as written; see the missing shellcheck runner under Major). Reviewer-posture passes applied: verify every claim a comment makes about other code, read what the tests do not cover, simulate the hard case rather than the first, trace each computed value end-to-end across module boundaries.
  • Git & Harness Cleanliness: Clean — this PR carries no harness or machine-local files; the only unstaged drift was in the reviewer's own clone.

Findings & Action Items

  • Major (Blockers)
    • scripts/compute_version_bump.sh:124 and scripts/extract-breaking-changes.sh:22: printf … | grep -q under set -o pipefail returns 141 when the marker is found, so the version derivation and the breaking-change note are both non-deterministic (14/20 runs wrong under squash). Replace both with a pipe-free test and add a preflight assertion that the derived version equals a declared intent.
    • helm/bnk-forge/values.yaml:19, helm/bnk-forge/Chart.yaml:6, frontend-v2/package.json:4: pinned to 3.0.1 / 2.12.0 while the release publishes only :${VERSION}. Derive from VERSION and assert agreement in CI.
    • .github/workflows/release.yml:399-431 with main's lock_branch: enabled: the workflow cannot push to main, and there is no idempotent way to republish images for an existing tag after a partial failure.
  • Minor (Non-blocking)
    • f9e4389: +33,917 lines arrive in a single "integration branch snapshot" commit — ~60% of the release without per-change provenance.
    • .github/workflows/: no gitleaks job, no shellcheck job, and compute_version_bump.sh's SELF_TEST is never invoked.
    • user-pack/install-guide.html, docs/DOCKER.md, scripts/publish-signed-images.sh: ghcr.io/jlcode-tech namespace and a cosign verify identity that will not match CI-signed images.
    • docs/How to write CI container runner modules and blueprints for BNK Forge.md:651-659: documents the named-USER form the gate now refuses; no migration note anywhere.
    • MIN_UPGRADE_FROM: equals the newest final tag, contradicting ci.yml:633-637.
    • CHANGELOG.md:7: the entire 3.x line is undocumented; the 4.0.0 entry will sit directly above v2.10.74.
  • Nits
    • backend/services/execution/container_runner.py:553: stale "KNOWN GAP" self-reference.
    • docs/ROADMAP.md:51, docs/roadmap.yaml:360: shipped #408 items still marked Planned.
    • .env.example:61: developer home path. .trivyignore:35: expired self-imposed deadline.
    • release.yml:248-257: count-based RC numbering. release.yml:70-82: silent success when the loop guard suppresses a release.

Suggested sequencing. Fix the two grep -q sites in #178 and land it, then re-derive and confirm 4.0.0 before promoting. Merge this PR with a merge commit, not a squash — squash collapses the 65 commits into one whose subject is the PR title, which both loses the conventional-commit corpus and produces the ~105 KB body that triggers the race. Blocker 2 is a small change and belongs in this PR. Blocker 3 needs settling before the first automated publish, since every symptom of it only becomes visible after an irreversible public artifact exists.

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The corrected framing is right — title, version, the explanation of what the tool couldn't see, and a "Breaking change in this release" section that names the USER nonrootUSER 65532 migration. That's what an operator upgrading from 3.1.6 needs, and it now exists independently of whether the generated notes pick it up.

Holding the block on merge order only, for a mechanical reason rather than anything left in the description.

This PR's head is 009840ae, which is the current tip of staging — and staging does not yet contain #178:

$ git show origin/staging:scripts/compute_version_bump.sh | grep -c 'pretty=format:"%H"'
0

So the version derivation on this branch today is still the subject-only one. Merging now would run the old script, and release.yml would cut 3.1.7 regardless of what this PR is titled — the number comes from the script at merge time, not from the description.

Once #178 lands on staging, this PR's head advances with it automatically (its head is staging), the derivation reads bodies, and the promotion cuts 4.0.0 with the migration in the notes. That's the sequence your description already specifies; I'm just keeping the block in place so the ordering can't be lost.

Ping me or push anything to staging and I'll re-check — I'm watching both, and I'll approve as soon as #178 is in. Nothing else in the promotion needs changing: contents verified earlier (all of #164#171, #173, #174 present, #172 correctly absent), and CI is green.

#178)

* fix: version-derivation and release notes must read commit bodies, not just subjects

PR #177 review surfaced that compute_version_bump.sh scanned only commit
subjects (%s). A conventional-commits BREAKING CHANGE is a *footer* — it lives
in the body by definition — so the footer branch the code commented on was
unreachable, and only `type!:` in a subject could ever produce a major bump.

Concretely: 7ece9b0 (container-runner hardening, #2) declares in its body that
the non-root gate now refuses named users — `USER nonroot` (the distroless
convention) must become `USER 65532`. A real behavioural break, labelled as
such by its author, that the tool couldn't see: it shipped as a patch, telling
operators the opposite of the truth. The same %s-only blindness affected the
release notes and CHANGELOG generation, so even had the version been right, the
migration step would never have reached an operator.

Fixes both:
- compute_version_bump.sh reads each commit's subject AND body. Major is
  `type!:` in the subject or an uppercase `BREAKING CHANGE`/`BREAKING-CHANGE`
  marker anywhere; minor is `feat:` in the subject (type is declared in the
  subject, never the body). Case-sensitive on the marker so body prose like
  "not a breaking change" can't false-trigger now that bodies are read.
- New scripts/extract-breaking-changes.sh emits a "⚠️ Breaking Changes" block
  (subject + the BREAKING CHANGE paragraph, so the migration text carries
  through). Wired into the RC notes, final release notes, and CHANGELOG steps;
  emits nothing when a range has no breaking change.

Against v3.1.6..staging this now derives major → 4.0.0 (was silently 3.1.7) and
surfaces the USER nonroot → USER 65532 migration. The bug outlived any single
release: until the tool read bodies, every future footer-declared break would
ship silently mis-versioned with its migration invisible.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* test: cover the footer BREAKING CHANGE case; make the self-test self-contained

Adds to the built-in SELF_TEST suite the case that was silently broken:
- BREAKING CHANGE footer in the BODY of a fix-subject commit → major (non-
  vacuous: subject-only derivation computes patch here, so this fails against
  the pre-fix script).
- lowercase "breaking change" in body prose → patch (guards the case-sensitive
  marker, so reading bodies can't false-positive on prose).

The harness gains optional per-commit bodies (subject~~BODY~~body) since a
footer only exists in a body, and sets a local git identity in its temp repos
so the suite runs on a fresh runner with no global git config (it aborted
otherwise). Also dropped a dead duplicate invocation and its stale COMMITS
comment.

  SELF_TEST=1 bash scripts/compute_version_bump.sh  → 6 passed.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

---------

Co-authored-by: John Gruber <john.t.gruber@gmail.com>
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 @mwiget — you were both right, and I own merging this prematurely (I admin-bypassed the CHANGES_REQUESTED state; the release then failed anyway on the main lock, so nothing shipped). Every blocker and major is now addressed in focused PRs against staging:

Blockers

Majors

Not fixable in a PR: the f9e4389 "~60% opaque snapshot" is merged history — can't be un-squashed; flagging it stays on the record. And the historical roadmap sync-note prose mentioning jlcode-tech I left as dated records.

Once these land on staging and you're satisfied, we re-promote with a fresh release PR — after the main lock is resolved so the automation can actually complete. Sequencing #179 first (it's the version-correctness one). Thanks for the depth on both reviews; the SIGPIPE race in particular I'd have shipped.

jgruberf5 pushed a commit that referenced this pull request Aug 20, 2026
…ic chart

PR #177 nit (bonnyr-f5): helm/bnk-forge/values.yaml shipped
`mcpPassword: changeme` -- a known default password now that the chart is the
public distribution path. The other four secrets (postgres/redis/jwt/encryption)
are generated with randAlphaNum when left empty and reused across upgrades via
the existing-secret lookup, but mcp-password had no such generation and used the
raw value directly.

Added the same lookup-then-randAlphaNum(24) logic for mcp-password and blanked
the default in values.yaml, with a comment on how to retrieve the generated
value (kubectl get secret ... | base64 -d). `helm template` confirms a random
mcp-password is rendered, not "changeme".

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 20, 2026
…gger

bonnyrf5 aggregate review, #179.

The trigger (and the detector in compute_version_bump.sh) matches a BREAKING
CHANGE marker anywhere on a line, but the note-extraction awk was anchored to
line-start (^BREAKING). A commit whose marker wasn't at line-start ("... a
BREAKING CHANGE: ...") therefore bumped the major version yet produced an EMPTY
note — a bare bullet with no migration text in CHANGELOG.md and the published
Release body, which is exactly the #177 class this script exists to close.

- Extracted _is_breaking / _breaking_note so the trigger and the note-capture
  share one regex core and can't drift apart again.
- Loosened the awk start match to the marker anywhere on the line.
- Added a bare-bullet fallback pointing at the commit, as a last resort.
- Added --self-test (matching compute_version_bump.sh's harness): non-line-start
  marker, spec footer, markdown-bold footer all yield a note; lowercase prose
  does not trigger.

Verified: self-test OK, shellcheck clean, real BREAKING CHANGE commit still
emits its full note end-to-end.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 20, 2026
PR #177 review (bonnyr-f5) — Major. `.gitleaks.toml` was a config nothing ran,
`make shellcheck` had no caller, and compute_version_bump.sh's SELF_TEST was
never invoked — so the release-critical shell scripts were statically unchecked
and, for a public repo, the secret-scanning gate was unwired.

Three new P1 jobs, all wired into the CI gate:
- Secret Scan (gitleaks): `--no-git` over the tracked source with the repo's
  config. Extended the allowlist to cover generated build artifacts (.pyc,
  frontend-v2/dist) alongside the existing synthetic-key entries; a fresh
  checkout now scans clean (verified locally even with artifacts present).
- ShellCheck: runs `make shellcheck` over the whole script corpus. Fixed the two
  pre-existing findings that would have blocked the gate — a missing shebang in
  get_dpu_pwd.sh (SC2148) and unused `for i` loop counters in
  ibm_cloud_bnk_forge.sh (SC2034, now `for _`). Corpus is clean at
  --severity=warning.
- Script Self-Tests: runs `SELF_TEST=1 compute_version_bump.sh`, which now exits
  non-zero on failure (that change ships with the SIGPIPE-race PR), so a
  regression in the logic that decides the released version fails CI.

The release-critical scripts (compute_version_bump, extract-breaking-changes,
publish-signed-images) already pass shellcheck cleanly.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 20, 2026
PR #177 review (bonnyr-f5) — Major. `.gitleaks.toml` was a config nothing ran,
`make shellcheck` had no caller, and compute_version_bump.sh's SELF_TEST was
never invoked — so the release-critical shell scripts were statically unchecked
and, for a public repo, the secret-scanning gate was unwired.

Three new P1 jobs, all wired into the CI gate:
- Secret Scan (gitleaks): `--no-git` over the tracked source with the repo's
  config. Extended the allowlist to cover generated build artifacts (.pyc,
  frontend-v2/dist) alongside the existing synthetic-key entries; a fresh
  checkout now scans clean (verified locally even with artifacts present).
- ShellCheck: runs `make shellcheck` over the whole script corpus. Fixed the two
  pre-existing findings that would have blocked the gate — a missing shebang in
  get_dpu_pwd.sh (SC2148) and unused `for i` loop counters in
  ibm_cloud_bnk_forge.sh (SC2034, now `for _`). Corpus is clean at
  --severity=warning.
- Script Self-Tests: runs `SELF_TEST=1 compute_version_bump.sh`, which now exits
  non-zero on failure (that change ships with the SIGPIPE-race PR), so a
  regression in the logic that decides the released version fails CI.

The release-critical scripts (compute_version_bump, extract-breaking-changes,
publish-signed-images) already pass shellcheck cleanly.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
Consolidated landing of seven interdependent PRs whose shared credential and
release/CI surfaces prevented merging in any order (see issue #192's conflict
matrix). Merged in dependency order 179, 180, 181, 188, 186, 183, 182; the
#186/#188 credential surface was reconciled once (single reserved-name guard;
provenance + migrations + stale-disable combined with rotation + backend MCP
wiring + threadpool). Squashed to one commit; per-PR history retained on the
seven archived branches.

Validated on the merged tree: ruff + mypy clean; 172 auth/credential/startup/
migration tests pass; single alembic head v2_155; openapi + frontend types fresh;
helm lint/template and docker compose config green on all modes; version and
detector self-tests green; commit-message lint clean.

Closes #192.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
…he credential seed

Addresses mwiget's three CHANGES_REQUESTED blockers on the #177 integration PR
(#193), plus follow-up findings from a max-effort review of the same credential
surface. The merge chose #188's "unset MCP_SERVICE_PASSWORD -> disable" over
#186's "unset -> generate", so the generate/rotate-on-unset code was left
unreachable but still documented, and the release-notes footer was missing.

BLOCKING 2 (core/config.py): the MCP_SERVICE_PASSWORD comment claimed
ensure_service_user "generates a random secret and surfaces it once" when unset,
contradicting the merged behaviour. Rewrote it to state the truth: when unset (or
a known default) seed_auth_step calls disable_stale_service_user, leaving the mcp
account disabled/unavailable until an operator configures a real password; a
published default is refused and rotated out; the backend receives
MCP_SERVICE_PASSWORD on every deploy mode and reconciles to it when set. Also
removed the duplicate #186 block that sat above the wrong field.

BLOCKING 3 (services/auth_service.py): removed the unreachable usable_password is
None branches from ensure_service_user (the generate-on-create and
rotate-on-unset paths). seed_auth_step only calls it under the _mcp_pw_usable
gate, so password is never None/default in production. ensure_service_user now
requires a usable password and only creates/reconciles with it (failing closed
and loudly if handed an unusable one); the unset case is owned entirely by
disable_stale_service_user. Dropped the now-dead _log_generated_service_password
helper and the service-account token_urlsafe/_persist_generated_password calls
(_persist_generated_password is still used by the admin seed). Kept the
reserved-name guard, the provenance check, the adopt-a-published-default
remediation, and disable_stale_service_user fully intact. Updated the affected
unit tests (published-default/None now refused; added a reachable
adopt-and-reconcile test; stale-row setup builds the legacy row directly) and
fixed scripts/mcp_live_smoke.py, which pointed operators at
/app/keys/initial_mcp_password, a file no reachable path writes.

CR-1 (services/auth_service.py, seed_admin_user): fixed a concurrent-first-boot
admin lockout. With DEFAULT_ADMIN_PASSWORD unset and 2+ api replicas, both
generated different passwords and the loser overwrote the keys file while its
INSERT rolled back, so the file and the committed row disagreed. The fresh seed
now creates+flushes first (the loser's INSERT raises IntegrityError -> rollback,
no file write) and persists the keys file only after winning but before commit,
so the file can only ever hold the committed row's password. Added a
losing-replica test.

CR-5 (services/auth_service.py, _persist_generated_password): os.open's 0600 mode
only applies on create, so a pre-existing 0644 file was truncated in place and
kept 0644, writing the secret world-readable. Added os.fchmod(fd, 0o600) and a
test that a pre-existing 0644 file is tightened to 0600.

CR-2 (routes/k8s_websocket.py, dpus_websocket.py, benchmarks.py): the WS auth
validators called the blocking sync token_user_state directly on the event loop.
Moved it off the loop via run_in_threadpool, matching core/auth_middleware.py.

Validation: make lint-backend clean; mypy core/ schemas/ unchanged; auth
(57) + ws/benchmark/startup (79) suites pass; alembic heads single v2_155;
helm lint/template OK and --set secrets.mcpPassword=changeme fails the render;
docker compose config OK on all modes; extract-breaking-changes and
compute_version_bump self-tests pass; lint-commit-markers clean.

BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required — the backend SystemExits under ENVIRONMENT=staging|production when it is unset or a known default; the shipped admin/changeme default is removed and rotated out on upgrade; the dist bundle renames MCP_USERNAME/MCP_PASSWORD to MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD; and the Helm chart ships mcpUsername=mcp with a generated mcpPassword instead of admin/changeme.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 22, 2026
Follow-up to the #177 integration on pr177-integration, addressing the
CREDENTIAL/BACKEND/HELM/DIST/DOCS half of bonnyr-f5's BLOCK on PR #193.

B1 (SECURITY): ensure_service_user no longer adopts any human account whose
password is a known default. The adoption exception is now scoped to v2_155's
exact backfill fingerprint (username=='mcp' AND email=='mcp@bnk-forge.local'),
matching the migration's own conservative rule, and must_change_password is no
longer cleared on an adopted row. Adds tests proving a human operator/changeme
row (and a wrong-email mcp row) is REFUSED, not taken over.

B2/B2b: all five compose files (root + dist docker-compose{,.local}.yml and the
IBM embedded compose) honor legacy MCP_USERNAME/MCP_PASSWORD as aliases for
MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD on both backend and mcp env, so an
existing customer .env keeps working after upgrade. Docs (dist/README.md,
dist/.env.example, user-pack/install-guide.html) document MCP_SERVICE_PASSWORD as
canonical with MCP_PASSWORD honored as a legacy alias.

B3: ENVIRONMENT is plumbed to the backend in every compose file, so an operator
who sets ENVIRONMENT=staging|production actually reaches config.py's MCP
fail-fast. Helm already routes ENVIRONMENT=production onto api/worker/beat.

M1: the unset-MCP behavior stays "disabled" (#188 over #186); added an explicit
deliberate-consolidation comment at the decision point.

M2: disable_stale_service_user skips the about-to-be-reconciled row and the
"no usable MCP_SERVICE_PASSWORD" warning is conditional on a configured password,
so a correctly-configured install no longer logs a false warning or commits an
inactive MCP window on every boot.

M7: the Helm mcp deployment and the IBM installer's mcp service now run the exec
auth-probe healthcheck (python -m bnk_forge_mcp.healthcheck) instead of a bare
tcpSocket probe.

M8: the chart fails the render for a reserved mcpUsername (admin), mirroring the
mcpPassword guard, and NOTES.txt/values.yaml call it out.

Minors: deterministic checksum/secret via a shared helper (stable across renders,
identical across api/worker/beat/mcp); vestigial _persist_generated_password
filename docstring; false "backend generates its own secret" rationale corrected
in compose/helpers/ibm; mcp_live_smoke.py #186/#188 attribution; e2e/config.py
changeme default note; .env.example :58/:73/:94 fixes; unified BNK_FORGE_VERSION
to latest across dist.

Validation: ruff clean; typecheck-backend (core/ schemas/) Success 38 files;
199 auth/credential/startup/ws/migration tests pass incl. new B1 tests; helm lint
+ template stable checksums, --set secrets.mcpUsername=admin and
secrets.mcpPassword=changeme both FAIL; docker compose config on all five modes
shows the backend receiving MCP_SERVICE_PASSWORD (via either alias) and ENVIRONMENT.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
bonnyr-f5 pushed a commit that referenced this pull request Aug 24, 2026
)

* Integrate the #177 follow-up stack (#179 #180 #181 #182 #183 #186 #188)

Consolidated landing of seven interdependent PRs whose shared credential and
release/CI surfaces prevented merging in any order (see issue #192's conflict
matrix). Merged in dependency order 179, 180, 181, 188, 186, 183, 182; the
#186/#188 credential surface was reconciled once (single reserved-name guard;
provenance + migrations + stale-disable combined with rotation + backend MCP
wiring + threadpool). Squashed to one commit; per-PR history retained on the
seven archived branches.

Validated on the merged tree: ruff + mypy clean; 172 auth/credential/startup/
migration tests pass; single alembic head v2_155; openapi + frontend types fresh;
helm lint/template and docker compose config green on all modes; version and
detector self-tests green; commit-message lint clean.

Closes #192.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): reconcile the merged unset-MCP-password model and harden the credential seed

Addresses mwiget's three CHANGES_REQUESTED blockers on the #177 integration PR
(#193), plus follow-up findings from a max-effort review of the same credential
surface. The merge chose #188's "unset MCP_SERVICE_PASSWORD -> disable" over
#186's "unset -> generate", so the generate/rotate-on-unset code was left
unreachable but still documented, and the release-notes footer was missing.

BLOCKING 2 (core/config.py): the MCP_SERVICE_PASSWORD comment claimed
ensure_service_user "generates a random secret and surfaces it once" when unset,
contradicting the merged behaviour. Rewrote it to state the truth: when unset (or
a known default) seed_auth_step calls disable_stale_service_user, leaving the mcp
account disabled/unavailable until an operator configures a real password; a
published default is refused and rotated out; the backend receives
MCP_SERVICE_PASSWORD on every deploy mode and reconciles to it when set. Also
removed the duplicate #186 block that sat above the wrong field.

BLOCKING 3 (services/auth_service.py): removed the unreachable usable_password is
None branches from ensure_service_user (the generate-on-create and
rotate-on-unset paths). seed_auth_step only calls it under the _mcp_pw_usable
gate, so password is never None/default in production. ensure_service_user now
requires a usable password and only creates/reconciles with it (failing closed
and loudly if handed an unusable one); the unset case is owned entirely by
disable_stale_service_user. Dropped the now-dead _log_generated_service_password
helper and the service-account token_urlsafe/_persist_generated_password calls
(_persist_generated_password is still used by the admin seed). Kept the
reserved-name guard, the provenance check, the adopt-a-published-default
remediation, and disable_stale_service_user fully intact. Updated the affected
unit tests (published-default/None now refused; added a reachable
adopt-and-reconcile test; stale-row setup builds the legacy row directly) and
fixed scripts/mcp_live_smoke.py, which pointed operators at
/app/keys/initial_mcp_password, a file no reachable path writes.

CR-1 (services/auth_service.py, seed_admin_user): fixed a concurrent-first-boot
admin lockout. With DEFAULT_ADMIN_PASSWORD unset and 2+ api replicas, both
generated different passwords and the loser overwrote the keys file while its
INSERT rolled back, so the file and the committed row disagreed. The fresh seed
now creates+flushes first (the loser's INSERT raises IntegrityError -> rollback,
no file write) and persists the keys file only after winning but before commit,
so the file can only ever hold the committed row's password. Added a
losing-replica test.

CR-5 (services/auth_service.py, _persist_generated_password): os.open's 0600 mode
only applies on create, so a pre-existing 0644 file was truncated in place and
kept 0644, writing the secret world-readable. Added os.fchmod(fd, 0o600) and a
test that a pre-existing 0644 file is tightened to 0600.

CR-2 (routes/k8s_websocket.py, dpus_websocket.py, benchmarks.py): the WS auth
validators called the blocking sync token_user_state directly on the event loop.
Moved it off the loop via run_in_threadpool, matching core/auth_middleware.py.

Validation: make lint-backend clean; mypy core/ schemas/ unchanged; auth
(57) + ws/benchmark/startup (79) suites pass; alembic heads single v2_155;
helm lint/template OK and --set secrets.mcpPassword=changeme fails the render;
docker compose config OK on all modes; extract-breaking-changes and
compute_version_bump self-tests pass; lint-commit-markers clean.

BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required — the backend SystemExits under ENVIRONMENT=staging|production when it is unset or a known default; the shipped admin/changeme default is removed and rotated out on upgrade; the dist bundle renames MCP_USERNAME/MCP_PASSWORD to MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD; and the Helm chart ships mcpUsername=mcp with a generated mcpPassword instead of admin/changeme.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): close bonnyr-f5's credential/backend/helm/dist/docs blockers

Follow-up to the #177 integration on pr177-integration, addressing the
CREDENTIAL/BACKEND/HELM/DIST/DOCS half of bonnyr-f5's BLOCK on PR #193.

B1 (SECURITY): ensure_service_user no longer adopts any human account whose
password is a known default. The adoption exception is now scoped to v2_155's
exact backfill fingerprint (username=='mcp' AND email=='mcp@bnk-forge.local'),
matching the migration's own conservative rule, and must_change_password is no
longer cleared on an adopted row. Adds tests proving a human operator/changeme
row (and a wrong-email mcp row) is REFUSED, not taken over.

B2/B2b: all five compose files (root + dist docker-compose{,.local}.yml and the
IBM embedded compose) honor legacy MCP_USERNAME/MCP_PASSWORD as aliases for
MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD on both backend and mcp env, so an
existing customer .env keeps working after upgrade. Docs (dist/README.md,
dist/.env.example, user-pack/install-guide.html) document MCP_SERVICE_PASSWORD as
canonical with MCP_PASSWORD honored as a legacy alias.

B3: ENVIRONMENT is plumbed to the backend in every compose file, so an operator
who sets ENVIRONMENT=staging|production actually reaches config.py's MCP
fail-fast. Helm already routes ENVIRONMENT=production onto api/worker/beat.

M1: the unset-MCP behavior stays "disabled" (#188 over #186); added an explicit
deliberate-consolidation comment at the decision point.

M2: disable_stale_service_user skips the about-to-be-reconciled row and the
"no usable MCP_SERVICE_PASSWORD" warning is conditional on a configured password,
so a correctly-configured install no longer logs a false warning or commits an
inactive MCP window on every boot.

M7: the Helm mcp deployment and the IBM installer's mcp service now run the exec
auth-probe healthcheck (python -m bnk_forge_mcp.healthcheck) instead of a bare
tcpSocket probe.

M8: the chart fails the render for a reserved mcpUsername (admin), mirroring the
mcpPassword guard, and NOTES.txt/values.yaml call it out.

Minors: deterministic checksum/secret via a shared helper (stable across renders,
identical across api/worker/beat/mcp); vestigial _persist_generated_password
filename docstring; false "backend generates its own secret" rationale corrected
in compose/helpers/ibm; mcp_live_smoke.py #186/#188 attribution; e2e/config.py
changeme default note; .env.example :58/:73/:94 fixes; unified BNK_FORGE_VERSION
to latest across dist.

Validation: ruff clean; typecheck-backend (core/ schemas/) Success 38 files;
199 auth/credential/startup/ws/migration tests pass incl. new B1 tests; helm lint
+ template stable checksums, --set secrets.mcpUsername=admin and
secrets.mcpPassword=changeme both FAIL; docker compose config on all five modes
shows the backend receiving MCP_SERVICE_PASSWORD (via either alias) and ENVIRONMENT.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): close bonnyr-f5 CI/RELEASE/SCRIPTS blockers, majors, and minors

Blockers:
- B4 registry-tag-probe.sh: replace GNU-only BRE alternation (\|) with
  portable `sed -nE (access_token|token)` so the token parse works on BSD/
  macOS sed; on BSD the empty token classified every image `unknown` and
  routed the operator into FORCE_LATEST=1, overwriting immutable :VERSION
  manifests (INV-24). Reproduced under `sed --posix`, fix proven to parse T2.
- B5 registry-tag-probe.test.sh had no caller and was red on BSD. Both the
  Makefile script-selftests target and ci.yml's script-selftests job now
  enumerate and run every scripts/tests/*.test.sh, failing on an empty
  enumeration or any non-zero rc.
- B6 lint-commit-markers.sh: replace the spoofable committer-identity
  exemption (GitHub <noreply@github.com> + single parent) with an
  unspoofable "already reachable from origin/main|origin/staging" check;
  lint the PR title (PR_TITLE via env) on pull_request events; split the
  rules so machine/already-merged is exempt for the marker rule but the
  spurious-major rule always applies.

Majors:
- M3 release.yml overwrite guard: derive the vacuity floor from an
  independent source (docker-bake.hcl default group, sourced from the
  workflow-ref tooling) and assert the probe's exit status before trusting
  its output, so an unavailable probe fails closed instead of "safe".
- M4 (INV-31): generate release notes and run the registry existence-probe
  BEFORE the irreversible push in release-final/release-manual (new shared
  scripts/registry-overwrite-guard.sh); release-publish keeps its own
  in-critical-section re-check.
- M5 make script-selftests now runs the INV-15 detector-parity diff
  (extracted to scripts/tests/detector-parity.test.sh) so local == CI.
- M6 extractor self-test runs unconditionally with anti-vacuity assertions
  (ok lines + END marker), no longer gated on grepping its own --self-test.

Minors: stale cross-PR comments in release.yml and extract-breaking-changes.sh;
removed the duplicate Makefile version-check target; `git add dist/VERSION`
no longer swallows failures; first-ever-release notes range fixed; CHANGELOG
insertion asserts a non-no-op before committing; refreshed .trivyignore
CVE-2026-7598 review deadline; removed e2e-tests.yml dead `|| true`; documented
the new Docker dependency in the pre-push hook.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r2): close credential/backend/helm/dist blockers — drop the username alias, make ENVIRONMENT=production satisfiable, and fix the mcp liveness probe

bonnyr-f5 round-2 BLOCK, credential/backend/helm/dist/docs half.

B-1 (INV-12): the compose files aliased the SERVICE username
(MCP_SERVICE_USERNAME:-${MCP_USERNAME:-mcp}); a legacy .env with MCP_USERNAME=admin
resolved it to `admin`, and against the guardless image `latest` still points at,
the old ensure_service_user rewrites the human admin row to `changeme` every boot.
Drop the username alias across all five compose files + the ibm embedded compose
(keep the harmless password alias), and default BNK_FORGE_VERSION to 4.0.0 (the
release this tree becomes, first image with the guards) instead of `latest`, so a
compose file can never hand the new credential contract to a pre-guard image.

B-2: ENVIRONMENT=production reaches validate_production, which also gates on
JWT_SECRET_KEY / ENCRYPTION_KEY / ALLOWED_ORIGINS — none of which were deliverable
from a compose install, so the switch bricked the backend. Plumb all three into
every x-backend-env anchor (four compose files + ibm) and document them in the
env examples; treat an empty ("" from ${VAR:-}) key as unset in config.py so the
plumbed empty default auto-generates rather than passing as a real empty key.
_persist_or_load_key now flags only keys WE generated as auto_generated (sidecar
.autogen marker), so an operator-provisioned key on the volume validates while a
fresh prod boot still fail-fasts permanently.

M-4: mcp liveness returned non-zero when the BACKEND was unreachable, so k8s
restarted the pod for a dependency outage. Move the auth-probe to readiness only;
liveness is tcpSocket. Fix mcp-server/README + mcp_live_smoke hints to name only
the vars each process actually reads (container: BNK_FORGE_*, backend: MCP_SERVICE_*).

Minors: refuse a known-default DEFAULT_ADMIN_PASSWORD on fresh seed + helm
adminPassword fail-guard; guard secrets.yaml mcpUsername nil with kindIs "invalid";
make the Python reserved-name check case-insensitive/trim to match Helm; neutralise
the hash when disabling a stale service account; correct the benchmarks.py
JWT-gate comment; surface an empty MCP_SERVICE_PASSWORD in install.sh; fix the
.env.example "No .env file is needed!" contradiction.

Tests: config B-2 satisfiability + provenance-marker tests; seed_auth_step against
an admin-still-holds-changeme upgrade DB; case-insensitive reserved-name and
disable-hash-neutralisation cases. ruff clean, mypy clean, 4831 unit pass, helm
lint/template green, docker compose config verified on all modes.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): close r2 CI/RELEASE blockers B-3, M-1..M-3 + scripts/release minors

B-3 (commit-lint exemptions): key the already-merged exemption on the range
BASE (github.event.before), not the post-push tip, so a new skip-CI marker on a
push to main/staging is caught while a genuinely-already-merged base commit stays
exempt; replace the self-settable `^release: ` subject exemption with
release.yml's own version+trailing-skip fingerprint.

M-1 (spurious-major rule): redefine rule 2 as the exact complement of the
detectors, sourced from the shared predicate, so it flags only a marker the
detectors would MISS (never dash-bullet, markdown-bold or indented shapes);
give it the same already-merged exemption; and lint inputs.release_notes through
the script before it becomes a release commit/tag.

M-2 (overwrite guard floor): derive the vacuity floor from `docker buildx bake
--print default | jq '.group.default.targets | length'`, scoped to the default
group, so a second bake group no longer wedges the release; separate bake-file
parse failures from registry-unreachable in the messaging. Single-source the
policy: release-publish and make push-images now call the one guard.

M-3 (portability): drop bash-4 mapfile from the probe test; rebuild the compute
self-test newline expansion with awk to avoid the bash-3.2 parameter-expansion
cliff, so make script-selftests runs under stock macOS bash 3.2.

Detector single-sourced into scripts/lib/breaking-change-detect.sh (compute,
extract, lint all source it); detector-parity test asserts the wiring; added
mutation tests for the lint rules and the overwrite guard.

Minors: fix version-consistency misdiagnosis of a column-0 YAML comment; correct
the compute/extract parity docstrings and the docker-bake four-push-paths note;
wire artifact-network-self-test into ci-gates; make the pre-push hook migration
message reachable under set -e; omit the false provenance buildStartedOn; filter
the release CI-status poll by commit SHA.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): seed the re-enable-guard test's default-hash row directly

The round-2 disable_stale_service_user hash-scrub (bonnyr-f5 #193 minor) collided
with the re-enable guard's own regression test: _seed_disabled_default_mcp built
its "disabled while holding the published default" state BY CALLING
disable_stale, which now scrubs the hash -- so holds_known_default_password was
false and the PUT re-enable was allowed (200) instead of refused (400).

The guard defends a row taken inactive by a path that LEAVES the credential
intact (a manual operator PUT), not one disable_stale scrubbed. Seed that state
directly (set is_active=False on the default-hash row) so the guard's real
scenario is exercised; assert the default hash survives the seed. Corrected the
now-stale guard comment in routes/auth.py that still claimed disable "only flips
is_active". Neutralisation and its asserting tests are unchanged.

Verified: TestServiceAccountReEnableGuard 2/2 pass; the three affected auth files
(test_startup_seed_auth, component/test_auth_service, integration/test_routes_auth)
97/97 pass; ruff clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r3): fail-close key provenance (B-2) + credential/auth minors

Own the round-3 CREDENTIAL/AUTH findings.

B-2 (BLOCKER): _persist_or_load_key classified a marker-less key file as
operator-provided, so every upgrade keys volume (key present, no marker) let
SEC-006's fail-fast pass OPEN on an auto-generated JWT/ENCRYPTION secret in
production. Invert the sentinel: a marker-less key now classifies AUTO-GENERATED
(fail closed); an operator asserts provenance with an explicit <filename>.operator
opt-out marker. No marker is written on generation, which also removes the second
trigger (a partial marker write can no longer downgrade provenance). Regression
tests seed the PREVIOUS-RELEASE on-disk shape and assert validate_production
raises under ENVIRONMENT=production.

Minors:
- Write the key via os.open(0o600)+fchmod so the secret is never briefly 0644.
- Single-source the MCP known-default denylist: delete the local tuple in
  auth_service and use core.config.MCP_KNOWN_DEFAULT_PASSWORDS (comment notes the
  helm copy is deploy-owned).
- Correct holds_known_default_password docstring (disable_stale now scrubs the
  hash; this guard covers the other disable paths).
- Reconcile ENCRYPTION_KEY docs with reality: the keys-file is the source of
  truth for at-rest crypto; the env var only drives the production gate
  (encryption.py comment + .env.example).
- Clarify the v2_155 custom-username remedy in disable_stale docstring.

Test-gaps:
- Normalise the service username (trim/casefold) at the reconcile lookup and the
  disable skip filter, so " mcp "/"MCP" reconciles the existing mcp row instead of
  minting a second service account and disabling the live one.
- Honest seed_admin_user log/logic under DEFAULT_ADMIN_MUST_CHANGE=false (no more
  "must change on first login" when no gate was applied).
- disable_stale_service_user(skip_username=...) leaves the live row wholly
  untouched (no inactive window), variant included.
- db.commit() failure after the keys file is written leaves a retriable state
  (published default still authenticates, orphan file password does not).

All owned-suite tests pass; ruff clean. Each fix reproduced then mutation-tested.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r3): close the release/CI blocker + major + every release/CI minor

M-6 (blocker): commit-lint no longer reds unamendable merge history.
- rule 2 now flags ONLY a mis-anchored DECLARATIVE marker (the colon form);
  a colonless marker-shaped PROSE line the detectors treat as inert (an
  already-merged body such as "- <MARKER> footer in the body ...") is no
  longer flagged, so the push-to-main range (before..head, which INCLUDES the
  PR merge-base) goes green without a history rewrite. Detection of a real
  mis-anchored marker is unchanged.
- deleted the already-merged exemption as dead code: base..head excludes the
  base by construction, so no scanned commit can ever be an ancestor of it.
  Removed the BEFORE derivation, _already_merged, tests B3.2/M1.5, and the
  ~20-line header claim. The release-bot exemption stays.
- rule 2 now scans the whole body via _under_detected_markers and reports
  EVERY mis-anchored marker, not just the first.

M-7 (major): secret-scan no longer false-fails a delete-only range. A
delete-only commit has rev-list count > 0 but gitleaks scans 0 (it scans
added content), so the count-based backstop is replaced by a range-
resolvability check plus gitleaks' exit status.

Release/CI minors:
- release-bot fingerprint single-sourced to scripts/lib/is-release-bot-subject.sh;
  release.yml's inline copy byte-locked by a parity self-test; dropped the
  false unforgeability claim and documented the residual honestly.
- registry-overwrite-guard: added a fail-closed default arm for an
  unrecognised/empty probe status (+ malformed/empty test scenarios).
- Makefile push-images: FORCE_LATEST now overrides ONLY the recency guard; a
  new FORCE_OVERWRITE overrides ONLY the immutable-tag guard; fixed the
  missing-jq remediation text.
- registry-tag-probe: the network arm now matches the real doubled "000000"
  curl-failure shape (was dead code); test fixture reproduces it.
- INV-15: single-sourced the marker regex (one canonical value + a
  detector-parity assertion that every embedded copy is byte-identical).
- release.yml Publish summary counts what buildx actually pushed (bake
  --metadata-file), not the static target list.
- registry-tag-probe test enumerates the bake DEFAULT group (scoped), matching
  the guard's enumeration.
- added scripts/tests/secret-scan.test.sh (fake-docker mutation suite).

release.yml: added a post-push step running scripts/verify-image-pins.sh so a
release cannot complete while shipping an unpublished image pin (script owned
by the deploy agent; referenced by path from .release-tooling).

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r3): single-source every deploy version pin + close deploy majors/minors

B-1/B-1b: dist/docker-compose{,.local}.yml, dist/.env.example and
ibm_cloud_bnk_forge.sh hard-pinned 4.0.0, a tag that has never been published,
while Helm pinned 3.1.6 -- two shipped paths naming two versions, one of which
does not exist. Bring all of them under sync-version-artifacts.sh (new PIN/
DISTENV readers+writers, --check, --list) so every pin derives from VERSION
(3.1.6, which exists) and the release re-stamps them atomically via the existing
--write $NEW; drop the dist/ disclaimer. Add scripts/verify-image-pins.sh (+
selftest) that resolves every shipped compose image: pin against the registry and
fails on manifest unknown, wired post-push in the release job.

M-1..M-5: reword NOTES/compose/chart comments that asserted post-guard behaviour
as already-true on the pre-guard pinned image (they land with the guard-carrying
release); NOTES now leads with the required ALLOWED_ORIGINS override; dist/README
and the install guide stop recommending latest/3.1.6 and the keys-file cat the
pinned image does not write; install.sh strips quotes and rejects the known-
default MCP passwords so the "MCP not active" warning fires instead of a green
lie; add deploy-version-lockstep + helm-known-defaults-lockstep selftests.

Deploy minors: MCP_USERNAME "do not set" made consistent across docs/Makefile;
chart ENCRYPTION_KEY now emits a valid Fernet key; DEFAULT_ADMIN_* added to the
ibm P3 backend env; secrets.mcpUsername defaults to "mcp" on null; ibm mapfile ->
portable while-read.

Verified: sync --check exit 0; --write round-trip moves every pin and restores;
helm lint/template clean (default + origin override); script selftests green;
bash -n + shellcheck clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): unify the at-rest encryption key (B-3), fail-close key marker (M-1), row-matches-client (M-2) + credential minors

B-3: validate_production gated ENCRYPTION_KEY while a second, independent generator
in core/encryption.py produced the real at-rest Fernet key unchecked — setting
ENCRYPTION_KEY (the documented remedy: token_hex(16), not even a Fernet key) turned
the gate green while encryption auto-generated a different key. Unify: one key file
(_encryption_key_path == core.encryption.ENCRYPTION_KEY_FILE), one generator. When
ENCRYPTION_KEY is set it is VALIDATED as a real Fernet key (fail clearly if not),
written to that file with a .operator marker, and consumed by core.encryption and
services.backup_service; the provenance flag reflects the value that actually
protects data. Never clobber an operator-marked key on a mismatch. config.py:319
and .env.example now print the Fernet recipe.

M-1: _persist_or_load_key required a regular-FILE marker (os.path.isfile, not
os.path.exists — a directory no longer counts) and treats "marker present, key file
absent" as a provisioning error: generate but do NOT persist, so the stale-marker
rotation gesture can never heal into auto=False on the next boot.

M-2 (regression this PR introduced): ensure_service_user normalised the username
before lookup/create, so MCP_SERVICE_USERNAME=MCP seeded 'mcp' while the client
sends the raw 'MCP' and authenticate_user matched exactly -> login denied. Create/
reconcile under the RAW value (what the client sends); the disable_stale skip keys
on the same raw value; only the reserved-name guard normalises. Fixed the false
"Matches the Helm chart lower|trim" docstring.

Credential minors: non-vacuous localhost-CORS test (valid MCP password so only the
CORS branch fails) + wildcard is now an exact origin-list entry, not a substring;
new DPU-websocket must-change/unresolvable-user tests mirroring the k8s twin;
middleware unresolvable-JWT-subject-refused and exact-vs-suffix exempt-path tests;
corrected the denylist copy count (4th copy in dist/install.sh); corrected v2_155's
rationale (v2_154 is new in this diff, not "already shipped"); documented why
ensure_service_user's adoption branch is kept.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): close deploy-surface blocker + majors (B1, M7-M12) and deploy minors

B-1: a fresh install of the pinned 3.1.6 bundle seeded an admin nobody could log
in as. `${DEFAULT_ADMIN_PASSWORD:-}` delivers the var present-and-empty; 3.1.6's
`DEFAULT_ADMIN_PASSWORD: str = "changeme"` is then overridden by "" and the login
schema rejects "" (422). The prescribed `${VAR}` form does NOT omit on this compose
(map interpolation still renders ""); the working omit-when-unset form is a map
entry with NO value (passthrough / `docker run -e KEY` semantics). Converted
DEFAULT_ADMIN_PASSWORD to passthrough across all four compose paths (dist base+local,
root base+local, IBM embedded). Swept the class: JWT_SECRET_KEY/ENCRYPTION_KEY also
converted (3.1.6 uses `if KEY is None`, so "" is used literally, not auto-generated);
MCP_SERVICE_PASSWORD kept as `${...:-...}` because omitting it restores 3.1.6's known
default "mcp-service-changeme". Added a commented DEFAULT_ADMIN_PASSWORD block to
dist/.env.example. Verified via `docker compose config` + real container env both
directions (unset -> omitted; set in .env -> forwarded).

M-7: chart checksum/secret did not change on mcp-password rotation (3 renders, 3
generated passwords, one identical checksum). Made all generate/rotate fallbacks
deterministic (deriveSecret, release-seeded) so the Secret is stable across renders
and includes, and hash the RENDERED Secret so the annotation tracks every resolved
value. Now stable across renders, identical across the 4 deployments, and it flips
when any resolved value changes.

M-8: dist/install.sh credential guard failed open on `changeme ` (whitespace). Trim
leading/trailing whitespace around the quote-strip before the known-default compare.

M-9: removed newly-added forward-dated 4.0.0 prose (install-guide.html x2, DOCKER.md).

M-10: default helm install crashlooped (production + localhost). Added a render-time
guard mirroring backend validate_production (fail on wildcard under staging/production,
localhost under production); defaulted ALLOWED_ORIGINS to empty so the bare render
boots (empty is neither wildcard nor localhost). `helm lint` and bare `helm template`
stay green; the guard fires with a clear message on a real fatal posture.

M-11: portable in-place sed in the IBM installer (`sed -i.bak … && rm`), both sites.

M-12: dist/ no longer ships published default DB/redis creds on host networking.
install.sh generates strong POSTGRES_PASSWORD/REDIS_PASSWORD on the fresh .env (like
Helm/IBM); .env.example ships them empty; a pre-existing default triggers a warning.

Deploy minors: extended deploy-version-lockstep.test.sh to the bnk-operator chart
(appVersion + image.tag) and dist/VERSION; brought dist/VERSION under
sync-version-artifacts.sh (--check/--list/--write green); reworded install.sh's MCP
UNHEALTHY assertion to match what the pinned image actually reports; added
scripts/tests/ibm-compose-drift.test.sh freezing the credential/hardening env so the
IBM embedded compose and dist/docker-compose.yml cannot silently diverge.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): make the pin verifier reachable pre-push, de-vacuum the parity/self-test harnesses, and close the release minors

B-2 (blocker): scripts/verify-image-pins.sh could never pass where the release job
runs it — from the 4-file sparse .release-tooling checkout that holds no compose
file (ROOT resolved there), with REGISTRY/VERSION handed in as env vars the script
reads only as flags, and the whole step wired AFTER the tag/Release/push/signing.
Fixes, end to end:
  - add a consistency mode (--expect-version) that asserts every shipped first-party
    pin already renders to $NEW without a registry probe, and run it as the PRIMARY
    PRE-push gate in release-final and release-manual (before anything irreversible);
  - fix the post-push existence step to pass REGISTRY/VERSION as FLAGS and the compose
    files explicitly by --file (they live at the tag checkout at the workspace root),
    keeping it as a secondary confirmation;
  - widen the default file set to include the IBM Cloud installer's embedded compose;
  - add a dryrun-release-tooling job that rebuilds the exact publish-job layout and
    exercises both invocations against a fake probe, and gate release-publish on it,
    so a step that cannot execute is caught before it is wired ahead of a signature.
  The sibling registry-tag-probe.sh / registry-overwrite-guard.sh read nothing
  $ROOT-relative outside the sparse set, so they are unaffected.

M-3: detector-parity.test.sh enumerated the marker copies with the very token that
drifts, so a copy that drifted in the token vanished from enumeration (drifting
:96-97 dropped the count 5->3 yet stayed green). Enumerate by position/count instead:
an exact per-file canonical count plus a stable-anchor site scan that flags any
drifted site even under a compensating add.

M-4: the filesystem self-test loop checked only a non-empty enumeration and each
file's exit 0, so a test gutted to a no-op passed and deleting 7 of 8 stayed green.
It now requires each file to emit PASS lines, no FAIL line, and an ALL PASS terminal
marker, plus a count floor derived from git's tracked *.test.sh set (detector-parity
was conformed to that output convention).

M-5: release-rc created and pushed the RC tag before the fail-closed notes step;
the tag is now created locally, notes generated, then the tag pushed.

M-6: added mutation-tested coverage for this PR's four previously-uncovered lint
fixes (the PR-title lint, the pending-message lint, the RANGE fail-closed branch,
and the skip-checks trailer rule).

LEAD: the anti-vacuity staging floor derived the count from a stale literal while
--list grew to 8 paths; both sites now derive it from --list and require every listed
path to stage, and the stale comments are corrected.

Release minors: scope the release-bot commit-lint exemption to the range tip (a
forged release subject buried mid-range is no longer exempt) and add a REACHABLE
published-history exemption anchored to the last release tag so a mis-anchored marker
in unamendable history cannot red the release; add fixtures for the untested registry
probe/guard arms (5xx, unexpected code, unknown status, bake-parse failure);
ancestry-filter the LAST_FINAL tag queries so a tag on another branch cannot skew the
notes range; reconcile the empty-RANGE handling (commit-lint now fails closed on an
explicit empty RANGE, ci.yml leaves it unset); give the pre-push hook a clear message
and a fetch fallback when the remote tip is absent locally; derive the cosign
verify-identity org from REGISTRY instead of hardcoding it.

Flagged: gate Makefile push-customer-build through registry-overwrite-guard.sh, the
last documented push path that was still unguarded.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): keep M-7 checksum-tracks-rotation WITHOUT predictable secrets

The r4 deploy fix closed M-7 (checksum did not move on a secret rotation, so pods
never rolled) by making the generated fallbacks deterministic -- deriveSecret =
sha256(Release.Name|Namespace|fullname|purpose). Every input is public (they appear
in resource labels and the chart source), so that made the JWT signing key, the
at-rest Fernet key and the admin/mcp passwords COMPUTABLE by anyone who can read a
label -- a forge-any-token / decrypt-all-secrets exposure, strictly worse than the
cosmetic churn it fixed.

Revert generation to randAlphaNum (unpredictable) and instead hash the DETERMINISTIC
inputs that determine the Secret -- values.secrets, the persisted .data (reused via
lookup), plus a per-credential "rotating-from-default" marker for admin/mcp whose
persisted value is a known published default. That tracks every rotation (operator
edit, persisted-value change, rotate-away-from-default) so the pods roll, is stable
across renders including a bare no-cluster `helm template` (the hashed inputs carry
no randomness), and never derives a secret from public identity. deriveSecret removed.

New scripts/tests/helm-secret-checksum.test.sh locks all three: stable-across-renders,
changes-on-rotation, and generated-value-is-random -- so the determinism cannot return.

Verified: helm lint 0-failed; bare + override template OK; the M-10 render guard still
fires on production+localhost; the new selftest ALL PASS.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): self-review — close the encryption-key data-loss BLOCKER + deploy minors

A cold adversarial self-review (three auditors mirroring the reviewer's method) of
the r4 changeset found a data-loss BLOCKER we introduced this round, plus deploy
minors. Fixing before it ships.

B-3 (BLOCKER, data loss): the r4 encryption-key unification made ENCRYPTION_KEY env
OVERWRITE an existing at-rest key file on boot. Traced only to the "operator freshly
sets the key" consumer, never to (a) backup_service restore, which writes the backup's
key to that file, or (b) the r3->r4 upgrade population, whose data was encrypted under
a marker-less auto-gen FILE key (r3 core.encryption used the file regardless of env).
The first r4 boot clobbered those -> restored/existing data undecryptable (or bricked
the boot on a marker mismatch). A new test even LOCKED the clobber with a false premise.
Fix: the at-rest key FILE is the single source of truth. ENCRYPTION_KEY only SEEDS the
file when it is ABSENT and never overwrites an existing one; the gate reads the FILE's
.operator provenance. backup restore now drops the .operator marker so a restored key
passes the gate without a clobber. Rewrote the clobber-locking tests to lock the
no-clobber invariant; added upgrade-shape, production-fail-without-data-loss, and
restore-marker tests.

M-7 (deploy self-review): removed the rotation-marker from secretsChecksum — the
auditor proved it redundant (the same .data change already moves the digest; deleting
it left the test green) and its admin branch dead. Kept the input-hash; documented the
genuine trilemma (cluster-less-template-stable / tracks-generated-rotation /
unpredictable-secrets — pick two; determinism is the predictable-secret hole).

M-10: the render guard's wildcard check is now an exact comma-split entry, matching the
backend's `"*" in cors_origins` since r4, so a legitimate `https://*.example.com` is no
longer blocked; the localhost check stays a substring to match the backend.

.env.example: the admin-password template was an empty assignment that uncomments into
a lockout; it now carries a replace-me placeholder.

Verified: backend 8082 passed; config/encryption 66, backup 13; script-selftests all
pass; helm lint/template clean (subdomain-wildcard passes, bare '*'/prod+localhost
fail); ruff clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r5): fail closed instead of regenerating over an existing at-rest key (I-1)

bonnyr-f5 #193 round-5 closure blocker. B-3 wrote down the invariant "the at-rest key
file is the single source of truth; nothing overwrites it once it holds bytes" and
config.py honoured it -- but core.encryption.get_encryption_key() did not. A file
present but under 32 bytes (truncated / partial write / disk full / bad restore) logged
"Invalid key, regenerating" and OVERWROTE it with a fresh key, permanently destroying
the key any existing data was encrypted under -- silently, on a GREEN production boot,
because the intact .operator marker keeps validate_production passing.

Fix: the read path now fails CLOSED. A genuinely absent (or 0-byte) file still generates
and persists a new key. A file that HOLDS bytes is validated as a real Fernet key: valid
-> returned untouched; unusable -> SystemExit with a clear message, never regenerated. A
crashloop is recoverable; an overwritten key is not. This also closes r5 note #3 -- the
old `len >= 32` check accepted any blob and surfaced a mis-shaped key as a later cipher
error; it now Fernet-validates and says so plainly.

Locked by TestAtRestKeyFileNeverRegeneratedOverBytes: a 30-byte truncated key -> SystemExit
AND the original bytes survive on disk (recoverable); a valid key -> returned untouched; an
absent file -> generates a valid key.

Verified: backend 8086 passed; encryption unit tests 19 passed; ruff clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

---------

Co-authored-by: John Gruber <john.t.gruber@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants