diff --git a/.github/omnimarket-contract-pin.yaml b/.github/omnimarket-contract-pin.yaml index 612ccaaaa8..8ea4289ab8 100644 --- a/.github/omnimarket-contract-pin.yaml +++ b/.github/omnimarket-contract-pin.yaml @@ -32,4 +32,4 @@ # An `Omnimarket-Source-Ref: ` PR-body trailer still overrides this pin for # cross-repo co-development (OMN-15703's escape hatch, unchanged). repository: OmniNode-ai/omnimarket -omnimarket_contract_ref: 49ee50d5f1b01cb9b91a8c3a5cb5da33fe2683f5 +omnimarket_contract_ref: 303aff486dfb02efcbc0e58747e19ac753a39e4d diff --git a/.github/workflows/check-handshake.yml b/.github/workflows/check-handshake.yml index a72f9d0bd5..598db172ec 100644 --- a/.github/workflows/check-handshake.yml +++ b/.github/workflows/check-handshake.yml @@ -34,8 +34,8 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: OmniNode-ai/omnibase_core - # Auto-bumped by omnibase_core publish-downstream-pin-bump.yml to 645ebea04116. - ref: 645ebea04116d45540ab3bff8ac0661ce08ad016 + # Auto-bumped by omnibase_core publish-downstream-pin-bump.yml to 1e69bec909f2. + ref: 1e69bec909f245be511803191554e5b340def0b7 path: omnibase_core # OMNIBASE_CORE_TOKEN is required when omnibase_core is private. # GITHUB_TOKEN only has read access to the current repo by default. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a13ef9ed6c..482a5b6d0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,13 +303,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - # OMN-19613: depth 2 brings in the pull_request merge commit's first - # parent (the base tip it was merged onto), which is what - # scripts/ci/precommit_diff_scope.py diffs against to scope the - # per-file Plugin* guard below. Without it the scope fails closed to - # the whole tree, which is correct but slower. - fetch-depth: 2 - name: Setup Python and uv uses: ./.github/actions/setup-python-uv @@ -378,22 +371,6 @@ jobs: - name: Single-canonical in-memory event bus (OMN-13419) run: bash scripts/validation/check_no_infra_inmemory_import.sh - # OMN-19613: the guard is an AST check on each file's own classes, so a - # file the PR did not touch cannot turn it red. On pull_request it scans - # only the PR's files; it scans the whole tree on push (which proves the - # merged union on dev, OMN-18835), merge_group and dispatch, when the PR - # changes .pre-commit-config.yaml or this workflow, when the PR moves the - # omnibase-core pin in uv.lock (the validator's code lives in core), and - # whenever the base cannot be resolved. The step always runs; only the - # file list narrows, so the check never goes missing from a PR. - - name: Check Plugin* lifecycle class guard (OMN-13284, relocated to omnibase_core) - env: - EVENT_NAME: ${{ github.event_name }} - run: | - scope="$(uv run python scripts/ci/precommit_diff_scope.py --event-name "$EVENT_NAME")" - mapfile -t scope_args <<< "$scope" - uv run pre-commit run no-plugin-daemon-classes "${scope_args[@]}" - # OMN-13030: Deployed-SHA claims in handoff/OCC/evidence docs must carry an # adjacent `docker exec cat /app/build-provenance.json` probe. # Enforcement, not detection: this runs the same gate as the pre-commit hook @@ -603,34 +580,59 @@ jobs: --baseline config/validation/transport_mock_baseline.yaml \ tests/ - # Canonical handler-shape GROWTH RATCHET fan-out (OMN-14355/OMN-14368/OMN-14587). - # compliance_sweep checks handler hygiene but is blind to handler SHAPE, so a - # non-canonical handler passes green CI. This classifies each omnibase_infra - # node's contract-declared handler (definition B: typed-payload handle(request) - # -> response, adapted by the shared runtime, no envelope in the core), WARNs - # the committed baseline's debt (scripts/ci/canonical_handler_shape_baseline.py, - # OMN-14587: 92 non-canonical / 115 scanned), and HARD-FAILS a NEW non-canonical - # node or baseline-count growth. Sourced from the sibling omnibase_core checkout - # above (.ci/omnibase_core, already cloned for Transport-mock lint) since the - # classifier lives at omnibase_core's repo-root scripts/ci/, not the installed - # package. Runs --full (not the default --scope changed): the classifier's - # diff-scoped git-diff runs with cwd = the classifier's OWN repo - # (omnibase_core parents[2]), wrong for a fan-out invocation against this - # sibling checkout — --full is cheap here (~115 nodes, sub-second). - # No pre-commit mirror yet: same infeasibility already verified for the - # omnimarket fan-out (OMN-14372) — pre-commit's language:python remote-hook - # install only ships the installed src/omnibase_core/ package (classifier - # isn't importable that way) and language:script has no dependency isolation - # (needs pydantic/pyyaml); real fix is OMN-14563 (relocate the classifier into - # an installed omnibase_core module). CI-only matches the omnimarket precedent. - # --receipts-dir is mandatory here (OMN-14633 fix): the classifier's default - # for a non-core --package is a directory NEXT TO ITS OWN __file__ (i.e. - # inside the ephemeral .ci/omnibase_core sibling checkout above), which - # never contains this repo's committed receipts. Without this flag every - # proven flip (adequacy receipt + equivalence artifact committed under - # THIS repo's scripts/ci/adequacy_receipts/) reports as unproven and - # HARD-FAILS CI, defeating the whole flip-gate — caught while proving - # node_coding_agent_workspace_compute's first flip against this baseline. + - name: Run mypy type checking + # OMN-18640: scripts/deploy-agent/deploy_agent is IN the scope. It is + # the code that runs every deploy on the lab lane and it sat outside + # the type gate carrying ten errors, three of which were functions + # declared to return `str` while returning `Any`. Its tests have their + # own workflow because it is a standalone uv sub-project; its TYPES are + # checked here, with the root project's mypy, because that is where + # this repository declares type scope. + run: uv run mypy src/omnibase_infra scripts/deploy-agent/deploy_agent + + # =========================================================================== + # Shape-gate detectors that run on every PR, whatever the preflight says + # (OMN-20298). Each job below has no `needs:` and no `if:`: the change-control + # preflight failing, or a skipped dependency, can never stop a detector from + # running. Every shape-gate failure in the 30 days before 2026-10-01 was the + # preflight failing first, so no detector produced a detection. These jobs + # were steps of `lint`, which waits on `occ-preflight`. Each is a STRICT + # CI Summary gate (scripts/ci/ci_summary_gate.py STRICT_GATE_JOBS). The + # `shape-gate-independence` job refuses any detector job that comes back + # behind the preflight. No baseline, allowlist or suppression exists for it. + # =========================================================================== + canonical-handler-shape-gate: + name: Canonical Handler-Shape Gate (OMN-20298) + runs-on: >- + ${{ + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository) + && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') + || fromJSON((github.event_name == 'merge_group' && vars.OMNI_REQUIRED_CI_RUNS_ON_JSON) || vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') + }} + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Setup Python and uv + uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ env.PYTHON_VERSION }} + uv-version: ${{ env.UV_VERSION }} + cache-version: ${{ env.CACHE_VERSION }} + cache-enabled: "false" + + - name: Checkout omnibase_core validator source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: OmniNode-ai/omnibase_core + ref: dev + path: .ci/omnibase_core + token: ${{ secrets.OMNIBASE_CORE_TOKEN || secrets.GITHUB_TOKEN }} + - name: Canonical handler-shape ratchet (OMN-14355 fan-out, OMN-14587) run: | uv run python .ci/omnibase_core/scripts/ci/canonical_handler_shape.py \ @@ -672,15 +674,70 @@ jobs: --baseline scripts/ci/canonical_handler_shape_baseline.py \ --receipts-dir scripts/ci/adequacy_receipts - - name: Run mypy type checking - # OMN-18640: scripts/deploy-agent/deploy_agent is IN the scope. It is - # the code that runs every deploy on the lab lane and it sat outside - # the type gate carrying ten errors, three of which were functions - # declared to return `str` while returning `Any`. Its tests have their - # own workflow because it is a standalone uv sub-project; its TYPES are - # checked here, with the root project's mypy, because that is where - # this repository declares type scope. - run: uv run mypy src/omnibase_infra scripts/deploy-agent/deploy_agent + no-plugin-daemon-classes-gate: + name: No Plugin Daemon Classes Gate (OMN-20298) + runs-on: >- + ${{ + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository) + && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') + || fromJSON((github.event_name == 'merge_group' && vars.OMNI_REQUIRED_CI_RUNS_ON_JSON) || vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') + }} + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 2 + + - name: Setup Python and uv + uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ env.PYTHON_VERSION }} + uv-version: ${{ env.UV_VERSION }} + cache-version: ${{ env.CACHE_VERSION }} + cache-enabled: "false" + + # OMN-19613: the guard is an AST check on each file's own classes, so a + # file the PR did not touch cannot turn it red. On pull_request it scans + # only the PR's files; it scans the whole tree on push (which proves the + # merged union on dev, OMN-18835), merge_group and dispatch, when the PR + # changes .pre-commit-config.yaml or this workflow, when the PR moves the + # omnibase-core pin in uv.lock (the validator's code lives in core), and + # whenever the base cannot be resolved. The step always runs; only the + # file list narrows, so the check never goes missing from a PR. + - name: Check Plugin* lifecycle class guard (OMN-13284, relocated to omnibase_core) + env: + EVENT_NAME: ${{ github.event_name }} + run: | + scope="$(uv run python scripts/ci/precommit_diff_scope.py --event-name "$EVENT_NAME")" + mapfile -t scope_args <<< "$scope" + uv run pre-commit run no-plugin-daemon-classes "${scope_args[@]}" + + shape-gate-independence: + name: Shape-Gate Independence (OMN-20298) + runs-on: >- + ${{ + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository) + && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') + || fromJSON((github.event_name == 'merge_group' && vars.OMNI_REQUIRED_CI_RUNS_ON_JSON) || vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') + }} + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.8.3" + + - name: Refuse shape-gate detector jobs that depend on the preflight (OMN-20298) + run: | + uv run --no-project \ + --with 'omnibase-core @ git+https://github.com/OmniNode-ai/omnibase_core@5220ecf1758b4475c2963ec13171b6431fdc2224' \ + python -m omnibase_core.validators.shape_gate_independence # RT-5 (OMN-14467): the deploy-trigger producer must fail closed on zero # output. Its RED-on-zero acceptance test and the anti-regression policy gate diff --git a/.github/workflows/planted-shape-gate.yml b/.github/workflows/planted-shape-gate.yml new file mode 100644 index 0000000000..dc19634f2e --- /dev/null +++ b/.github/workflows/planted-shape-gate.yml @@ -0,0 +1,10 @@ +name: Planted Shape Gate +on: [pull_request] +jobs: + occ-preflight: + uses: OmniNode-ai/omnibase_core/.github/workflows/occ-preflight.yml@52851458622f368c3b596c82bf810bc6acce1d5e + canonical-inference-gate: + needs: occ-preflight + runs-on: ubuntu-latest + steps: + - run: echo canonical-inference diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c147e4113b..aa973a3b20 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -85,6 +85,14 @@ repos: require_serial: true args: [--allowlist, .onex_ratchets/noncanonical_class_allowlist.yaml, src/omnibase_infra] files: ^src/omnibase_infra/.*\.py$ + # Shape-gate independence (OMN-20298): a shape-gate detector job never waits + # on the change-control preflight and never reads its result. BLOCKING, zero + # baseline, no allowlist, no suppression comment. CI twin: the + # shape-gate-independence job in .github/workflows/ci.yml (STRICT CI Summary gate). + - repo: https://github.com/OmniNode-ai/omnibase_core + rev: 5220ecf1758b4475c2963ec13171b6431fdc2224 # pragma: allowlist secret + hooks: + - id: check-shape-gate-independence # Hardcoded topic guard — sourced from onex_change_control (OMN-5256/OMN-5259) # Pre-existing violations excluded pending migration to canonical constants (OMN-5259) # OMN-13195 (phase A4): dropped the redundant `event_bus/topic_constants.py` diff --git a/config/migration_classes.yaml b/config/migration_classes.yaml index e25b057423..74a72e6986 100644 --- a/config/migration_classes.yaml +++ b/config/migration_classes.yaml @@ -357,6 +357,7 @@ migrations: forward/nodes/node_projection_savings/091_grant_app_dashboard_savings_series.sql: expand-only forward/nodes/node_projection_savings/091_savings_estimates_house_tenant_uuid_backfill.sql: forward-only # UPDATE (house slug -> house UUID); ALTER TABLE ALTER COLUMN SET DEFAULT; transaction-scoped CREATE/DROP POLICY inside one DO block forward/nodes/node_projection_savings/092_restore_savings_views_security_invoker.sql: forward-only # ALTER VIEW + forward/nodes/node_projection_savings/093_savings_series_persisted_baseline.sql: forward-only # CREATE OR REPLACE VIEW; ALTER VIEW SET (security_invoker) forward/nodes/node_projection_session_content/0001_create_session_content.sql: expand-only forward/nodes/node_projection_session_outcome/0021_session_outcomes.sql: forward-only # EXECUTE; ALTER TABLE ADD CONSTRAINT SESSION_OUTCOMES_PKEY; CREATE OR REPLACE; DROP; TRIGGER on an existing tab forward/nodes/node_projection_session_outcome/0022_grant_omninode_runtime_session_outcomes.sql: expand-only diff --git a/config/runner_routing_policy.yaml b/config/runner_routing_policy.yaml index b155f4c8e0..1a3478b20c 100644 --- a/config/runner_routing_policy.yaml +++ b/config/runner_routing_policy.yaml @@ -1055,6 +1055,7 @@ route: # STRICT in ci.yml and enforced by the CI Summary poller. - Bus ACL Boundary (OMN-19928) - call-reject-skip-token / scan / reject-skip-gate-token + - Canonical Handler-Shape Gate (OMN-20298) - Canonical Inference Gate - CI Evidence Policy (OMN-18247) - CI Summary @@ -1104,6 +1105,7 @@ route: - merge-hold-gate / evaluate - Migration Freeze Check - Migration Integration Test + - No Plugin Daemon Classes Gate (OMN-20298) - no-noncanonical-lifecycle-classes - Node Migration Declaration Check # OMN-19677: the five shared shrink-only callers are strict CI Summary @@ -1128,6 +1130,7 @@ route: # registered STRICT. Enforced by the CI Summary poller and absent from # branch protection, which is exactly the shape this list records. - Sanitized Legacy RDS Fixture Proof / PostgreSQL 16 Fresh + Legacy Fixture + - Shape-Gate Independence (OMN-20298) - Skip Count Baseline One-way (OMN-19677) / anti-growth-baseline - Skip Count Ratchet (OMN-18776) - Stale TODO Gate diff --git a/deploy/lane-census/census-snapshot.json b/deploy/lane-census/census-snapshot.json index 7abe530822..78ce7f0e13 100644 --- a/deploy/lane-census/census-snapshot.json +++ b/deploy/lane-census/census-snapshot.json @@ -1 +1 @@ -{"schema_version": "1.1.0", "event_type": "lane-census-drift", "topic": "onex.evt.infra.lane-census-drift.v1", "host": "omninode-pc", "host_id": "lab-201", "emitted_at": "2026-09-28T19:03:54.186487+00:00", "severity": "warning", "lanes_checked": ["stability-test", "judge", "dev", "lakshman", "prepr-1", "prepr-2", "ci-bus"], "lanes_not_applicable": ["dogfood", "sim-202", "dev-202", "dev-200", "dev-105"], "lanes_skipped_optional_down": ["prepr-1", "prepr-2"], "drift_count": 0, "findings": [], "alert_key": "lane-census-drift:omninode-pc:e3b0c44298fc1c14", "ticket_title": "lane census: no drift", "ticket_body": "## Lane census drift detected\n\nHost: `omninode-pc`\nLanes checked: stability-test, judge, dev, lakshman, prepr-1, prepr-2, ci-bus\nLanes not applicable on this host: dogfood, sim-202, dev-202, dev-200, dev-105\n\nThe desired-state lane census (deploy/lane-census/lane-manifest.yaml)\ndoes not match the live runtime. Drift items below name exactly what is\nmissing or extra. This is the regression class the lane-census ratchet\n(OMN-13011) exists to catch \u2014 it would have fired on 2026-06-11 when prod\nruntime containers and the broker network were silently absent.\n\n## Findings\n\n\n## Action\nBring the lane back to declared state (compose up the absent containers / reattach the network), or update the lane manifest in the same PR if the desired state legitimately changed. Do not silence the check."} +{"schema_version": "1.1.0", "event_type": "lane-census-drift", "topic": "onex.evt.infra.lane-census-drift.v1", "host": "omninode-pc", "host_id": "lab-201", "emitted_at": "2026-10-01T07:20:22.684544+00:00", "severity": "warning", "lanes_checked": ["stability-test", "judge", "dev", "lakshman", "principal-issuer", "prepr-1", "prepr-2", "ci-bus"], "lanes_not_applicable": ["dogfood", "sim-202", "dev-202", "dev-200", "dev-105"], "lanes_skipped_optional_down": ["prepr-1", "prepr-2"], "drift_count": 0, "findings": [], "alert_key": "lane-census-drift:omninode-pc:e3b0c44298fc1c14", "ticket_title": "lane census: no drift", "ticket_body": "## Lane census drift detected\n\nHost: `omninode-pc`\nLanes checked: stability-test, judge, dev, lakshman, principal-issuer, prepr-1, prepr-2, ci-bus\nLanes not applicable on this host: dogfood, sim-202, dev-202, dev-200, dev-105\n\nThe desired-state lane census (deploy/lane-census/lane-manifest.yaml)\ndoes not match the live runtime. Drift items below name exactly what is\nmissing or extra. This is the regression class the lane-census ratchet\n(OMN-13011) exists to catch \u2014 it would have fired on 2026-06-11 when prod\nruntime containers and the broker network were silently absent.\n\n## Findings\n\n\n## Action\nBring the lane back to declared state (compose up the absent containers / reattach the network), or update the lane manifest in the same PR if the desired state legitimately changed. Do not silence the check."} diff --git a/docker/docker-compose.infra.yml b/docker/docker-compose.infra.yml index 19d38fa16a..58c06cb583 100644 --- a/docker/docker-compose.infra.yml +++ b/docker/docker-compose.infra.yml @@ -680,8 +680,16 @@ services: - "${REDPANDA_PANDAPROXY_PORT:-18082}:18082" # Schema Registry - "${REDPANDA_SCHEMA_REGISTRY_PORT:-18081}:18081" - # OMN-4959: Admin API (used by omnidash event-bus-health-poller) - - "${REDPANDA_ADMIN_PORT:-9644}:9644" + # Admin API, published on the host loopback ONLY (OMN-20260). + # admin_api_require_auth is false on this broker (the healthcheck, the + # deploy-runtime.sh broker probe and the rpk one-shots call it with no + # credentials), so an all-interfaces publish let any LAN or tailnet host + # create SCRAM users and rewrite cluster config, undoing per-developer + # and per-tenant broker isolation. Every in-network consumer uses + # redpanda:9644 on the compose network and is unaffected; host-side + # tools on the lab host still reach localhost:9644. Same shape as the + # dev-105/dev-200/dev-202 overlays. + - "127.0.0.1:${REDPANDA_ADMIN_PORT:-9644}:9644" volumes: - redpanda_data:/var/lib/redpanda/data # OMN-13469: .bootstrap.yaml is the volume-reset-proof primary mechanism. diff --git a/docker/docker-compose.lakshman.yml b/docker/docker-compose.lakshman.yml index 0996f14118..7adabc9098 100644 --- a/docker/docker-compose.lakshman.yml +++ b/docker/docker-compose.lakshman.yml @@ -66,7 +66,7 @@ # postgres 45436 # valkey 46379 # redpanda kafka 55092 (external; container-internal stays 19092) -# redpanda admin 55644 +# redpanda admin 55644 (host loopback only, OMN-20260) # projection-api 53002 # keycloak 58080 reserved, unused — this lane runs no Keycloak # @@ -87,7 +87,12 @@ x-omninode-contract-overlay: lakshman-redpanda: advertised_kafka_addr: &lakshman_redpanda_advertised_kafka_addr "internal://redpanda:9092,external://localhost:55092" kafka_port_publish: &lakshman_redpanda_kafka_port_publish "55092:19092" - admin_port_publish: &lakshman_redpanda_admin_port_publish "55644:9644" + # Admin API on the host loopback ONLY (OMN-20260). admin_api_require_auth is + # false on this broker (the healthcheck and the rpk one-shots call it with no + # credentials), so an all-interfaces publish let any LAN or tailnet host + # create SCRAM users and rewrite cluster config. In-network consumers use + # redpanda:9644; host-side tools on .201 still reach localhost:55644. + admin_port_publish: &lakshman_redpanda_admin_port_publish "127.0.0.1:55644:9644" lakshman-model-secrets: # Correction (b): no required_secret_refs. Every cloud LLM ref below is # optional so the lane boots without any of them. diff --git a/docker/migrations/forward/_ledger/application-migrations.tsv b/docker/migrations/forward/_ledger/application-migrations.tsv index 21d925d021..756e465ff0 100644 --- a/docker/migrations/forward/_ledger/application-migrations.tsv +++ b/docker/migrations/forward/_ledger/application-migrations.tsv @@ -234,6 +234,7 @@ nodes/node_projection_savings/090_savings_aggregate_excludes_model_text.sql node nodes/node_projection_savings/091_grant_app_dashboard_savings_series.sql node:node_projection_savings node:node_projection_savings tenant node:node_projection_savings:091_grant_app_dashboard_savings_series.sql a814bc1923e77dfa589efdd09bc05406414bdf3a9ea4523eac5882bbb7dd4d91 nodes/node_projection_savings/091_savings_estimates_house_tenant_uuid_backfill.sql node:node_projection_savings node:node_projection_savings tenant node:node_projection_savings:091_savings_estimates_house_tenant_uuid_backfill.sql 698715c892be33825d35bf080d00fc1f5a03cb4b741f5fda742f6ab6981e46f6 nodes/node_projection_savings/092_restore_savings_views_security_invoker.sql node:node_projection_savings node:node_projection_savings tenant node:node_projection_savings:092_restore_savings_views_security_invoker.sql 9d4ae97609cfed093355e0f9720c0ec30d367184be32b7cbd29f85769825cbac +nodes/node_projection_savings/093_savings_series_persisted_baseline.sql node:node_projection_savings node:node_projection_savings tenant node:node_projection_savings:093_savings_series_persisted_baseline.sql 3c53f906dbb86254d8983f82b119aabe1020ba9d5a81260873b540fd70bce806 nodes/node_projection_session_content/0001_create_session_content.sql node:node_projection_session_content node:node_projection_session_content omninode_internal node:node_projection_session_content:0001_create_session_content.sql a4246a5acd3c33c50f87abb32ff84e05a6e59b70f12986cfed2b620da27835a1 nodes/node_projection_session_outcome/0021_session_outcomes.sql node:node_projection_session_outcome node:node_projection_session_outcome omninode_internal node:node_projection_session_outcome:0021_session_outcomes.sql de421b3a1ef9bd31f29fe6bce7d1efc469a1ead9a08a1f59e9bd8fb53a1a77f0 nodes/node_projection_session_outcome/0022_grant_omninode_runtime_session_outcomes.sql node:node_projection_session_outcome node:node_projection_session_outcome omninode_internal node:node_projection_session_outcome:0022_grant_omninode_runtime_session_outcomes.sql a6fb760e0669fe0157b12b67ed8083f15a6a61916bae5e188b1d44671a05db82 diff --git a/docker/migrations/forward/nodes/node_projection_savings/093_savings_series_persisted_baseline.sql b/docker/migrations/forward/nodes/node_projection_savings/093_savings_series_persisted_baseline.sql new file mode 100644 index 0000000000..efc3d663e5 --- /dev/null +++ b/docker/migrations/forward/nodes/node_projection_savings/093_savings_series_persisted_baseline.sql @@ -0,0 +1,150 @@ +-- OMN-19969: the series view must not invent a savings baseline for legacy +-- delegation_events. That table does not persist model_cloud_baseline; only +-- savings_estimates does. Keep the recorded value on savings rows and represent +-- the event-only value as NULL until a producer persists its selected baseline. +-- Do not include an event-only row in the savings series without that value; +-- otherwise a numeric cost_savings_usd would appear against an unknown baseline. +-- +-- 090 leaves this series view untouched, so 087's definition remained active. +-- This forward replacement keeps the existing output contract and tier buckets. + +CREATE OR REPLACE VIEW public.projection_delegation_savings_series AS +WITH savings_sessions AS ( + SELECT + session_id, + COALESCE(task_type, '') AS task_type, + model_local AS model_name, + local_cost_usd::float AS local_cost_usd, + cloud_cost_usd::float AS cloud_cost_usd, + savings_usd::float AS savings_usd, + model_cloud_baseline AS baseline_model, + COALESCE(pricing_manifest_version, 'savings-estimated') + AS pricing_manifest_version, + COALESCE(savings_method, 'estimated') AS savings_method, + COALESCE(usage_source, 'unknown') AS usage_source, + prompt_tokens::int AS prompt_tokens, + completion_tokens::int AS completion_tokens, + NULL::int AS tokens_to_compliance, + NULL::int AS latency_ms, + created_at, + NULL::text AS prompt_text, + NULL::text AS response_text, + NULL::text AS cost_tier_name + FROM public.savings_estimates +), +event_sessions AS ( + SELECT + COALESCE(NULLIF(session_id, ''), NULLIF(correlation_id, ''), id::text) + AS session_id, + COALESCE(task_type, '') AS task_type, + COALESCE(NULLIF(model_name, ''), NULLIF(delegated_to, ''), 'local') + AS model_name, + COALESCE(cost_usd, 0)::float AS local_cost_usd, + (COALESCE(cost_usd, 0) + COALESCE(cost_savings_usd, 0))::float + AS cloud_cost_usd, + COALESCE(cost_savings_usd, 0)::float AS savings_usd, + NULL::text AS baseline_model, + pricing_manifest_version::text AS pricing_manifest_version, + CASE WHEN cost_measurement_source IN ( + 'metered', 'free_local', 'budgeted_in_budget', + 'budgeted_overage', 'budgeted_split') + THEN 'measured' ELSE 'estimated' END AS savings_method, + CASE + WHEN cost_measurement_source IN ( + 'metered', 'free_local', 'budgeted_in_budget', + 'budgeted_overage', 'budgeted_split') THEN 'measured' + WHEN cost_measurement_source = 'manifest_compute' THEN 'estimated' + ELSE 'unknown' + END AS usage_source, + COALESCE(tokens_input, 0)::int AS prompt_tokens, + COALESCE(tokens_output, 0)::int AS completion_tokens, + NULLIF(tokens_to_compliance, 0)::int AS tokens_to_compliance, + COALESCE(delegation_latency_ms, latency_ms)::int AS latency_ms, + COALESCE(created_at, timestamp) AS created_at, + prompt_text, + response_text, + NULLIF(cost_tier_name, '') AS cost_tier_name + FROM public.delegation_events +), +event_tiers AS ( + SELECT + session_id, + (array_agg(cost_tier_name ORDER BY created_at DESC) + FILTER (WHERE cost_tier_name IS NOT NULL))[1] AS cost_tier_name + FROM event_sessions + GROUP BY session_id +), +combined_sessions AS ( + SELECT + savings_sessions.session_id, + savings_sessions.task_type, + savings_sessions.model_name, + savings_sessions.local_cost_usd, + savings_sessions.cloud_cost_usd, + savings_sessions.savings_usd, + savings_sessions.baseline_model, + savings_sessions.pricing_manifest_version, + savings_sessions.savings_method, + savings_sessions.usage_source, + savings_sessions.prompt_tokens, + savings_sessions.completion_tokens, + savings_sessions.tokens_to_compliance, + savings_sessions.latency_ms, + savings_sessions.created_at, + savings_sessions.prompt_text, + savings_sessions.response_text, + COALESCE(event_tiers.cost_tier_name, savings_sessions.cost_tier_name) + AS cost_tier_name + FROM savings_sessions + LEFT JOIN event_tiers USING (session_id) + UNION ALL + SELECT event_sessions.* + FROM event_sessions + WHERE NOT EXISTS ( + SELECT 1 + FROM savings_sessions + WHERE savings_sessions.session_id = event_sessions.session_id + ) + AND event_sessions.baseline_model IS NOT NULL +), +classified_sessions AS ( + SELECT + *, + CASE + WHEN cost_tier_name = 'local' THEN 'local' + WHEN cost_tier_name IN ('cheap_cloud', 'cheap_frontier') THEN 'cheap' + WHEN cost_tier_name = 'claude' THEN 'premium' + ELSE NULL + END AS tier_bucket + FROM combined_sessions +) +SELECT + date_trunc('day', created_at) AS bucket, + COALESCE(SUM(local_cost_usd), 0)::float AS actual_cost_usd, + COALESCE(SUM(cloud_cost_usd), 0)::float AS baseline_cost_usd, + COALESCE(SUM(savings_usd), 0)::float AS savings_usd, + COUNT(*)::int AS task_count, + COALESCE( + COUNT(*) FILTER (WHERE tier_bucket = 'local')::float + / NULLIF(COUNT(*) FILTER (WHERE tier_bucket IS NOT NULL), 0), + 0 + )::float AS local_pct, + COALESCE( + COUNT(*) FILTER (WHERE tier_bucket = 'cheap')::float + / NULLIF(COUNT(*) FILTER (WHERE tier_bucket IS NOT NULL), 0), + 0 + )::float AS cheap_pct, + COALESCE( + COUNT(*) FILTER (WHERE tier_bucket = 'premium')::float + / NULLIF(COUNT(*) FILTER (WHERE tier_bucket IS NOT NULL), 0), + 0 + )::float AS prem_pct +FROM classified_sessions +GROUP BY 1 +ORDER BY 1; + +-- CREATE OR REPLACE VIEW resets omitted view options, so the replacement above has +-- dropped security_invoker (OMN-19808: the same reset after 089 and 090). Without it +-- the view reads with its owner's rights and bypasses the tenant row-level security +-- on savings_estimates and delegation_events. Set it back in the same migration. +ALTER VIEW public.projection_delegation_savings_series SET (security_invoker = true); diff --git a/docker/runners/runner-image.lock.json b/docker/runners/runner-image.lock.json index 8d7af9343a..9592da0930 100644 --- a/docker/runners/runner-image.lock.json +++ b/docker/runners/runner-image.lock.json @@ -1,12 +1,12 @@ { "base_image_digest": "sha256:3ba65aa20f86a0fad9df2b2c259c613df006b2e6d0bfcc8a146afb8c525a9751", "gh_version": "2.67.0", - "identity_digest": "1228e0dfd2cf63646edec26514ae8595", + "identity_digest": "1b273fb2312f46d96725971084a7cdcb", "image_version": 11, "kubectl_version": "1.32.1", "python_version": "3.12", "runner_version": "2.336.0", - "shared_env_digest": "675a3fe3e801a6647d3ea9ce", + "shared_env_digest": "17cf9fe53c7b48e2fcf92662", "shared_env_install_args": "--frozen --all-extras --all-groups --no-install-project", "trivy_version": "0.74.0", "uv_version": "0.6.14" diff --git a/pyproject.toml b/pyproject.toml index 07ba8c15ef..f9b2e74d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "omnibase_infra" -version = "0.38.61" +version = "0.38.62" description = "ONEX Infrastructure - Service integration and database infrastructure tools" authors = [{ name = "OmniNode.ai", email = "contact@omninode.ai" }] license = {text = "MIT"} @@ -271,7 +271,7 @@ override-dependencies = [ # OMN-19749: released omnibase-core 0.47.24 (PyPI, OMN-18595) carries # omnibase_core#1786's runtime lane declaration types this draft needs; the # temporary git-rev override below is removed in the same change. - "omnibase-core==0.47.27", + "omnibase-core==0.47.28", "omnibase-spi==0.23.5", "omnibase-compat==0.5.7", ] diff --git a/scripts/apply_governed_compose_config.py b/scripts/apply_governed_compose_config.py new file mode 100644 index 0000000000..ae42084950 --- /dev/null +++ b/scripts/apply_governed_compose_config.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Gated apply of a compose-config change to a governed lane (OMN-20260). + +The ``stability-test`` and ``judge`` compose lanes are governed (CLAUDE.md rule +2a, OMN-15243): a mutation needs a CODEOWNERS-approved grant. Every grant kind +in ``prod_promotion_grants.yaml`` names an image digest or a kustomize overlay, +so a compose-config change (a loopback port bind, for one) had no grant that +could authorize it. The operator approved a new grant type on 2026-10-01 +(RULING 2026-10-01T10:37:55Z lane=compose-grant-kind). Its anchor is +``onex_change_control`` ``grants/governed_lane_grants.yaml``, kind +``compose_config``; this script is the only path that consumes it. + +``apply`` refuses (exit 1) unless ALL of these hold: + +1. The anchor is read from ``onex_change_control@main`` (never a PR branch, and + never a local file when ``--execute`` is given). +2. It holds an entry with ``--grant-id``, ``target_kind: compose_config``, + ``runtime_lane`` equal to ``--lane`` (``stability-test`` or ``judge``), the + lane's ``omnibase-infra-`` project, ``approved_by`` different from + ``requested_by``, not consumed, and ``expires_at`` in the future. +3. This checkout's ``HEAD`` is the grant's ``compose_ref`` and none of the + grant's compose files has an uncommitted change. +4. The digest of ``docker compose config`` rendered HERE, from exactly the + grant's compose files, env files and profiles, equals the grant's + ``rendered_digest``. The digest is recomputed in-process; no caller can + assert it. + +Only then, with ``--execute``, it runs the OMN-15218 lane-deploy attribution +preflight and recreates exactly the grant's services with ``up -d --no-deps +--no-build``. Without ``--execute`` it stops after the checks (exit 0 = would +apply). + +``digest`` prints the canonical digest and ``HEAD`` for a set of inputs, which +is how a grant request is authored: run it on the lane host, at the commit the +grant will name, and copy the two values into the entry. + +Canonical render: ``docker compose -p [--env-file ...] -f ... [--profile +...] config --format json --no-path-resolution``, parsed as JSON and re-encoded +with sorted keys and compact separators; ``sha256:`` + hex of that. Path +resolution is off so the digest does not depend on where the checkout lives. +Interpolation is on, so the digest binds the env values the apply will use; +the rendered text is hashed and never printed. The env inputs are the same ones +``deploy-runtime.sh`` interpolates with: the grant's repo env files (normally +``docker/runtime-policy.env``), then the operator env file +(``$OMNIBASE_OPERATOR_ENV_FILE``, default ``~/.omnibase/.env``), which lives +outside the repo and is never named in a public grant. Run both ``digest`` and +``apply`` from a clean shell on the lane host: an exported variable outranks an +env file in compose interpolation, so a stray one changes the digest and the +apply refuses (fail closed). + +Exit codes: 0 allowed / digest printed, 1 refused, 2 usage or environment error. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shlex +import subprocess +import sys +from collections.abc import Sequence +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from omnibase_core.validators.no_unguarded_git_subprocess import ( + scrub_git_location_env, +) + +GOVERNED_COMPOSE_LANES: frozenset[str] = frozenset({"stability-test", "judge"}) +TARGET_KIND = "compose_config" +ANCHOR_RELPATH = "grants/governed_lane_grants.yaml" +ANCHOR_REF = "main" +GIT_TIMEOUT_SECONDS = 60 +COMPOSE_TIMEOUT_SECONDS = 600 +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class ApplyUsageError(RuntimeError): + """Usage or environment error (exit 2), never a policy refusal.""" + + +def canonical_digest(rendered: bytes) -> str: + """sha256 over the canonical JSON re-encoding of a compose config render.""" + document = json.loads(rendered.decode("utf-8")) + canonical = json.dumps(document, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def compose_argv( + *, + project: str, + compose_files: Sequence[str], + env_files: Sequence[str], + profiles: Sequence[str], + repo_root: Path, + operator_env: Path, +) -> list[str]: + """The ``docker compose`` prefix for exactly these inputs, in grant order.""" + argv = ["docker", "compose", "-p", project] + for env_file in env_files: + argv += ["--env-file", str(repo_root / env_file)] + argv += ["--env-file", str(operator_env)] + for compose_file in compose_files: + argv += ["-f", str(repo_root / compose_file)] + for profile in profiles: + argv += ["--profile", profile] + return argv + + +def render_digest(prefix: Sequence[str]) -> str: + """Render the config with ``prefix`` and return its canonical digest.""" + completed = subprocess.run( + [*prefix, "config", "--format", "json", "--no-path-resolution"], + capture_output=True, + timeout=COMPOSE_TIMEOUT_SECONDS, + check=False, + ) + if completed.returncode != 0: + # stderr can name a missing variable; it never carries the rendered text. + raise ApplyUsageError( + "docker compose config failed: " + + completed.stderr.decode(errors="replace").strip() + ) + return canonical_digest(completed.stdout) + + +def _parse_time(value: Any) -> datetime: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str): + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + else: + raise ValueError(f"not a timestamp: {value!r}") + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def match_grant( + raw_anchor: bytes, *, grant_id: str, lane: str, now: datetime +) -> tuple[dict[str, Any] | None, list[str]]: + """Find and check the grant. Pure: returns (entry, refusals).""" + try: + document = yaml.safe_load(raw_anchor.decode("utf-8")) + except (UnicodeDecodeError, yaml.YAMLError) as exc: + return None, [f"grant anchor does not parse: {exc}"] + if not isinstance(document, dict) or not isinstance(document.get("entries"), list): + return None, ["grant anchor has no 'entries' list"] + matches = [ + e + for e in document["entries"] + if isinstance(e, dict) and e.get("grant_id") == grant_id + ] + if len(matches) != 1: + return None, [ + f"grant {grant_id!r} is not in the anchor at @{ANCHOR_REF} " + f"({len(matches)} matches). No grant, no apply." + ] + entry = matches[0] + refusals: list[str] = [] + if entry.get("target_kind") != TARGET_KIND: + refusals.append( + f"target_kind is {entry.get('target_kind')!r}, not {TARGET_KIND!r}" + ) + if lane not in GOVERNED_COMPOSE_LANES: + refusals.append(f"--lane {lane!r} is not a governed compose lane") + if entry.get("runtime_lane") != lane: + refusals.append( + f"grant is for lane {entry.get('runtime_lane')!r}, not {lane!r}" + ) + if entry.get("compose_project") != f"omnibase-infra-{lane}": + refusals.append(f"grant names project {entry.get('compose_project')!r}") + approved, requested = entry.get("approved_by"), entry.get("requested_by") + if ( + not isinstance(approved, str) + or not isinstance(requested, str) + or not approved.strip() + or approved.casefold() == requested.casefold() + ): + refusals.append("approved_by is missing or equals requested_by (self_granted)") + if entry.get("consumed") is True: + refusals.append("grant is consumed; a spent grant is never replayed") + try: + if _parse_time(entry.get("expires_at")) <= now: + refusals.append(f"grant expired at {entry.get('expires_at')}") + except ValueError as exc: + refusals.append(f"expires_at unreadable: {exc}") + for field in ("compose_files", "services"): + value = entry.get(field) + if not isinstance(value, list) or not value: + refusals.append(f"{field} must be a non-empty list") + for field in ("env_files", "profiles"): + if not isinstance(entry.get(field), list): + refusals.append(f"{field} must be a list") + for field in ("compose_ref", "rendered_digest"): + if not isinstance(entry.get(field), str): + refusals.append(f"{field} missing") + return entry, refusals + + +def _git(repo: Path, *args: str) -> str: + completed = subprocess.run( + ["git", "-c", f"safe.directory={repo}", "-C", str(repo), *args], + env=scrub_git_location_env(os.environ), + capture_output=True, + text=True, + timeout=GIT_TIMEOUT_SECONDS, + check=False, + ) + if completed.returncode != 0: + raise ApplyUsageError( + f"git {' '.join(args)} failed: {completed.stderr.strip()}" + ) + return completed.stdout + + +def fetch_anchor_from_main(occ_repo: Path) -> tuple[bytes, str]: + """Read the anchor at ``onex_change_control@main`` after a fresh fetch.""" + if not (occ_repo / ".git").exists(): + raise ApplyUsageError(f"not a git clone: {occ_repo}") + _git(occ_repo, "fetch", "origin", ANCHOR_REF, "--quiet") + commit = _git(occ_repo, "rev-parse", f"origin/{ANCHOR_REF}").strip() + raw = _git(occ_repo, "show", f"origin/{ANCHOR_REF}:{ANCHOR_RELPATH}") + return raw.encode("utf-8"), commit + + +def checkout_refusals(repo_root: Path, entry: dict[str, Any]) -> list[str]: + """The checkout must be the grant's commit, with its inputs unmodified.""" + refusals: list[str] = [] + head = _git(repo_root, "rev-parse", "HEAD").strip() + if head != entry["compose_ref"]: + refusals.append( + f"checkout HEAD {head} is not the grant's compose_ref {entry['compose_ref']}" + ) + dirty = _git( + repo_root, "status", "--porcelain", "--", *entry["compose_files"] + ).strip() + if dirty: + refusals.append(f"grant compose files are modified in this checkout: {dirty}") + return refusals + + +def operator_env_file() -> Path: + """The operator env deploy-runtime.sh sources; required, never defaulted away.""" + raw = os.environ.get("OMNIBASE_OPERATOR_ENV_FILE", "").strip() + path = Path(raw) if raw else Path.home() / ".omnibase" / ".env" + if not path.is_file(): + raise ApplyUsageError(f"operator env file not found: {path}") + return path + + +def _occ_repo(arg: str) -> Path: + if arg: + return Path(arg) + omni_home = os.environ.get("OMNI_HOME", "").strip() + if not omni_home: + raise ApplyUsageError("set --occ-repo or OMNI_HOME (no default path)") + return Path(omni_home) / "onex_change_control" + + +def cmd_digest(args: argparse.Namespace) -> int: + repo_root = Path(args.repo_root) + prefix = compose_argv( + project=args.project, + compose_files=args.file, + env_files=args.env_file, + profiles=args.profile, + repo_root=repo_root, + operator_env=operator_env_file(), + ) + print( + json.dumps( + { + "compose_project": args.project, + "compose_files": args.file, + "env_files": args.env_file, + "profiles": args.profile, + "compose_ref": _git(repo_root, "rev-parse", "HEAD").strip(), + "rendered_digest": render_digest(prefix), + }, + indent=2, + ) + ) + return 0 + + +def cmd_apply(args: argparse.Namespace) -> int: + repo_root = Path(args.repo_root) + now = _parse_time(args.now) if args.now else datetime.now(UTC) + if args.grants_file: + if args.execute: + raise ApplyUsageError( + "--grants-file is a test seam; --execute reads the anchor at " + f"onex_change_control@{ANCHOR_REF} only" + ) + raw, source = Path(args.grants_file).read_bytes(), f"file:{args.grants_file}" + else: + raw, commit = fetch_anchor_from_main(_occ_repo(args.occ_repo)) + source = f"onex_change_control@{ANCHOR_REF}={commit}" + + entry, refusals = match_grant(raw, grant_id=args.grant_id, lane=args.lane, now=now) + if entry is not None and not refusals: + refusals = checkout_refusals(repo_root, entry) + prefix: list[str] = [] + if entry is not None and not refusals: + prefix = compose_argv( + project=entry["compose_project"], + compose_files=entry["compose_files"], + env_files=entry["env_files"], + profiles=entry["profiles"], + repo_root=repo_root, + operator_env=operator_env_file(), + ) + local = render_digest(prefix) + if local != entry["rendered_digest"]: + refusals.append( + f"rendered digest {local} does not match the grant's " + f"{entry['rendered_digest']}: this is not the config the approver read" + ) + + if refusals or entry is None: + print(f"REFUSED grant={args.grant_id} lane={args.lane} anchor={source}") + for refusal in refusals: + print(f" - {refusal}") + return 1 + + services = [str(s) for s in entry["services"]] + up = [*prefix, "up", "-d", "--no-deps", "--no-build", *services] + print(f"ALLOWED grant={args.grant_id} lane={args.lane} anchor={source}") + print(f" services: {' '.join(services)}") + if not args.execute: + print(" check only; pass --execute to apply") + return 0 + + preflight = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "preflight_lane_deploy_attribution.py"), + "--compose-project", + entry["compose_project"], + "--source", + "apply_governed_compose_config.py", + "--invoking-command", + shlex.join(up), + ], + check=False, + ) + if preflight.returncode != 0: + print("REFUSED by the lane-deploy attribution preflight (OMN-15218)") + return 1 + return subprocess.run(up, timeout=COMPOSE_TIMEOUT_SECONDS, check=False).returncode + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo-root", default=str(REPO_ROOT)) + sub = parser.add_subparsers(dest="command", required=True) + + digest = sub.add_parser("digest", help="print the canonical digest and HEAD") + digest.add_argument("--project", required=True) + digest.add_argument("--file", action="append", required=True) + digest.add_argument("--env-file", action="append", default=[]) + digest.add_argument("--profile", action="append", default=[]) + digest.set_defaults(func=cmd_digest) + + apply = sub.add_parser("apply", help="check a grant and, with --execute, apply") + apply.add_argument("--lane", required=True, choices=sorted(GOVERNED_COMPOSE_LANES)) + apply.add_argument("--grant-id", required=True) + apply.add_argument("--occ-repo", default="") + apply.add_argument("--grants-file", default="") + apply.add_argument("--now", default="") + apply.add_argument("--execute", action="store_true") + apply.set_defaults(func=cmd_apply) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + result: int = args.func(args) + except ApplyUsageError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + return result + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/application_database_sql_baseline.yaml b/scripts/ci/application_database_sql_baseline.yaml index e4bb0bd645..e88832a07b 100644 --- a/scripts/ci/application_database_sql_baseline.yaml +++ b/scripts/ci/application_database_sql_baseline.yaml @@ -24,14 +24,8 @@ # Entries are keyed by sha256 of the ": " violation line -- content, # never line numbers, so unrelated SQL edits cannot silently re-key an entry. generated_at: "2026-08-15" -count: 200 +count: 194 violations: - - key: "d253a5eec3857233d66f04c8ff8b30e9b7807ca0e7fad2df3095640e1e017faf" - path: "docker/application-acl-proof/seed.sql" - violation: "application relation target 'all' must be schema-qualified" - - key: "87513822c4c19c1c97e72a782ddd20ba1fa8c33a5808a7bf7bc4aaa70ab3b842" - path: "docker/application-acl-proof/seed.sql" - violation: "application relation target 'false' must be schema-qualified" - key: "af6044a4b757ce804d0612ed0332c04f45167f99a6a628cf8537caef1fda84ed" path: "docker/application-acl-proof/seed.sql" violation: "application relation target 'omninode_internal' must be schema-qualified" @@ -41,9 +35,6 @@ violations: - key: "bdde09dc72c65af5ed96c1c9dd893b2669700265ae92202e9168d820cca6a344" path: "docker/application-acl-proof/seed.sql" violation: "application relation target 'public.legacy_scaffold_data' is prohibited in public" - - key: "504420a2df287548117ddee80d176cf4d5a2fbd841073ff0d205721219612bc0" - path: "docker/application-acl-proof/seed.sql" - violation: "application relation target 'true' must be schema-qualified" - key: "d3d3a4ec16674640bcf885cd5f53b59677fa6e03e21ba457679fdda49ba77378" path: "docker/application-acl-proof/seed.sql" violation: "application target omninode_internal.runtime_code requires exactly one ownership declaration" @@ -470,12 +461,6 @@ violations: - key: "5a43ba1ba5cd6e6723e34355ea4aa2af9af73411cfa15076ac5eb1d2a14f190f" path: "docker/migrations/forward/nodes/node_projection_live_events/0001_reclassify_event_lifecycle_types.sql" violation: "application relation target 'live_events' must be schema-qualified" - - key: "11e0276b3eeb323ca9c22731def2b6633a40fb682344cdd502c5ca84f095e6eb" - path: "docker/migrations/forward/nodes/node_projection_live_events/0001_reclassify_event_lifecycle_types.sql" - violation: "application relation target 'or' must be schema-qualified" - - key: "22b572fd4bf78d656909e5c045aa38769ebadb55d85d884e747e84dbb721027f" - path: "docker/migrations/forward/nodes/node_projection_live_events/0001_reclassify_event_lifecycle_types.sql" - violation: "application relation target 'then' must be schema-qualified" - key: "6871b504bb84f4f11518926de889321064820bfa37d5282bea6a8b5d47ea2492" path: "docker/migrations/forward/nodes/node_projection_llm_cost/0001_create_llm_call_metrics.sql" violation: "application relation target 'llm_call_metrics' must be schema-qualified" @@ -620,9 +605,6 @@ violations: - key: "a5b1545729f4a20557951b252f91346b56366d3d887253054238f044e97f1902" path: "docker/migrations/intelligence/026_create_code_entities.sql" violation: "application relation target 'code_relationships' must be schema-qualified" - - key: "f6c6e998dd8567ac04ce66d9de4599726fee18e896a180c0627319e56b38f87d" - path: "docker/migrations/rollback/rollback_096_grant_role_omnidash_omnidash_analytics.sql" - violation: "application relation target 'all' must be schema-qualified" - key: "9d54fdd9dd6f098f4e51562cceee3e4c38f25b2b48124c001fff560e4e243548" path: "docker/migrations/rollback/rollback_096_grant_role_omnidash_omnidash_analytics.sql" violation: "application relation target 'role_omnidash' must be schema-qualified" diff --git a/scripts/ci/ci_summary_gate.py b/scripts/ci/ci_summary_gate.py index a2f92a9c36..7e801cf990 100644 --- a/scripts/ci/ci_summary_gate.py +++ b/scripts/ci/ci_summary_gate.py @@ -139,6 +139,9 @@ "Writer-Migration Coupling Check", # migration-required-check "Node Migration Declaration Check", # node-migration-declaration-check (OMN-15717) "no-noncanonical-lifecycle-classes", # OMN-14350 non-canonical lifecycle-class ratchet + "Canonical Handler-Shape Gate (OMN-20298)", # canonical-handler-shape-gate — detector with no preflight dependency + "No Plugin Daemon Classes Gate (OMN-20298)", # no-plugin-daemon-classes-gate — detector with no preflight dependency + "Shape-Gate Independence (OMN-20298)", # shape-gate-independence — refuses a detector behind the preflight "Effect-Assertion Gate (RT-5)", # OMN-14467 deploy-trigger fails closed on zero output "OCC Companion Merged Gate (OMN-15214)", # occ-companion-merged — cited OCC evidence must be MERGED before product merge # OMN-16774: whole event chains driven through the REAL dispatch seam on the diff --git a/scripts/ci/lab_pass_receipt.py b/scripts/ci/lab_pass_receipt.py index b0c62a4b35..2c16d11b55 100644 --- a/scripts/ci/lab_pass_receipt.py +++ b/scripts/ci/lab_pass_receipt.py @@ -4948,6 +4948,11 @@ def rerun_refused_deliveries( #: of an older run supersede a newer-created one, in both directions. WORKFLOW_VERDICT_PAGE: Final[int] = 50 +#: Orders a run with no parseable start before every real one when two reads +#: return different attempts of the same run id (the later evaluation still +#: refuses a run with no parseable start). +_EPOCH: Final[datetime] = datetime.fromtimestamp(0, tz=UTC) + #: OMN-19311 AC5 -- the planted-red drill's blast-radius ceiling. `until` may #: not be set more than this many hours in the future, so a mistyped date #: cannot leave staging delivery closed for days with nobody watching. The @@ -5114,17 +5119,44 @@ def refuse(reason: str) -> int: if not admitted: return refuse("no event is admitted, so no run can decide the verdict.") - path = ( - f"repos/{repo}/actions/workflows/{workflow}/runs" - f"?branch={branch}&status=completed&per_page={WORKFLOW_VERDICT_PAGE}" + # OMN-20277: one read is not enough. The filtered listing intermittently + # returns a page that lacks the newest runs (on 14 of 18 Delegation Health + # Check reds, 2026-09-30/10-01, it named a 4-13 day old run as newest while + # the real newest was a fresh green). Every read below must succeed, and the + # runs they return are unioned by id, keeping each run's latest attempt. + # A union only ADDS real completed runs, so it can surface a newer run a + # bad page hid, and it can never hide a newer red that any read returned. + base = f"repos/{repo}/actions/workflows/{workflow}/runs" + filtered = ( + f"{base}?branch={branch}&status=completed&per_page={WORKFLOW_VERDICT_PAGE}" + ) + paths = (filtered, f"{base}?per_page={WORKFLOW_VERDICT_PAGE}", filtered) + by_id: dict[object, Mapping[str, Any]] = {} + unkeyed: list[object] = [] + for path in paths: + try: + payload = json.loads(_gh_api(path).decode("utf-8")) + except Exception as exc: # noqa: BLE001 - rule 16: an unread surface is a refusal + return refuse(f"the run surface is unreadable: {exc}.") + listed = payload.get("workflow_runs") if isinstance(payload, dict) else None + if not isinstance(listed, list): + return refuse("the run listing carries no 'workflow_runs' list.") + for r in listed: + key = r.get("id") if isinstance(r, dict) else None + if key is None: + unkeyed.append(r) + continue + held = by_id.get(key) + if held is None or (_run_started(r) or _EPOCH) > ( + _run_started(held) or _EPOCH + ): + by_id[key] = r + runs: list[object] = [*by_id.values(), *unkeyed] + print( + f" reads : {len(paths)} listing read(s) unioned by run id " + f"({len(by_id)} distinct run(s))", + file=out, ) - try: - payload = json.loads(_gh_api(path).decode("utf-8")) - except Exception as exc: # noqa: BLE001 - rule 16: an unread surface is a refusal - return refuse(f"the run surface is unreadable: {exc}.") - runs = payload.get("workflow_runs") if isinstance(payload, dict) else None - if not isinstance(runs, list): - return refuse("the run listing carries no 'workflow_runs' list.") candidates = [ r diff --git a/scripts/deploy-agent/deploy/deploy-agent-dev.service b/scripts/deploy-agent/deploy/deploy-agent-dev.service index bd1f18c8b4..3095a5e5d0 100644 --- a/scripts/deploy-agent/deploy/deploy-agent-dev.service +++ b/scripts/deploy-agent/deploy/deploy-agent-dev.service @@ -166,7 +166,17 @@ WorkingDirectory=/data/omninode/omnibase_infra/scripts/deploy-agent Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Environment=DEPLOY_AGENT_ENV_FILE=/home/jonah/.omnibase/.env Environment=DEPLOY_AGENT_PYTHON=/data/omninode/omnibase_infra/scripts/deploy-agent/.venv/bin/python -Environment="DEPLOY_AGENT_ENV_PROTECTED=PATH DEPLOY_AGENT_ENV_FILE DEPLOY_AGENT_PYTHON DEPLOY_AGENT_ENV_PROTECTED DEPLOY_AGENT_DIR DEPLOY_AGENT_STATE_DIR DEPLOY_AGENT_PORT DEPLOY_AGENT_TRACKING_REF DEPLOY_AGENT_ALLOWED_LANES KAFKA_BOOTSTRAP_SERVERS KAFKA_ENVIRONMENT KAFKA_SECURITY_PROTOCOL KAFKA_SASL_MECHANISM KAFKA_SASL_ENV_PREFIX" +# OMN-20255: OMNI_HOME is the DEPLOY-SOURCE root, the tree reconcile-host converges +# (it resolves OMNI_HOME=/data/omninode from /data/omninode/omnibase_infra/.env) +# and the root its OMN-20154 lock lives under. A workspace build's RT-1 step +# (stage_workspace.sh -> deploy_source_ref.py) runs checkout --force, reset --hard +# and clean -ffdx on every vendored sibling under OMNI_HOME. The env store above +# sets OMNI_HOME=/data/omninode/omni_home, the lanes' canonical tree, so before +# this line every dev job detached omnimarket, omnibase_core and omnibase_compat +# there, and the clone guard refused every in-process delegate while they stayed +# detached. Declared and protected here so the env store cannot move it back. +Environment=OMNI_HOME=/data/omninode +Environment="DEPLOY_AGENT_ENV_PROTECTED=PATH OMNI_HOME DEPLOY_AGENT_ENV_FILE DEPLOY_AGENT_PYTHON DEPLOY_AGENT_ENV_PROTECTED DEPLOY_AGENT_DIR DEPLOY_AGENT_STATE_DIR DEPLOY_AGENT_PORT DEPLOY_AGENT_TRACKING_REF DEPLOY_AGENT_ALLOWED_LANES KAFKA_BOOTSTRAP_SERVERS KAFKA_ENVIRONMENT KAFKA_SECURITY_PROTOCOL KAFKA_SASL_MECHANISM KAFKA_SASL_ENV_PREFIX" Environment=DEPLOY_AGENT_DIR=/data/omninode/omnibase_infra/scripts/deploy-agent Environment=DEPLOY_AGENT_STATE_DIR=/data/omninode/deploy-agent/state/jobs-dev Environment=DEPLOY_AGENT_PORT=8098 diff --git a/scripts/deploy-agent/tests/unit/test_dev_unit_omni_home_deploy_source_omn20255.py b/scripts/deploy-agent/tests/unit/test_dev_unit_omni_home_deploy_source_omn20255.py new file mode 100644 index 0000000000..d0d793f80b --- /dev/null +++ b/scripts/deploy-agent/tests/unit/test_dev_unit_omni_home_deploy_source_omn20255.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""The .201 dev unit stages workspace builds from the deploy-source tree (OMN-20255). + +A workspace build's RT-1 step (``stage_workspace.sh`` -> ``deploy_source_ref.py``) +runs ``checkout --force``, ``reset --hard`` and ``clean -ffdx`` on every vendored +sibling under ``OMNI_HOME``. The dev unit took ``OMNI_HOME`` from the operator env +store, which names ``/data/omninode/omni_home``: the lanes' canonical tree. Every +dev-lane job then detached omnimarket, omnibase_core and omnibase_compat there +(2026-10-01, jobs 3ef8ac17, 7120389d and cc5545bb, each followed about 13 seconds +later by a detach in that clone's reflog), and the clone guard refused every +in-process delegate on the host while they stayed detached. + +The deploy-source root is the tree reconcile-host converges, and the OMN-20154 +build/reconcile lock is ``/.onex-reconcile-host.lock`` on both sides, +so the two must name the same root or the interlock is inert. The unit declares +that root itself and protects the name from the env store. +""" + +import re +from pathlib import Path + +import pytest + +_DEPLOY_DIR = Path(__file__).resolve().parents[2] / "deploy" +_DEV_UNIT = _DEPLOY_DIR / "deploy-agent-dev.service" +_LAUNCHER = _DEPLOY_DIR / "deploy-agent-launch.sh" +_LOCK = Path(__file__).resolve().parents[2] / "deploy_agent" / "reconcile_host_lock.py" +_RECONCILE_HOST = Path(__file__).resolve().parents[3] / "reconcile-host.sh" + +_DEPLOY_SOURCE_ROOT = "/data/omninode" +_LANES_TREE = "/data/omninode/omni_home" + +_ENVIRONMENT_RE = re.compile(r'^Environment="?([A-Za-z_][A-Za-z0-9_]*)=(.*?)"?$') + + +def _env(unit: Path) -> dict[str, str]: + values: dict[str, str] = {} + for line in unit.read_text().splitlines(): + if not line or line.lstrip().startswith("#"): + continue + match = _ENVIRONMENT_RE.match(line) + if match: + values[match.group(1)] = match.group(2) + return values + + +@pytest.mark.unit +def test_the_launcher_sources_an_env_store_that_could_override_omni_home() -> None: + """Positive control: protection is needed only because the store is sourced.""" + launcher = _LAUNCHER.read_text() + assert '. "$DEPLOY_AGENT_ENV_FILE"' in launcher + assert "DEPLOY_AGENT_ENV_PROTECTED" in launcher + + +@pytest.mark.unit +def test_the_build_lock_and_reconcile_host_lock_share_one_root_name() -> None: + """Both sides derive the lock from OMNI_HOME, so the roots must agree.""" + assert '".onex-reconcile-host.lock"' in _LOCK.read_text() + assert '"$OMNI_HOME/.onex-reconcile-host.lock"' in _RECONCILE_HOST.read_text() + + +@pytest.mark.unit +def test_omni_home_deploy_source_is_declared_by_the_dev_unit() -> None: + env = _env(_DEV_UNIT) + assert env.get("OMNI_HOME") == _DEPLOY_SOURCE_ROOT, env.get("OMNI_HOME") + assert env["OMNI_HOME"] != _LANES_TREE + + +@pytest.mark.unit +def test_omni_home_deploy_source_is_protected_from_the_env_store() -> None: + protected = set(_env(_DEV_UNIT)["DEPLOY_AGENT_ENV_PROTECTED"].split()) + assert "OMNI_HOME" in protected diff --git a/scripts/runtime_build/prepr_runtime_pool.py b/scripts/runtime_build/prepr_runtime_pool.py index 2f4cbd96cc..4a3d2f7804 100644 --- a/scripts/runtime_build/prepr_runtime_pool.py +++ b/scripts/runtime_build/prepr_runtime_pool.py @@ -55,6 +55,7 @@ import argparse import base64 +import concurrent.futures import datetime as dt import gzip import hashlib @@ -471,13 +472,22 @@ def survey( now: dt.datetime, me: str | None = None, ) -> list[HostState]: - states = [] - for host in cfg.hosts: - if host.status == "excluded": - states.append(HostState(host, "EXCLUDED")) - continue - rc, out = transport.run(host, _probe_command(cfg, host), timeout=30) - states.append(parse_probe(host, rc, out)) + states: list[HostState] = [] + max_workers = max(1, sum(host.status != "excluded" for host in cfg.hosts)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + i: executor.submit( + transport.run, host, _probe_command(cfg, host), timeout=30 + ) + for i, host in enumerate(cfg.hosts) + if host.status != "excluded" + } + for i, host in enumerate(cfg.hosts): + if host.status == "excluded": + states.append(HostState(host, "EXCLUDED")) + continue + rc, out = futures[i].result() + states.append(parse_probe(host, rc, out)) return assess(states, cfg, holds, now, me) diff --git a/src/omnibase_infra/topology/table_grant_derivation.py b/src/omnibase_infra/topology/table_grant_derivation.py index 6ec8c06ebb..723775fc7c 100644 --- a/src/omnibase_infra/topology/table_grant_derivation.py +++ b/src/omnibase_infra/topology/table_grant_derivation.py @@ -370,27 +370,6 @@ class ContractTableDeclaration: role="demo_readiness", ), ), - # OMN-20154: infra vendors provider_quota_state before omnimarket#3134 - # lands its node contract. This bridge expires once the pin includes it. - # Retired by: omnimarket#3134 merging and the pin advancing past it. - ContractTableDeclaration( - node="legacy_migration:provider_quota_state", - contract_path=Path( - "docker/migrations/forward/nodes/node_projection_provider_quota/" - "0000_create_provider_quota_state.sql" - ), - table=ModelDbTableDeclaration( - name="provider_quota_state", - database_ref="application", - schema="public", - migration=( - "docker/migrations/forward/nodes/node_projection_provider_quota/" - "0000_create_provider_quota_state.sql" - ), - access="read_write", - role="provider_quota_state", - ), - ), # OMN-20242: infra vendors the disposition table before the omnimarket # node_projection_delegation_disposition contract lands. This bridge expires # when that source change merges and the pin advances to declare the table. diff --git a/src/omnibase_infra/validation/application_database_domain_enforcement.py b/src/omnibase_infra/validation/application_database_domain_enforcement.py index 49c22e742a..13b5639621 100644 --- a/src/omnibase_infra/validation/application_database_domain_enforcement.py +++ b/src/omnibase_infra/validation/application_database_domain_enforcement.py @@ -71,6 +71,94 @@ ) _OPTIONAL_ONLY_TARGET = r"(?:only\s+(?:\(\s*)?)?" _SYSTEM_READ_SCHEMAS = frozenset({"information_schema", "pg_catalog"}) +# OMN-20201 (I21): PostgreSQL's fully reserved key words. Unquoted, none of them +# can name a table, view, sequence or function, so a relation-target pattern that +# lands on one has matched SQL grammar, not a relation. Observed captures: ALL in +# `GRANT ... ON ALL TABLES IN SCHEMA`, OR and THEN after the LIKE operator once its +# string literal is blanked, TRUE and FALSE in `CHECK (x IS TRUE)`. A quoted +# identifier ("all") is a real relation name and is still held to the lint. +_POSTGRES_RESERVED_KEYWORDS: frozenset[str] = frozenset( + { + "all", + "analyse", + "analyze", + "and", + "any", + "array", + "as", + "asc", + "asymmetric", + "both", + "case", + "cast", + "check", + "collate", + "column", + "constraint", + "create", + "current_catalog", + "current_date", + "current_role", + "current_time", + "current_timestamp", + "current_user", + "default", + "deferrable", + "desc", + "distinct", + "do", + "else", + "end", + "except", + "false", + "fetch", + "for", + "foreign", + "from", + "grant", + "group", + "having", + "in", + "initially", + "intersect", + "into", + "lateral", + "leading", + "limit", + "localtime", + "localtimestamp", + "not", + "null", + "offset", + "on", + "only", + "or", + "order", + "placing", + "primary", + "references", + "returning", + "select", + "session_user", + "some", + "symmetric", + "system_user", + "table", + "then", + "to", + "trailing", + "true", + "union", + "unique", + "user", + "using", + "variadic", + "when", + "where", + "window", + "with", + } +) # OMN-16237: the static schema-qualification lint below must agree with the # runtime grants system's physical_grant_schema_for_table() on which tables # are logically tenant/omninode_internal domain but still physically created @@ -1240,7 +1328,10 @@ def _record_sql_target( """Apply one topology-derived qualification verdict to a parsed target.""" name = _unquote_identifier(name_token) if schema_token is None: - if name.lower() in {"false", "true"}: + if ( + not name_token.strip().startswith('"') + and name in _POSTGRES_RESERVED_KEYWORDS + ): return if permits_ephemeral and ( name in cte_names or remaining.lstrip().startswith("(") diff --git a/tests/ci/test_application_database_acl_connect_inputs.py b/tests/ci/test_application_database_acl_connect_inputs.py index 340516c623..1af6c734fd 100644 --- a/tests/ci/test_application_database_acl_connect_inputs.py +++ b/tests/ci/test_application_database_acl_connect_inputs.py @@ -156,8 +156,9 @@ def principal_names(self) -> frozenset[str]: # quoted text instead -- stated plainly rather than dressed up as an assertion. _IN_REPO_PROVENANCE: dict[str, str] = { # OMN-14375 shifted this six lines down by adding the GITHUB_WEBHOOK_SECRET - # runtime-env entry above it (885 -> 891). - "omnibase_infra:docker/docker-compose.infra.yml:891": "KC_DB_USERNAME: postgres", + # runtime-env entry above it (885 -> 891). OMN-20260 shifted it eight more + # by documenting the loopback-only Redpanda admin publish (891 -> 899). + "omnibase_infra:docker/docker-compose.infra.yml:899": "KC_DB_USERNAME: postgres", "omnibase_infra:docker/postgres/init/02-keycloak-db.sql:4": ( "CREATE DATABASE keycloak" ), @@ -189,7 +190,7 @@ def principal_names(self) -> frozenset[str]: "four-column checksum ledger" ), provenance=( - "omnibase_infra:docker/docker-compose.infra.yml:891", + "omnibase_infra:docker/docker-compose.infra.yml:899", "omnibase_infra:docker/postgres/init/02-keycloak-db.sql:4", "omninode_infra:k8s/auth/keycloak-values.yaml:32", "omninode_infra:scripts/init-databases.sh:603", diff --git a/tests/ci/test_lab_pass_workflow_verdict_omn18866.py b/tests/ci/test_lab_pass_workflow_verdict_omn18866.py index aa831a2d78..06307ed2d3 100644 --- a/tests/ci/test_lab_pass_workflow_verdict_omn18866.py +++ b/tests/ci/test_lab_pass_workflow_verdict_omn18866.py @@ -254,9 +254,14 @@ def test_the_query_reads_completed_runs_on_the_branch( ) -> None: runs = _Runs([GREEN]) _verdict(runs, monkeypatch, now="2026-09-23T17:30:00Z") - assert runs.paths == [ + filtered = ( f"repos/{REPO}/actions/workflows/{WORKFLOW}/runs" "?branch=dev&status=completed&per_page=50" + ) + assert runs.paths == [ + filtered, + f"repos/{REPO}/actions/workflows/{WORKFLOW}/runs?per_page=50", + filtered, ] diff --git a/tests/ci/test_lakshman_lane_governance_boundary.py b/tests/ci/test_lakshman_lane_governance_boundary.py index 5f919dd495..67d12b6dfe 100644 --- a/tests/ci/test_lakshman_lane_governance_boundary.py +++ b/tests/ci/test_lakshman_lane_governance_boundary.py @@ -172,14 +172,21 @@ def test_compose_file_publishes_only_the_reserved_port_block() -> None: """ reserved = {"58085", "58086", "45436", "46379", "55092", "55644", "53002"} raw = (ROOT / "docker" / "docker-compose.lakshman.yml").read_text(encoding="utf-8") - # Host-side of every published mapping: literal "HOST:CONTAINER" entries and - # `${VAR:-HOST}:CONTAINER` defaults alike. - published = set(re.findall(r'"(?:\$\{[A-Z_]+:[-?][^}]*\}|\d+):\d+"', raw)) + # Host-side of every published mapping: literal "HOST:CONTAINER" entries, + # `${VAR:-HOST}:CONTAINER` defaults, and either shape behind a loopback bind + # ("127.0.0.1:HOST:CONTAINER", the redpanda admin publish since OMN-20260). + published = set( + re.findall(r'"(?:127\.0\.0\.1:)?(?:\$\{[A-Z_]+:[-?][^}]*\}|\d+):\d+"', raw) + ) host_ports = { m.group(1) for entry in published - if (m := re.match(r'"?(?:\$\{[A-Z_]+:-)?(\d+)', entry)) + if (m := re.match(r'"?(?:127\.0\.0\.1:)?(?:\$\{[A-Z_]+:-)?(\d+)', entry)) } + assert "55644" in host_ports, ( + "the redpanda admin publish fell out of this scrape; the reservation " + "check would pass without seeing it" + ) # The two runtime ports come from the policy env, not a literal, so add the # contract's own values rather than pretending the scrape found them. profile = _contract().profiles[LANE] diff --git a/tests/ci/test_lint_validator_runner.py b/tests/ci/test_lint_validator_runner.py index de7d948173..2a57b9af0b 100644 --- a/tests/ci/test_lint_validator_runner.py +++ b/tests/ci/test_lint_validator_runner.py @@ -508,7 +508,12 @@ def test_plugin_daemon_scope_fails_closed_on_an_unreadable_lockfile( def test_plugin_daemon_ci_yml_step_uses_the_scope_and_a_depth_two_checkout() -> None: - steps = _lint_steps() + workflow = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8")) + job = workflow["jobs"]["no-plugin-daemon-classes-gate"] + assert "needs" not in job and "if" not in job, ( + "a shape-gate detector never waits on the preflight (OMN-20298)" + ) + steps = job["steps"] checkout = steps[0] assert str(checkout.get("uses", "")).startswith("actions/checkout@") assert checkout.get("with", {}).get("fetch-depth") == 2 diff --git a/tests/ci/test_shape_gate_jobs_independent_omn20298.py b/tests/ci/test_shape_gate_jobs_independent_omn20298.py new file mode 100644 index 0000000000..c4bc8b643c --- /dev/null +++ b/tests/ci/test_shape_gate_jobs_independent_omn20298.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""Shape-gate detector jobs run on every PR, whatever the preflight says (OMN-20298).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +SUMMARY_GATE = ROOT / "scripts" / "ci" / "ci_summary_gate.py" + +DETECTOR_JOBS = ( + "canonical-handler-shape-gate", + "no-plugin-daemon-classes-gate", + "shape-gate-independence", +) + + +def _jobs() -> dict[str, Any]: + workflow = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8")) + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + return jobs + + +@pytest.mark.unit +@pytest.mark.parametrize("job_id", DETECTOR_JOBS) +def test_detector_job_has_no_dependency_and_no_condition(job_id: str) -> None: + job = _jobs()[job_id] + assert "needs" not in job, f"{job_id} must not wait on any job, preflight included" + assert "if" not in job, f"{job_id} must run on every event" + + +@pytest.mark.unit +@pytest.mark.parametrize("job_id", DETECTOR_JOBS) +def test_detector_job_is_a_strict_ci_summary_gate(job_id: str) -> None: + display_name = _jobs()[job_id]["name"] + assert f'"{display_name}"' in SUMMARY_GATE.read_text(encoding="utf-8") + + +@pytest.mark.unit +def test_lint_job_carries_no_shape_detector_step() -> None: + steps = _jobs()["lint"]["steps"] + text = " ".join(str(step) for step in steps) + assert "canonical_handler_shape" not in text + assert "no-plugin-daemon-classes" not in text diff --git a/tests/ci/test_supplemental_declaration_expiry_omn18863.py b/tests/ci/test_supplemental_declaration_expiry_omn18863.py index 8820a32e3a..f46e464523 100644 --- a/tests/ci/test_supplemental_declaration_expiry_omn18863.py +++ b/tests/ci/test_supplemental_declaration_expiry_omn18863.py @@ -107,7 +107,6 @@ def _pinned_contracts_root(proof_dependencies: Path) -> Path: # will tell you when to take it out. _INTERIM_ENTRIES: dict[str, str] = { "delegation_dispositions": "omnimarket OMN-20242 (node_projection_delegation_disposition)", - "provider_quota_state": "omnimarket#3134", "demo_readiness_latest": "omnimarket OMN-19861 (node_projection_demo_readiness)", } diff --git a/tests/ci/test_workflow_verdict_union_reads.py b/tests/ci/test_workflow_verdict_union_reads.py new file mode 100644 index 0000000000..05fe337c5d --- /dev/null +++ b/tests/ci/test_workflow_verdict_union_reads.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""OMN-20277 -- the workflow-verdict reader unions several listing reads. + +On 2026-09-30/10-01 the Delegation Health Check went red 14 times because one +read of the omnimarket nightly's run listing returned a page without the newest +runs: it named run 35320416369 (2026-09-18, failure) as newest while run +36832264088 (2026-10-01T07:46:16Z, success) existed. The reader now unions the +runs of every read by id, so a page that hides the newest run no longer decides +the verdict, and a newer red that any read returns still refuses. +""" + +from __future__ import annotations + +import io +import json +from datetime import UTC, datetime +from typing import Any + +import pytest + +from scripts.ci.lab_pass_receipt import evaluate_workflow_verdict + +pytestmark = pytest.mark.unit + +REPO = "OmniNode-ai/omnimarket" +WORKFLOW = "delegation-regression-nightly.yml" +NOW = datetime(2026, 10, 1, 9, 56, 56, tzinfo=UTC) + + +def _run( + run_id: int, *, conclusion: str, started: str, attempt: int = 1 +) -> dict[str, Any]: + return { + "id": run_id, + "event": "schedule", + "status": "completed", + "conclusion": conclusion, + "head_branch": "dev", + "head_sha": "2b591abfd3aae2485777111905e249112126b06c", + "run_attempt": attempt, + "created_at": started, + "run_started_at": started, + "display_title": "Delegation Regression (nightly) lane=stability-test", + "html_url": f"https://github.com/{REPO}/actions/runs/{run_id}", + } + + +OLD_RED = _run(35320416369, conclusion="failure", started="2026-09-18T07:38:43Z") +NEWEST_GREEN = _run(36832264088, conclusion="success", started="2026-10-01T07:46:16Z") + + +class _Pages: + """Answers each read with the next page in order (the last repeats).""" + + def __init__(self, *pages: list[dict[str, Any]]) -> None: + self.pages = pages + self.calls = 0 + + def __call__(self, _path: str) -> bytes: + page = self.pages[min(self.calls, len(self.pages) - 1)] + self.calls += 1 + return json.dumps({"workflow_runs": page}).encode() + + +def _verdict(pages: _Pages, monkeypatch: Any) -> tuple[int, str]: + monkeypatch.setattr("scripts.ci.lab_pass_receipt._gh_api", pages) + out = io.StringIO() + code = evaluate_workflow_verdict( + REPO, + WORKFLOW, + "dev", + 26.0, + ("schedule", "workflow_dispatch"), + out, + now=NOW, + dispatch_title_contains="lane=stability-test", + ) + return code, out.getvalue() + + +def test_a_page_that_hides_the_newest_green_does_not_decide(monkeypatch: Any) -> None: + code, output = _verdict( + _Pages([OLD_RED], [NEWEST_GREEN, OLD_RED], [OLD_RED]), monkeypatch + ) + assert code == 0, output + assert "36832264088" in output + assert "3 listing read(s) unioned" in output + + +def test_the_single_bad_page_alone_still_refuses(monkeypatch: Any) -> None: + """Positive control: if every read hides the newest run, the old red decides.""" + code, output = _verdict(_Pages([OLD_RED]), monkeypatch) + assert code == 1 + assert "35320416369" in output + + +def test_a_newer_red_in_any_read_still_refuses(monkeypatch: Any) -> None: + newer_red = _run(36900000000, conclusion="failure", started="2026-10-01T09:00:00Z") + code, output = _verdict( + _Pages([NEWEST_GREEN], [newer_red, NEWEST_GREEN], [NEWEST_GREEN]), monkeypatch + ) + assert code == 1 + assert "36900000000" in output + + +def test_a_red_rerun_seen_by_one_read_supersedes_its_green_first_attempt( + monkeypatch: Any, +) -> None: + red_rerun = _run( + 36832264088, conclusion="failure", started="2026-10-01T09:30:00Z", attempt=2 + ) + code, output = _verdict( + _Pages([NEWEST_GREEN], [red_rerun], [NEWEST_GREEN]), monkeypatch + ) + assert code == 1 + assert "attempt 2" in output + + +def test_a_stale_green_from_every_read_still_refuses(monkeypatch: Any) -> None: + stale = _run(36538536307, conclusion="success", started="2026-09-29T07:45:26Z") + code, output = _verdict(_Pages([stale]), monkeypatch) + assert code == 1 + assert "stale green" in output + + +def test_any_unreadable_read_refuses(monkeypatch: Any) -> None: + calls = {"n": 0} + + def flaky(_path: str) -> bytes: + calls["n"] += 1 + if calls["n"] == 2: + msg = "`gh api` exited 1: HTTP 502" + raise RuntimeError(msg) + return json.dumps({"workflow_runs": [NEWEST_GREEN]}).encode() + + monkeypatch.setattr("scripts.ci.lab_pass_receipt._gh_api", flaky) + out = io.StringIO() + code = evaluate_workflow_verdict( + REPO, WORKFLOW, "dev", 26.0, ("schedule",), out, now=NOW + ) + assert code == 1 + assert "unreadable" in out.getvalue() diff --git a/tests/fixtures/seams/core_release/0.46.11_expected_symbols.json b/tests/fixtures/seams/core_release/0.46.11_expected_symbols.json index 766d7672ce..d1257e6591 100644 --- a/tests/fixtures/seams/core_release/0.46.11_expected_symbols.json +++ b/tests/fixtures/seams/core_release/0.46.11_expected_symbols.json @@ -12,9 +12,10 @@ "OMN-19408: bumped 0.47.22 -> 0.47.23 (omnibase_infra#4077) to pick up ModelRuntimeLaneScope. The seam test reported the version line and no symbol or field-type drift, so the consumed surface is unchanged.", "OMN-19749: bumped 0.47.23 -> 0.47.24 (released, carries omnibase_core#1786/be874b620) when the LO5a temporary git-rev override was replaced by the released pin. The seam test reported the version line and no symbol or field-type drift, so the consumed surface is unchanged.", "OMN-20024: bumped 0.47.24 -> 0.47.25 by the dependency cascade (omnibase_infra#4317). The seam test reported the version line and no symbol or field-type drift, so the consumed surface is unchanged.", - "OMN-18595: bumped 0.47.25 -> 0.47.27 by the dependency cascade (omnibase_infra#4355). The seam test reported the version line and no symbol or field-type drift, so the consumed surface is unchanged." + "OMN-18595: bumped 0.47.25 -> 0.47.27 by the dependency cascade (omnibase_infra#4355). The seam test reported the version line and no symbol or field-type drift, so the consumed surface is unchanged.", + "OMN-18595: bumped 0.47.27 -> 0.47.28 by the dependency cascade (omnibase_infra#4414). The seam test reported the version line and no symbol or field-type drift, so the consumed surface is unchanged." ], - "core_version": "0.47.27", + "core_version": "0.47.28", "source": "pypi", "modules": { "omnibase_core.models.delegation.wire": [ diff --git a/tests/integration/infra/test_omn_12765_release_backmerge_identity.py b/tests/integration/infra/test_omn_12765_release_backmerge_identity.py index 39a95c0791..0f4ba70aa5 100644 --- a/tests/integration/infra/test_omn_12765_release_backmerge_identity.py +++ b/tests/integration/infra/test_omn_12765_release_backmerge_identity.py @@ -73,6 +73,9 @@ def test_release_backmerge_preserves_proven_runtime_core_pin() -> None: OMN-18595 refresh: the dependency cascade advances the proven runtime to core 0.47.27 (omnibase_infra#4355); spi stays 0.23.5. + + OMN-18595 refresh: the dependency cascade advances the proven runtime to + core 0.47.28 (omnibase_infra#4414); spi stays 0.23.5. """ uv_lock = (ROOT / "uv.lock").read_text(encoding="utf-8") @@ -80,7 +83,7 @@ def test_release_backmerge_preserves_proven_runtime_core_pin() -> None: pins = _declared_pins() # The proven runtime pins the published PyPI releases (exact versions). - assert pins["omnibase-core"] == "0.47.27" + assert pins["omnibase-core"] == "0.47.28" assert pins["omnibase-spi"] == "0.23.5" # The retired git-rev overrides must be gone from both manifest and lock: diff --git a/tests/integration/test_application_database_sql_reserved_keyword_targets_omn20201.py b/tests/integration/test_application_database_sql_reserved_keyword_targets_omn20201.py new file mode 100644 index 0000000000..87b2886d6e --- /dev/null +++ b/tests/integration/test_application_database_sql_reserved_keyword_targets_omn20201.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""The application-database SQL lint over the real SQL corpus (OMN-20201, I21). + +The unit regressions drive single statements. This module runs the shipped lint +over every tracked .sql file in the repository and over the committed frozen +baseline, because a key word read as a relation target shows up as a baseline +row long before anyone reads the statement that produced it. + +Nothing here connects to a database. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +import pytest +import yaml + +from omnibase_core.validators.no_unguarded_git_subprocess import ( + scrub_git_location_env, +) +from omnibase_infra.topology.application_database import load_topology_profile +from omnibase_infra.validation.application_database_domain_enforcement import ( + _POSTGRES_RESERVED_KEYWORDS, + lint_application_database_sql, +) + +pytestmark = pytest.mark.integration + +_REPO_ROOT = Path(__file__).parents[2] +_BASELINE = _REPO_ROOT / "scripts" / "ci" / "application_database_sql_baseline.yaml" +_TARGET = re.compile(r"application relation target '([^'.]+)' must be schema-qualified") + + +def _tracked_sql_files() -> list[Path]: + listed = subprocess.run( + ["git", "ls-files", "*.sql"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=True, + env=scrub_git_location_env(), + ).stdout.split() + return [_REPO_ROOT / name for name in listed] + + +def test_no_tracked_sql_file_yields_a_reserved_key_word_target() -> None: + files = _tracked_sql_files() + assert len(files) > 100, "positive control: the corpus must be found" + topology = load_topology_profile("local") + offenders = [] + for path in files: + for violation in lint_application_database_sql( + path.read_text(encoding="utf-8"), topology + ): + match = _TARGET.fullmatch(violation) + if match and match.group(1) in _POSTGRES_RESERVED_KEYWORDS: + offenders.append(f"{path.relative_to(_REPO_ROOT)}: {violation}") + assert not offenders, "\n".join(offenders) + + +def test_frozen_baseline_holds_no_reserved_key_word_target_row() -> None: + rows = yaml.safe_load(_BASELINE.read_text(encoding="utf-8"))["violations"] + assert rows, "positive control: the baseline must load" + targets = [ + match.group(1) + for row in rows + if (match := _TARGET.fullmatch(row["violation"])) is not None + ] + assert targets, "positive control: the baseline must hold target rows" + assert not [t for t in targets if t in _POSTGRES_RESERVED_KEYWORDS] + + +def test_real_corpus_statements_that_produced_the_rows_stay_clean() -> None: + topology = load_topology_profile("local") + for relative in ( + "docker/application-acl-proof/seed.sql", + "docker/migrations/rollback/rollback_096_grant_role_omnidash_omnidash_analytics.sql", + "docker/migrations/forward/nodes/node_projection_live_events/0001_reclassify_event_lifecycle_types.sql", + ): + joined = "\n".join( + lint_application_database_sql( + (_REPO_ROOT / relative).read_text(encoding="utf-8"), topology + ) + ) + for word in ("all", "true", "false", "then", "or"): + assert ( + f"application relation target {word!r} must be schema-qualified" + not in joined + ), relative diff --git a/tests/integration/topology/test_provider_quota_state_bridge_retired_omn17292.py b/tests/integration/topology/test_provider_quota_state_bridge_retired_omn17292.py new file mode 100644 index 0000000000..f20753e453 --- /dev/null +++ b/tests/integration/topology/test_provider_quota_state_bridge_retired_omn17292.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""The pinned omnimarket contract replaced the provider_quota_state bridge. + +Retiring the OMN-20154 bridge removes only its hand-authored declaration. The +vendored migration lineage and the exact grants in every shipped instance must +survive. This proof uses committed repository state and needs no foreign checkout. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from omnibase_infra.topology.table_grant_derivation import ( + LEGACY_MIGRATION_TABLE_DECLARATIONS, +) + +pytestmark = pytest.mark.integration + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_NODE_MIGRATIONS = Path( + "docker/migrations/forward/nodes/node_projection_provider_quota" +) +_MIGRATIONS = ( + "0000_create_provider_quota_state.sql", + "0001_grant_tenant_projection_writer_provider_quota_state.sql", + "0002_force_rls_provider_quota_state.sql", +) +_SHIPPED_INSTANCES = ("local", "onex-dev", "onex-prod") +_TABLE = "provider_quota_state" +_SCHEMA = "public" +_PRINCIPAL = "tenant_projection_writer" +_REQUIRED_PRIVILEGES = frozenset({"SELECT", "INSERT", "UPDATE"}) + + +def test_no_supplemental_bridge_remains() -> None: + carried = { + declaration.table.name for declaration in LEGACY_MIGRATION_TABLE_DECLARATIONS + } + assert _TABLE not in carried, ( + f"{_TABLE} still has a supplemental declaration, although the pinned " + "omnimarket contract now declares it" + ) + + +@pytest.mark.parametrize("filename", _MIGRATIONS) +def test_vendored_migration_lineage_remains(filename: str) -> None: + migration = _NODE_MIGRATIONS / filename + assert (_REPO_ROOT / migration).is_file(), ( + f"{migration} is missing; retiring {_TABLE}'s bridge must leave its " + "vendored migration in the tree" + ) + + +@pytest.mark.parametrize("profile", _SHIPPED_INSTANCES) +def test_shipped_instance_retains_exact_writer_privileges(profile: str) -> None: + path = ( + _REPO_ROOT + / "src" + / "omnibase_infra" + / "topology" + / "instances" + / f"{profile}.yaml" + ) + document: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) + grants = document["databases"]["application"]["principals"][_PRINCIPAL]["grants"] + matching = [ + grant + for grant in grants + if grant["object_type"] == "TABLE" + and grant["schema"] == _SCHEMA + and _TABLE in grant["objects"] + ] + assert len(matching) == 1, ( + f"{profile} ships {len(matching)} TABLE grants for {_SCHEMA}.{_TABLE} " + f"to {_PRINCIPAL}; expected exactly one" + ) + assert frozenset(matching[0]["privileges"]) == _REQUIRED_PRIVILEGES diff --git a/tests/unit/docker/test_docker_security.py b/tests/unit/docker/test_docker_security.py index a2177d7f2a..dcd353c9d9 100644 --- a/tests/unit/docker/test_docker_security.py +++ b/tests/unit/docker/test_docker_security.py @@ -104,6 +104,17 @@ def build_private_ip_pattern() -> re.Pattern[str]: # Compiled pattern for use in tests PRIVATE_IP_PATTERN = build_private_ip_pattern() +# A ports-list entry that publishes a container port on the host loopback only, +# e.g. ``- "127.0.0.1:${REDPANDA_ADMIN_PORT:-9644}:9644"``. This is a bind +# restriction, not a dependency on a host: every machine has 127.0.0.1, so it +# costs no portability, and it is how an unauthenticated admin surface is kept +# off the LAN (OMN-20260). Only this exact shape is exempt from the +# private-IP scan; a loopback address anywhere else still fails it. +LOOPBACK_PORT_PUBLISH_PATTERN = re.compile( + r'^[ \t]*-[ \t]*"127\.0\.0\.1:(?:\$\{[A-Z0-9_]+(?::-\d+)?\}|\d+):\d+(?:/(?:tcp|udp))?"[ \t]*$', + re.MULTILINE, +) + @pytest.mark.unit class TestEnvExampleSecurity: @@ -626,7 +637,10 @@ def test_self_contained_infrastructure(self) -> None: # localhost and link-local. See module-level docstring for details. # Note: Redpanda is now a local Docker service (OMN-3431). It uses the # Docker-internal hostname redpanda:9092, not a private IP address. - matches = PRIVATE_IP_PATTERN.findall(content) + # Loopback-only port publishes are bind restrictions, not host + # dependencies (OMN-20260); strip exactly that shape before scanning. + scanned = LOOPBACK_PORT_PUBLISH_PATTERN.sub("", content) + matches = PRIVATE_IP_PATTERN.findall(scanned) assert not matches, ( f"Found hardcoded private IP addresses: {matches}\n" "Configuration should use Docker service names or environment variables " diff --git a/tests/unit/docker/test_lakshman_redpanda_admin_loopback_omn20260.py b/tests/unit/docker/test_lakshman_redpanda_admin_loopback_omn20260.py new file mode 100644 index 0000000000..6706ebfa7d --- /dev/null +++ b/tests/unit/docker/test_lakshman_redpanda_admin_loopback_omn20260.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""The lakshman lane publishes its Redpanda Admin API on loopback only (OMN-20260). + +The collaborator lane's broker runs with ``admin_api_require_auth`` false, because +its healthcheck and the rpk one-shots call the Admin API with no credentials. +Published on every host interface (``55644:9644``), any LAN or tailnet host could +create SCRAM users and rewrite cluster config. In-network consumers reach +``redpanda:9644`` and never needed the host publish; the reserved host port +55644 (OMN-17143) is kept, bound to 127.0.0.1. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +LAKSHMAN_COMPOSE = REPO_ROOT / "docker" / "docker-compose.lakshman.yml" +ADMIN_CONTAINER_PORT = "9644" +RESERVED_ADMIN_HOST_PORT = "55644" + + +def _construct_compose_value(loader: yaml.SafeLoader, node: yaml.Node) -> object: + if isinstance(node, yaml.MappingNode): + return loader.construct_mapping(node) + if isinstance(node, yaml.SequenceNode): + return loader.construct_sequence(node) + assert isinstance(node, yaml.ScalarNode) + return loader.construct_scalar(node) + + +class _ComposeLoader(yaml.SafeLoader): + """Test-local loader that tolerates Docker Compose merge tags.""" + + +for _tag in ("!override", "!reset"): + _ComposeLoader.add_constructor(_tag, _construct_compose_value) + + +def _admin_publishes(compose_path: Path) -> list[str]: + compose = yaml.load(compose_path.read_text(encoding="utf-8"), Loader=_ComposeLoader) # noqa: S506 + assert isinstance(compose, dict) + ports = compose["services"]["redpanda"].get("ports", []) + return [ + str(p) + for p in ports + if str(p).rsplit(":", 1)[-1].split("/", 1)[0] == ADMIN_CONTAINER_PORT + ] + + +def _is_loopback_publish(publish: str) -> bool: + return publish.startswith("127.0.0.1:") + + +@pytest.mark.unit +def test_lakshman_admin_api_is_published_on_loopback_only() -> None: + publishes = _admin_publishes(LAKSHMAN_COMPOSE) + assert publishes, ( + "lakshman redpanda no longer publishes its admin port; update this test" + ) + open_publishes = [p for p in publishes if not _is_loopback_publish(p)] + assert not open_publishes, ( + f"lakshman redpanda admin API published on all interfaces: {open_publishes}. " + "admin_api_require_auth is false on this broker; bind it to 127.0.0.1 (OMN-20260)." + ) + + +@pytest.mark.unit +def test_lakshman_admin_api_keeps_its_reserved_host_port() -> None: + """The bind changes the interface, not the OMN-17143 port reservation.""" + publishes = _admin_publishes(LAKSHMAN_COMPOSE) + assert publishes == [f"127.0.0.1:{RESERVED_ADMIN_HOST_PORT}:{ADMIN_CONTAINER_PORT}"] + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("publish", "loopback"), + [ + ("55644:9644", False), + ("0.0.0.0:55644:9644", False), + ("127.0.0.1:55644:9644", True), + ], +) +def test_loopback_check_refuses_an_all_interfaces_publish( + publish: str, loopback: bool +) -> None: + """Positive control: the predicate refuses the pre-fix shape.""" + assert _is_loopback_publish(publish) is loopback diff --git a/tests/unit/docker/test_redpanda_admin_loopback_omn20260.py b/tests/unit/docker/test_redpanda_admin_loopback_omn20260.py new file mode 100644 index 0000000000..bbd48bfab8 --- /dev/null +++ b/tests/unit/docker/test_redpanda_admin_loopback_omn20260.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""The base compose file publishes the Redpanda Admin API on loopback only (OMN-20260). + +The dev lane broker runs with ``admin_api_require_auth`` false, because its +healthcheck, the deploy-runtime.sh broker probe and the rpk one-shots call the +Admin API with no credentials. Published on every host interface, that let any +LAN or tailnet host create SCRAM users and rewrite cluster config. In-network +consumers reach ``redpanda:9644`` and never needed the host publish. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +INFRA_COMPOSE = REPO_ROOT / "docker" / "docker-compose.infra.yml" +ADMIN_CONTAINER_PORT = "9644" + + +def _construct_compose_value(loader: yaml.SafeLoader, node: yaml.Node) -> object: + if isinstance(node, yaml.MappingNode): + return loader.construct_mapping(node) + if isinstance(node, yaml.SequenceNode): + return loader.construct_sequence(node) + assert isinstance(node, yaml.ScalarNode) + return loader.construct_scalar(node) + + +class _ComposeLoader(yaml.SafeLoader): + """Test-local loader that tolerates Docker Compose merge tags.""" + + +for _tag in ("!override", "!reset"): + _ComposeLoader.add_constructor(_tag, _construct_compose_value) + + +def _admin_publishes(compose_path: Path) -> list[str]: + compose = yaml.load(compose_path.read_text(encoding="utf-8"), Loader=_ComposeLoader) # noqa: S506 + assert isinstance(compose, dict) + ports = compose["services"]["redpanda"].get("ports", []) + return [ + str(p) + for p in ports + if str(p).rsplit(":", 1)[-1].split("/", 1)[0] == ADMIN_CONTAINER_PORT + ] + + +def _is_loopback_publish(publish: str) -> bool: + return publish.startswith("127.0.0.1:") + + +@pytest.mark.unit +def test_admin_api_is_published_on_loopback_only() -> None: + publishes = _admin_publishes(INFRA_COMPOSE) + assert publishes, "redpanda no longer publishes its admin port; update this test" + open_publishes = [p for p in publishes if not _is_loopback_publish(p)] + assert not open_publishes, ( + f"redpanda admin API published on all interfaces: {open_publishes}. " + "admin_api_require_auth is false on this broker; bind it to 127.0.0.1 (OMN-20260)." + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("publish", "loopback"), + [ + ("${REDPANDA_ADMIN_PORT:-9644}:9644", False), + ("0.0.0.0:9644:9644", False), + ("127.0.0.1:${REDPANDA_ADMIN_PORT:-9644}:9644", True), + ], +) +def test_loopback_check_refuses_an_all_interfaces_publish( + publish: str, loopback: bool +) -> None: + """Positive control: the predicate the guard relies on refuses the pre-fix shape.""" + assert _is_loopback_publish(publish) is loopback + + +@pytest.mark.unit +def test_private_ip_scan_exempts_only_a_loopback_port_publish() -> None: + """The security scan's exemption covers a loopback port bind and nothing else.""" + from tests.unit.docker.test_docker_security import ( + LOOPBACK_PORT_PUBLISH_PATTERN, + PRIVATE_IP_PATTERN, + ) + + publish = ' ports:\n - "127.0.0.1:${REDPANDA_ADMIN_PORT:-9644}:9644"\n' + assert PRIVATE_IP_PATTERN.findall(publish), "control: the raw scan flags it" + assert not PRIVATE_IP_PATTERN.findall( + LOOPBACK_PORT_PUBLISH_PATTERN.sub("", publish) + ) + + dependency = ' environment:\n KAFKA_BOOTSTRAP_SERVERS: "127.0.0.1:9092"\n' + assert PRIVATE_IP_PATTERN.findall( + LOOPBACK_PORT_PUBLISH_PATTERN.sub("", dependency) + ), "a loopback host dependency must still fail the scan" diff --git a/tests/unit/migrations/test_omn20154_provider_quota_state_vendor.py b/tests/unit/migrations/test_omn20154_provider_quota_state_vendor.py index e1ec5f76ac..3a45a79735 100644 --- a/tests/unit/migrations/test_omn20154_provider_quota_state_vendor.py +++ b/tests/unit/migrations/test_omn20154_provider_quota_state_vendor.py @@ -132,60 +132,3 @@ def test_the_sql_gate_is_live_positive_control() -> None: broken = sql.replace("public.provider_quota_state", "tenant.provider_quota_state") assert broken != sql assert lint_application_database_sql(broken, load_topology_profile("local")) != () - - -@pytest.mark.parametrize("profile", ["local", "onex-dev", "onex-prod"]) -def test_bridge_derives_the_shipped_table_grant(profile: str) -> None: - from omnibase_core.enums.enum_database_grant_object_type import ( - EnumDatabaseGrantObjectType, - ) - from omnibase_infra.topology import load_topology_profile - from omnibase_infra.topology.table_grant_derivation import ( - LEGACY_MIGRATION_TABLE_DECLARATIONS, - derive_table_grants, - ) - - bridges = tuple( - entry - for entry in LEGACY_MIGRATION_TABLE_DECLARATIONS - if entry.table.name == _TABLE - ) - assert len(bridges) == 1 - bridge = bridges[0] - assert bridge.table.access == "read_write" - assert bridge.table.schema == "public" - assert bridge.table.database_ref == "application" - assert bridge.contract_path == (_VENDOR / _CREATE).relative_to(_ROOT) - derived = derive_table_grants(load_topology_profile(profile), bridges) - matching = [ - (principal, grant) - for principal, grants in derived.grants.items() - for grant in grants - if grant.object_type is EnumDatabaseGrantObjectType.TABLE - and grant.schema == "public" - and _TABLE in grant.objects - ] - assert len(matching) == 1 - principal, grant = matching[0] - assert principal == "tenant_projection_writer" - assert {privilege.value for privilege in grant.privileges} == { - "SELECT", - "INSERT", - "UPDATE", - } - instance = yaml.safe_load( - (_ROOT / "src/omnibase_infra/topology/instances" / f"{profile}.yaml").read_text( - encoding="utf-8" - ) - ) - shipped = [ - entry - for entry in instance["databases"]["application"]["principals"][principal][ - "grants" - ] - if entry["object_type"] == "TABLE" - and entry["schema"] == "public" - and _TABLE in entry["objects"] - ] - assert len(shipped) == 1 - assert set(shipped[0]["privileges"]) == {"SELECT", "INSERT", "UPDATE"} diff --git a/tests/unit/scripts/test_apply_governed_compose_config_omn20260.py b/tests/unit/scripts/test_apply_governed_compose_config_omn20260.py new file mode 100644 index 0000000000..075c15eea7 --- /dev/null +++ b/tests/unit/scripts/test_apply_governed_compose_config_omn20260.py @@ -0,0 +1,281 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""The governed-lane compose-config apply refuses without a matched grant (OMN-20260). + +Faithful substitution, not mocks: a real git checkout, a real bare +``onex_change_control`` origin whose ``main`` carries the anchor, and a ``docker`` +executable on PATH that renders a fixed config and records every ``up`` it is +asked for. Each refusal is paired with the matched-grant control. +""" + +from __future__ import annotations + +import json +import os +import stat +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from omnibase_core.validators.no_unguarded_git_subprocess import ( + scrub_git_location_env, +) +from scripts.apply_governed_compose_config import ( + GOVERNED_COMPOSE_LANES, + canonical_digest, + main, +) + +RENDER = {"name": "omnibase-infra-judge", "services": {"redpanda": {"ports": ["x"]}}} +GRANT_ID = "grant-6f1c2b3a-1d2e-4f50-8a9b-0c1d2e3f4a5b" +NOW = "2026-10-01T12:00:00Z" + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(cwd), *args], + check=True, + capture_output=True, + text=True, + env=scrub_git_location_env(os.environ), + ).stdout.strip() + + +def _init_repo(path: Path) -> None: + path.mkdir(parents=True) + _git(path, "init", "-q", "-b", "main") + _git(path, "config", "user.email", "t@example.invalid") + _git(path, "config", "user.name", "t") + + +@pytest.fixture +def lab(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + checkout = tmp_path / "infra" + _init_repo(checkout) + (checkout / "docker").mkdir() + (checkout / "docker" / "docker-compose.judge.yml").write_text("services: {}\n") + _git(checkout, "add", ".") + _git(checkout, "commit", "-q", "-m", "c") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + up_log = tmp_path / "up.log" + fake = bin_dir / "docker" + fake.write_text( + "#!/bin/sh\n" + 'for a in "$@"; do\n' + ' if [ "$a" = config ]; then printf %s "$FAKE_RENDER"; exit 0; fi\n' + ' if [ "$a" = up ]; then echo "$@" >> "$UP_LOG"; exit 0; fi\n' + "done\nexit 3\n" + ) + fake.chmod(fake.stat().st_mode | stat.S_IEXEC) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + monkeypatch.setenv("FAKE_RENDER", json.dumps(RENDER)) + monkeypatch.setenv("UP_LOG", str(up_log)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + operator_env = tmp_path / "operator.env" + operator_env.write_text("POSTGRES_PASSWORD=unused-in-test\n") + monkeypatch.setenv("OMNIBASE_OPERATOR_ENV_FILE", str(operator_env)) + return {"tmp": tmp_path, "checkout": checkout, "up_log": up_log} + + +def _entry(lab: dict[str, Any], **overrides: Any) -> dict[str, Any]: + entry: dict[str, Any] = { + "grant_id": GRANT_ID, + "target_kind": "compose_config", + "runtime_lane": "judge", + "compose_project": "omnibase-infra-judge", + "compose_files": ["docker/docker-compose.judge.yml"], + "env_files": [], + "profiles": ["judge"], + "services": ["redpanda"], + "compose_ref": _git(lab["checkout"], "rev-parse", "HEAD"), + "rendered_digest": canonical_digest(json.dumps(RENDER).encode()), + "requested_by": "jonahgabriel", + "approved_by": "jake-b-omni", + "expires_at": "2026-10-02T12:00:00Z", + "created_at": "2026-10-01T11:00:00Z", + "reason": "OMN-20260 test", + } + entry.update(overrides) + return entry + + +def _anchor_file(lab: dict[str, Any], entries: list[dict[str, Any]]) -> Path: + path: Path = lab["tmp"] / "anchor.yaml" + path.write_text(yaml.safe_dump({"entries": entries})) + return path + + +def _check( + lab: dict[str, Any], entries: list[dict[str, Any]], lane: str = "judge" +) -> int: + return main( + [ + "--repo-root", + str(lab["checkout"]), + "apply", + "--lane", + lane, + "--grant-id", + GRANT_ID, + "--grants-file", + str(_anchor_file(lab, entries)), + "--now", + NOW, + ] + ) + + +def _occ_origin(lab: dict[str, Any], entries: list[dict[str, Any]]) -> Path: + """A bare origin whose main carries the anchor, and a clone of it.""" + work = lab["tmp"] / "occ-src" + _init_repo(work) + (work / "grants").mkdir() + (work / "grants" / "governed_lane_grants.yaml").write_text( + yaml.safe_dump({"entries": entries}) + ) + _git(work, "add", ".") + _git(work, "commit", "-q", "-m", "anchor") + bare = lab["tmp"] / "occ.git" + subprocess.run( + ["git", "clone", "-q", "--bare", str(work), str(bare)], + check=True, + env=scrub_git_location_env(os.environ), + ) + clone: Path = lab["tmp"] / "occ" + subprocess.run( + ["git", "clone", "-q", str(bare), str(clone)], + check=True, + env=scrub_git_location_env(os.environ), + ) + return clone + + +@pytest.mark.unit +def test_governed_lanes_are_pinned() -> None: + assert frozenset({"stability-test", "judge"}) == GOVERNED_COMPOSE_LANES + + +@pytest.mark.unit +def test_canonical_digest_ignores_key_order_and_whitespace() -> None: + a = canonical_digest(b'{"b": 1, "a": [1, 2]}') + b = canonical_digest(b'{"a":[1,2],"b":1}') + assert a == b + assert a != canonical_digest(b'{"a":[2,1],"b":1}') + + +@pytest.mark.unit +def test_matched_grant_is_allowed( + lab: dict[str, Any], capsys: pytest.CaptureFixture[str] +) -> None: + assert _check(lab, [_entry(lab)]) == 0 + assert "ALLOWED" in capsys.readouterr().out + assert not lab["up_log"].exists(), "check-only must not recreate anything" + + +@pytest.mark.unit +@pytest.mark.parametrize( + "overrides", + [ + {"grant_id": "grant-00000000-0000-4000-8000-000000000000"}, + {"target_kind": "image"}, + {"runtime_lane": "stability-test"}, + {"compose_project": "omnibase-infra"}, + {"approved_by": "JonahGabriel"}, + {"approved_by": ""}, + {"consumed": True}, + {"expires_at": "2026-10-01T11:59:59Z"}, + {"rendered_digest": "sha256:" + "0" * 64}, + {"compose_ref": "0" * 40}, + {"services": []}, + ], +) +def test_unmatched_grant_is_refused( + lab: dict[str, Any], overrides: dict[str, Any], capsys: pytest.CaptureFixture[str] +) -> None: + assert _check(lab, [_entry(lab, **overrides)]) == 1 + assert "REFUSED" in capsys.readouterr().out + assert not lab["up_log"].exists() + + +@pytest.mark.unit +def test_empty_anchor_is_refused(lab: dict[str, Any]) -> None: + assert _check(lab, []) == 1 + + +@pytest.mark.unit +def test_modified_compose_file_is_refused(lab: dict[str, Any]) -> None: + entry = _entry(lab) + (lab["checkout"] / "docker" / "docker-compose.judge.yml").write_text( + "services: {a: {}}\n" + ) + assert _check(lab, [entry]) == 1 + + +@pytest.mark.unit +def test_changed_render_is_refused( + lab: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + entry = _entry(lab) + monkeypatch.setenv("FAKE_RENDER", json.dumps({**RENDER, "name": "other"})) + assert _check(lab, [entry]) == 1 + + +@pytest.mark.unit +def test_execute_never_reads_a_local_anchor(lab: dict[str, Any]) -> None: + path = _anchor_file(lab, [_entry(lab)]) + argv = ["--repo-root", str(lab["checkout"]), "apply", "--lane", "judge"] + argv += ["--grant-id", GRANT_ID, "--grants-file", str(path), "--execute"] + assert main(argv) == 2 + assert not lab["up_log"].exists() + + +@pytest.mark.unit +def test_execute_reads_main_and_recreates_only_granted_services( + lab: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + occ = _occ_origin(lab, [_entry(lab)]) + monkeypatch.setenv("ONEX_DEPLOY_REASON", "OMN-20260 loopback admin bind test") + argv = ["--repo-root", str(lab["checkout"]), "apply", "--lane", "judge"] + argv += ["--grant-id", GRANT_ID, "--occ-repo", str(occ), "--now", NOW, "--execute"] + assert main(argv) == 0 + calls = lab["up_log"].read_text().splitlines() + assert len(calls) == 1 + assert calls[0].endswith("up -d --no-deps --no-build redpanda") + assert "-p omnibase-infra-judge" in calls[0] + + +@pytest.mark.unit +def test_execute_without_a_grant_on_main_recreates_nothing( + lab: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + occ = _occ_origin(lab, []) + monkeypatch.setenv("ONEX_DEPLOY_REASON", "OMN-20260 loopback admin bind test") + argv = ["--repo-root", str(lab["checkout"]), "apply", "--lane", "judge"] + argv += ["--grant-id", GRANT_ID, "--occ-repo", str(occ), "--now", NOW, "--execute"] + assert main(argv) == 1 + assert not lab["up_log"].exists() + + +@pytest.mark.unit +def test_execute_is_also_gated_by_the_attribution_preflight( + lab: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + occ = _occ_origin(lab, [_entry(lab)]) + monkeypatch.delenv("ONEX_DEPLOY_REASON", raising=False) + argv = ["--repo-root", str(lab["checkout"]), "apply", "--lane", "judge"] + argv += ["--grant-id", GRANT_ID, "--occ-repo", str(occ), "--now", NOW, "--execute"] + assert main(argv) == 1 + assert not lab["up_log"].exists() + + +@pytest.mark.unit +def test_missing_operator_env_is_a_usage_error( + lab: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OMNIBASE_OPERATOR_ENV_FILE", str(lab["tmp"] / "absent.env")) + assert _check(lab, [_entry(lab)]) == 2 diff --git a/tests/unit/scripts/test_prepr_runtime_pool_omn18893.py b/tests/unit/scripts/test_prepr_runtime_pool_omn18893.py index c9845cff2a..84f85988de 100644 --- a/tests/unit/scripts/test_prepr_runtime_pool_omn18893.py +++ b/tests/unit/scripts/test_prepr_runtime_pool_omn18893.py @@ -19,6 +19,8 @@ import shutil import subprocess import sys +import threading +import time from pathlib import Path from typing import Any @@ -284,6 +286,40 @@ def _survey( return states +@pytest.mark.unit +def test_survey_probes_concurrently_and_preserves_host_order() -> None: + class SlowTransport(FakeTransport): + def __init__(self, hosts: dict[str, FakeHost]) -> None: + super().__init__(hosts) + self.lock = threading.Lock() + self.in_flight = 0 + self.max_in_flight = 0 + self.probed: list[str] = [] + + def run(self, host: Any, command: str, timeout: float) -> tuple[int, str]: + if "CORES=$(getconf" not in command: + return super().run(host, command, timeout) + with self.lock: + self.in_flight += 1 + self.max_in_flight = max(self.max_in_flight, self.in_flight) + self.probed.append(host.name) + try: + time.sleep(0.3) + return super().run(host, command, timeout) + finally: + with self.lock: + self.in_flight -= 1 + + transport = SlowTransport({host.name: FakeHost() for host in CFG.hosts}) + states = pool.survey(CFG, transport, {}, NOW) + + assert transport.max_in_flight > 1 + assert [state.host.name for state in states] == [host.name for host in CFG.hosts] + assert sorted(transport.probed) == sorted( + host.name for host in CFG.hosts if host.status != "excluded" + ) + + def test_pick_takes_the_least_loaded_free_host_and_never_an_excluded_one() -> None: hosts = { "lab-101": FakeHost(load=6.0), diff --git a/tests/unit/validation/test_application_database_sql_enforcement_regressions.py b/tests/unit/validation/test_application_database_sql_enforcement_regressions.py index da28831b00..330428d8a7 100644 --- a/tests/unit/validation/test_application_database_sql_enforcement_regressions.py +++ b/tests/unit/validation/test_application_database_sql_enforcement_regressions.py @@ -867,3 +867,55 @@ def test_plpgsql_declared_variable_does_not_mask_a_real_relation_read() -> None: assert "schema-qualified" in "\n".join( lint_application_database_sql(sql, _TOPOLOGY) ) + + +@pytest.mark.parametrize( + ("sql", "keyword"), + [ + ( + "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA omninode_internal TO app_dashboard;", + "all", + ), + ( + "REVOKE SELECT ON ALL TABLES IN SCHEMA omninode_internal FROM role_omnidash;", + "all", + ), + ( + "UPDATE omninode_internal.live_events SET event_type = CASE " + "WHEN lower(topic) LIKE '%failed%' OR lower(topic) LIKE '%error%' " + "THEN 'ERROR' ELSE event_type END;", + "or", + ), + ( + "UPDATE omninode_internal.live_events SET event_type = CASE " + "WHEN lower(topic) LIKE '%cmd%'\n THEN 'COMMAND' ELSE event_type END;", + "then", + ), + ( + "CREATE TABLE omninode_internal.claims (ok BOOLEAN NOT NULL, " + "CONSTRAINT ck_ok CHECK (ok IS TRUE), CONSTRAINT ck_no CHECK (ok IS NOT FALSE));", + "true", + ), + ], +) +def test_reserved_keywords_are_never_relation_targets(sql: str, keyword: str) -> None: + """OMN-20201 (I21): a reserved key word the matcher lands on is grammar.""" + joined = "\n".join(lint_application_database_sql(sql, _TOPOLOGY)) + assert ( + f"application relation target {keyword!r} must be schema-qualified" + not in joined + ) + + +@pytest.mark.parametrize( + "sql", + [ + 'SELECT * FROM "all";', + 'UPDATE "then" SET x = 1;', + "SELECT * FROM events WHERE topic LIKE '%x%';", + ], +) +def test_reserved_keyword_guard_never_exempts_a_real_relation(sql: str) -> None: + """Control: a quoted key word, or a real bare relation beside LIKE, still fails.""" + joined = "\n".join(lint_application_database_sql(sql, _TOPOLOGY)) + assert "must be schema-qualified" in joined diff --git a/uv.lock b/uv.lock index 773933d9b7..08ed5d54f8 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [manifest] overrides = [ { name = "omnibase-compat", specifier = "==0.5.7" }, - { name = "omnibase-core", specifier = "==0.47.27" }, + { name = "omnibase-core", specifier = "==0.47.28" }, { name = "omnibase-spi", specifier = "==0.23.5" }, ] @@ -2233,7 +2233,7 @@ wheels = [ [[package]] name = "omnibase-core" -version = "0.47.27" +version = "0.47.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blake3" }, @@ -2250,14 +2250,14 @@ dependencies = [ { name = "tree-sitter" }, { name = "tree-sitter-python" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/c7/7111e984ac822f924f5c8d6ef19297b27b2c53c8ba1154311639b9da5ec9/omnibase_core-0.47.27.tar.gz", hash = "sha256:82525c8b833d36f6d4e5d2f7ab0131ab1326129a83e1f8e87411d4c1e3412f0e", size = 10078574, upload-time = "2026-09-30T14:45:32.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/0e/616973684cbc53b4b9f6061e4c48a380a6cf36445dc4f0ff62bbc2b6fefd/omnibase_core-0.47.28.tar.gz", hash = "sha256:9e1c6cb4c89526501239f47c529cb5e9bb4ceeb1121d0dbba1c5d0084c869c9a", size = 10187444, upload-time = "2026-10-01T09:55:30.404Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/9c89e7142f5d468e20450535207b396583ecfadee7c331fc17c8de83771f/omnibase_core-0.47.27-py3-none-any.whl", hash = "sha256:311df188cbe12bbeba8b73b4eb24c53b4856193b7867449990d20a4ff290ccc6", size = 7153614, upload-time = "2026-09-30T14:45:29.896Z" }, + { url = "https://files.pythonhosted.org/packages/59/96/0a352db874d89ad52889c286acbfa3fac92a53d5eec1246601f56effac84/omnibase_core-0.47.28-py3-none-any.whl", hash = "sha256:dcaeab1dfa3082c54671b03c58c81be971efdad7536af10b7183c5c5171b230a", size = 7246392, upload-time = "2026-10-01T09:55:28.076Z" }, ] [[package]] name = "omnibase-infra" -version = "0.38.61" +version = "0.38.62" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },