Skip to content

fix(OMN-17985): refuse an unregistered RUNTIME_PROFILE instead of silently falling back - #3238

Merged
jonahgabriel merged 1 commit into
devfrom
jonah/omn-17985-fail-closed-runtime-profile
Sep 6, 2026
Merged

jonahgabriel merged 1 commit into
devfrom
jonah/omn-17985-fail-closed-runtime-profile

Conversation

@jonahgabriel

@jonahgabriel jonahgabriel commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

OMN-17985 — the unregistered-RUNTIME_PROFILE fallback now fails closed

RUNTIME_PROFILE was read twice, by two code paths that disagreed about what it resolved to.

  • load_runtime_profile fell back to the default profile with a logger.warning. The resolved profile.name became default, so the ROLE IDENTITY the value carries was silently discarded.
  • The auto-wiring ownership filter never consulted that resolved profile at all. It read the raw env string and handed it, unvalidated, to filter_manifest_for_runtime_profile.

profile_ownership.py keeps a contract only when the profile is named in the contract's runtime_profiles list, or (for undeclared contracts) when the profile is main. An unregistered string satisfies neither, so every contract is skipped: the manifest empties, zero subscriptions are wired, and the process still passes readiness. The only signal was one warning emitted from a different function. That is the OMN-12950 orphan mechanism, and a fallback is the wrong shape for it — a role the runtime cannot resolve is not a milder role, it is an unknown deployment.

What changed

  1. load_runtime_profile refuses an unknown name — raises ProtocolConfigurationError naming the value and listing the known profiles, so an operator can fix it from the error alone. Unset or blank still resolves to default; that is unset, not unknown.
  2. resolve_runtime_profile_name() is the single validated read for ownership decisions. Its unset fallback is main — exactly the value the ownership filter already used, so consolidating adds validation and does not move a default. Twelve raw reads across service_kernel.py and runtime_host_process.py now go through it.
  3. The boot banner defaulted to default while every ownership read defaulted to main, so the process could name one role in its banner while wiring as another. It now reports the resolved identity.
  4. docker/Dockerfile.runtime shipped RUNTIME_PROFILE=all as an image-level default — a stale service-selection value from an older meaning of this variable, documented in the same file as all | minimal | kafka | secrets. No registry knows it, so any container built from this image that did not override it matched no contract's list and was != main: it wired zero contracts and stayed Ready. Now main. Every deployed lane (docker-compose.infra.yml, .judge.yml, .lakshman.yml, all three k8s clusters) overrides the value explicitly, so this changes only the un-overridden case, which was broken.

_current_runtime_profile_name in runtime_host_process.py deliberately keeps its own default fallback and is documented in place: it is compared against operator-written enabled_profiles lists, so moving that fallback would be a gating change rather than a validation change. It is safe to leave because an unregistered value can no longer reach a booted runtime.

RED then GREEN

tests/unit/runtime/test_runtime_profile_fail_closed.py is new and failed at import before the change (no resolve_runtime_profile_name); it now passes 31 assertions covering the refusal, the unset/blank paths, normalization, and that both readers agree on every known name. It carries a positive control (test_core_registry_names_are_all_bootable) so a gate that refused everything could not read as a pass.

tests/unit/runtime/test_runtime_profile.py::test_unknown_runtime_profile_still_falls_back_to_default is inverted here into test_unknown_runtime_profile_is_refused. The behaviour it pinned is the defect.

Verification

  • uv run pytest tests/unit/runtime/test_runtime_profile.py tests/unit/runtime/test_runtime_profile_fail_closed.py tests/integration/runtime/test_runtime_profile_registry_integration.py tests/unit/runtime/test_check_env_reads_gate.py tests/unit/runtime/test_service_kernel_introspection_identity_wiring_omn10856.py tests/ci/test_lane_projection_writer_coverage_omn17562.py => 97 passed, 1 skipped.
  • uv run pytest tests/unit/runtime/test_kernel.py tests/integration/test_writer_owned_projection_wiring_omn17519.py tests/integration/test_runtime_health_monitor_profile_filter.py tests/unit/runtime/test_pr_state_cross_profile_intent_routing.py => 65 passed, 2 skipped.
  • uv run ruff format / ruff check / mypy --strict on all three changed modules => clean.
  • pre-commit run --files ... on the six changed paths => all pass.
  • Governed pre-push selector escalated to the full suite on h101 => 26,695 passed. The one failure on the first attempt (test_policy_registry_performance.py::test_registration_throughput_regression, a 500 ms wall-clock threshold) is a load-sensitive benchmark in a module with prior flake history; it passes 3/3 locally in 0.36–0.57 s and shares no code with anything this PR touches. No override was minted — the push was simply re-run.

Scope note

This is the RUNTIME half. The registry half (registering the seven projection-writer-* names that already ship on onex-dev) is an omnibase_core change, and the manifest half is a gate in omninode_infra. Adding those names to _PROFILES here has to wait for the core release, because test_profiles_match_core_registry asserts exact equality with the core registry.

DoD evidence: OMN-17985, acceptance criterion 3 (the two readers now resolve one validated value) and criterion 4 (red-then-green).

Evidence-Ticket: OMN-17985
Evidence-Source: OCC#8379

…ently falling back

RUNTIME_PROFILE was read twice by two code paths that disagreed about what it
resolved to. load_runtime_profile fell back to the "default" profile with a
warning, discarding the role identity the value carries. The auto-wiring
ownership filter never consulted that resolved profile at all -- it read the
raw env string. An unregistered value matches no contract's declared
runtime_profiles list AND is != "main", so every contract is skipped: the
manifest empties, zero subscriptions are wired, and the process still passes
readiness. The only signal was one warning from a different function.

- load_runtime_profile now raises ProtocolConfigurationError on an unknown
  name, naming the value and listing the known profiles. Unset/blank still
  resolves to "default"; that is "unset", not "unknown".
- resolve_runtime_profile_name() is the single validated read for ownership
  decisions. Its unset fallback is "main", exactly the value the ownership
  filter already used -- consolidating adds validation, it does not move a
  default. Twelve raw reads across service_kernel and runtime_host_process now
  go through it, including the boot banner, which defaulted to "default" while
  every ownership read defaulted to "main" and so could name a role the process
  was not wiring as.
- Dockerfile.runtime shipped RUNTIME_PROFILE=all as an image default: a stale
  service-selection value from an older meaning of this variable. Any container
  from that image not overriding it wired zero contracts. Now "main".

_current_runtime_profile_name in runtime_host_process deliberately keeps its
own "default" fallback -- it matches operator-written enabled_profiles lists,
so moving that fallback would be a gating change, not a validation change. It
is safe because an unregistered value can no longer reach a booted runtime.

RED first: the new test module failed at import (no resolve_runtime_profile_name)
and the pre-existing test_unknown_runtime_profile_still_falls_back_to_default is
inverted here -- the behaviour it pinned is the defect.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hostile Reviewer — adversarial findings (OMN-17492)

Models succeeded: glm-review
Models failed: codex
New finding threads: 5
Deduped (already posted on this PR): 0
Nit-level findings suppressed: 1

The model is the FINDER, never the gate: merge is gated only by the
deterministic Hostile Review Thread Gate, which blocks while
hostile-reviewer threads are unresolved. Resolve each thread after
addressing (or rejecting, with a reply) its finding.

Findings not anchored to a changed file

  • [MAJOR] hostile-reviewer (glm-review)

    Raw read in _current_runtime_profile_name undermines the fail-closed invariant | The comment on _current_runtime_profile_name asserts safety because 'an unregistered value can no longer reach a booted runtime, because load_runtime_profile refuses it during kernel boot'. That guarantee only holds if every boot path calls load_runtime_profile. RuntimeHostProcess.main() and ServiceKernel may not be the only entrypoints; test harnesses, scripts, or alternate kernels that construct wiring helpers without going t

    Resolve this thread when addressed — the Hostile Review Thread Gate blocks while hostile-reviewer threads are unresolved (OMN-17492).

Findings demoted from threads (anchor rejected)

  • [MAJOR] hostile-reviewer (glm-review)

    The two readers still disagree on the unset fallback, contradicting the stated goal | The diff's stated purpose is that both readers of RUNTIME_PROFILE resolve the value through one validated function so they 'must not disagree about one variable again'. They still disagree: resolve_runtime_profile_name() maps unset/blank to "main" while load_runtime_profile() maps unset/blank to "default". The new test test_agrees_with_load_runtime_profile_on_every_known_name only exercises explicitly set, registered value

    Resolve this thread when addressed — the Hostile Review Thread Gate blocks while hostile-reviewer threads are unresolved (OMN-17492).

  • [MAJOR] hostile-reviewer (glm-review)

    resolve_runtime_profile_name can now raise inside long-lived async loops | The replacement of os.getenv reads with resolve_runtime_profile_name() converts previously infallible reads into raising calls at call sites inside background loops (_savings_correlation_loop, _wiring_health_loop) and message handlers (_triage_on_message). If the environment variable is somehow absent at these call sites but the process booted via a path that never validated (see prior finding), or if a container re-exec's these code

    Resolve this thread when addressed — the Hostile Review Thread Gate blocks while hostile-reviewer threads are unresolved (OMN-17492).

  • [MAJOR] hostile-reviewer (glm-review)

    Dockerfile default flip from 'all' to 'main' silently enables baseline subscriptions for existing default deployments | The image default changes from RUNTIME_PROFILE=all to RUNTIME_PROFILE=main. Under the old default, _baseline_subscription_wiring_disabled returned True (non-main), so containers that never overrode the variable ran no baseline contract-registry subscriptions. The new default flips that gate: any lane that inherited the image default and was (correctly or accidentally) relying on no baselin

    Resolve this thread when addressed — the Hostile Review Thread Gate blocks while hostile-reviewer threads are unresolved (OMN-17492).

  • [MINOR] hostile-reviewer (glm-review)

    No test that boot actually refuses an unregistered profile end to end | The new fail-closed tests exercise load_runtime_profile and resolve_runtime_profile_name in isolation. There is no test that RuntimeHostProcess.main() or ServiceKernel boot propagates ProtocolConfigurationError into a non-Ready state (nonzero exit, readiness gate) rather than crashing with a traceback in a partially initialized state or being caught by an over-broad exception handler upstream. The ticket's central claim ('raises during

    Resolve this thread when addressed — the Hostile Review Thread Gate blocks while hostile-reviewer threads are unresolved (OMN-17492).

  • [MINOR] hostile-reviewer (glm-review)

    test_core_registry_names_are_all_bootable does not cover whitespace/case forms of registered names | The positive control iterates REGISTERED_RUNTIME_PROFILES verbatim, but the normalization path (strip/lower) is exercised only by the single hardcoded ' MAIN ' case. If REGISTERED_RUNTIME_PROFILES contains mixed-case entries and _PROFILES keys were ever populated without normalization, the loop would pass while real env values normalized to a mismatched key and were refused. Low likelihood given _resolve_p

    Resolve this thread when addressed — the Hostile Review Thread Gate blocks while hostile-reviewer threads are unresolved (OMN-17492).

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ Hostile Reviewer — DEGRADED (informational)

Critical findings: 0
Major findings: 4
Total findings: 7
Models succeeded: glm-review

Note: Fewer than 2 reviewer models succeeded. Degraded results are informational (OMN-8468/OMN-8524) and do not block merge. Error: cli_review exit 2 (fewer than 2 models succeeded — partial/total outage)


Semantics (OMN-17492 — the model finds, thread resolution gates)

Surface Meaning Blocks merge?
Review threads Per-finding, posted by the reviewer No (informational)
Hostile Review Thread Gate Deterministic: unresolved hostile-reviewer threads exist Fails until resolved (not yet a required context)
degraded verdict Fewer than 2 models succeeded (infra) No

Powered by omniintelligence.review_pairing.cli_review — multi-model adversarial review: qwen3-review, qwen3-review-b, glm-review (OMN-8468/OMN-8524/OMN-17492)

jonahgabriel pushed a commit to OmniNode-ai/onex_change_control that referenced this pull request Sep 6, 2026
#8379)

* evidence: OCC companion pass 1 for OmniNode-ai/omnibase_infra#3238

* evidence: OCC companion self-bind for #8379

---------

Co-authored-by: node-occ-companion-effect <occ-companion-effect@omninode.ai>
@jonahgabriel
jonahgabriel merged commit 4cff061 into dev Sep 6, 2026
189 of 197 checks passed
@jonahgabriel
jonahgabriel deleted the jonah/omn-17985-fail-closed-runtime-profile branch September 6, 2026 11:59
jonahgabriel added a commit that referenced this pull request Sep 6, 2026
…5 registered (#3253)

* feat(OMN-17985): boot the seven projection-writer profiles core 0.47.5 registered

omnibase_core 0.47.5 admits projection-writer-{delegation,hook-ledger,
live-events,registration,savings,tenant-credentials,tenant-registry} into
REGISTERED_RUNTIME_PROFILES and CONSUMER_ATTACHED_RUNTIME_PROFILES. This repo
owns the behaviour half, and two guards make the pairing mandatory:

- test_profiles_match_core_registry asserts frozenset(_PROFILES) equals the
  core registry exactly.
- test_consumer_attached_profiles_actually_load requires every consumer-attached
  name to resolve through load_runtime_profile.

Since #3238 made that function REFUSE an unknown name, a name core blesses that
this dict lacks is now a hard boot failure rather than a silent fallback to
"default". RED proves it: bumping the core pin to 0.47.5 with _PROFILES
unchanged failed both guards, the second with ProtocolConfigurationError on
'projection-writer-live-events' -- the drift the parity guard exists to catch,
now loud instead of silent.

prefetch_policy stays "disabled" on all seven, like every other role-based
profile. No lane's behaviour changes: these names are already the deployed
RUNTIME_PROFILE values on onex-dev, and the processes carrying them are
standalone BaseProjectionRunner runners that never read the variable.

* chore(OMN-17985): rebind the three surfaces that mirror the core pin

Every commit that moves pyproject.toml or uv.lock rebinds these, per the same companion edit #3242 made for the 0.47.3 -> 0.47.4 move: the seam fixture that freezes the released core version, the version_compatibility min_version floor, and the runner image identity/shared-env digests (regenerated with scripts/ci/runner_image_identity.py --mode generate, then verified). The governed pre-push caught the first two as real reds on h201.2; the third was found by running the verifier rather than waiting for CI to say so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant