Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Refactor monetize reconciliation into the serviceoffer controller - #299

Merged
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller
Apr 7, 2026
Merged

Refactor monetize reconciliation into the serviceoffer controller#299
bussyjd merged 12 commits into
feat/monetize-pathfrom
codex/serviceoffer-controller

Conversation

@bussyjd

Copy link
Copy Markdown
Contributor

Summary

This PR moves sell-side monetization reconciliation out of the obol-agent runtime and into a dedicated Kubernetes controller.

The main outcome is that ServiceOffer becomes the single source of truth for monetized HTTP offers, while the request path (x402-verifier) remains a separate stateless service that derives live routes directly from Kubernetes state instead of a shared mutable ConfigMap.

Why

The previous flow relied on monetize.py running inside the agent runtime, periodic polling, and imperative mutation of shared x402 pricing config. That created a few structural problems:

  • reconciliation depended on the obol-agent process being alive
  • route publication lagged behind changes because it was poll-driven
  • live pricing state was stored in a shared rendered artifact (x402-pricing), which introduced race conditions and cleanup complexity
  • external registration side effects were not clearly owned by a controller/finalizer path

This PR keeps the controller and verifier separate, but gives each a cleaner boundary:

  • serviceoffer-controller owns cluster convergence and registration lifecycle
  • x402-verifier owns only the live payment-gating request path

What changed

1. Added a dedicated serviceoffer-controller

  • added a new controller binary: cmd/serviceoffer-controller
  • added controller reconciliation logic under internal/serviceoffercontroller
  • the controller watches ServiceOffer and reconciles the Kubernetes resources needed to publish a paid route
  • the controller updates status.conditions and status.observedGeneration
  • delete-time cleanup is now controller-owned via finalizer logic instead of CLI best-effort cleanup

2. Kept ServiceOffer as the source of truth

An earlier design path introduced a separate PaymentRoute projection. This PR intentionally does not keep that layer.

Instead:

  • ServiceOffer remains the only dynamic intent object
  • the controller reconciles from ServiceOffer
  • the verifier also reads ServiceOffer directly and rebuilds its in-memory route table from informer-backed cluster state

This keeps the model simpler and avoids duplicating routing state in another CRD.

3. Isolated registration side effects with RegistrationRequest

  • added a RegistrationRequest CRD
  • the controller now owns creation and observation of registration work instead of letting it leak into the request-serving path
  • registration publication and cleanup move closer to a proper controller/finalizer model

4. Simplified x402-verifier

  • x402-verifier no longer relies on the dynamic x402-pricing ConfigMap as the live source for per-offer routes
  • it now derives route rules from published ServiceOffer objects
  • /.well-known/agent-registration.json is no longer served by the verifier, which reduces the amount of non-request-path responsibility in that service
  • file-based config remains for static verifier settings, but not for live ServiceOffer routing state

5. Reduced agent-side monetization responsibilities

  • rewrote internal/embed/skills/sell/scripts/monetize.py into a much thinner compatibility layer
  • it now behaves as a CRUD/status/wait/publish helper instead of being the long-lived reconciliation owner
  • reduced the monetize RBAC footprint for the obol agent to reflect that it is no longer the control-plane reconciler

6. Updated schemas, docs, and tests to match the new model

  • extended registration-related schema/types so skills and domains flow through the new control plane
  • updated architecture/design docs and plans so the old poll-driven ConfigMap-mutating design is explicitly historical
  • updated x402 BDD/E2E and controller/unit test coverage for the new source-of-truth and controller model

Key files

  • cmd/serviceoffer-controller/main.go
  • internal/serviceoffercontroller/controller.go
  • internal/serviceoffercontroller/render.go
  • internal/embed/infrastructure/base/templates/registrationrequest-crd.yaml
  • internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
  • internal/embed/infrastructure/base/templates/x402.yaml
  • internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml
  • internal/x402/serviceoffer_source.go
  • internal/x402/verifier.go
  • internal/embed/skills/sell/scripts/monetize.py

Validation

Passed locally:

python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py
go test ./internal/serviceoffercontroller ./internal/embed ./internal/x402 -run 'Test(ServiceOfferCRD_|RegistrationRequestCRD_|MonetizeRBAC_|BuildMiddleware|BuildHTTPRoute|BuildRegistrationRequest|BuildActiveRegistrationDocument|RegistrationDataURL|SetConditionUpdatesExistingEntry|RoutesFromStore|RoutesFromStore_IgnoresUnpublishedOffers|Verifier_NoForwardedURI_Returns403|Verifier_FreeRoute_Returns200|Verifier_PaidRoute_NoPayment_Returns402|Verifier_PaidRoute_ValidPayment_Returns200|Verifier_PaidRoute_RejectedPayment_Returns402|Verifier_VerifyOnly_SkipsSettle|Verifier_Readyz|Verifier_InvalidChain|WatchConfig_)'
go test ./... -run TestDoesNotExist
go test -c -tags integration -o /tmp/obol-integration-compile/openclaw.test ./internal/openclaw
go test -c -tags integration -o /tmp/obol-integration-compile/x402.test ./internal/x402

Runtime integration note

I also made a real integration attempt with:

go test -tags integration -run TestIntegration_PaymentGate_FullLifecycle -timeout 30m ./internal/x402

That now gets past the earlier missing-k3d bootstrap blocker, creates the cluster, and enters obol stack up, but it did not complete within the session because it was still in the Docker image build/bootstrap path for the x402 stack. I am calling that out here because it is a meaningful improvement over the previous blocked state, but it is not yet a completed end-to-end runtime pass.

Follow-up

  • finish a full runtime integration pass in CI/local once the image-build/bootstrap path is stable enough to complete consistently
  • if desired, add a repo/issue update back to Replace monetize.py reconciliation loop with controller-runtime operator #296 summarizing the final design choice (ServiceOffer direct watch instead of introducing PaymentRoute); I attempted this through the GitHub app, but the app did not have permission to comment on the issue in this repo

bussyjdand others added 5 commits March 29, 2026 08:56
Resolve CLI/ERC-8004 conflicts for the ServiceOffer controller branch and replace the buyer proxy's x402 retry transport with a replay-safe local implementation so request bodies survive 402 retries under Go 1.26.
@bussyjd
bussyjd changed the base branch from main to feat/monetize-pathMarch 30, 2026 04:21
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Validation rerun on codex/serviceoffer-controller after resolving the feat/monetize-path merge conflicts.

Passed:

go test ./internal/embed ./internal/serviceoffercontroller ./internal/erc8004 ./internal/kubectl ./internal/schemas
go test ./cmd/obol ./internal/network ./internal/tunnel ./internal/x402/...
python3 -m unittest tests/test_sell_registration_metadata.py
python3 -m py_compile internal/embed/skills/sell/scripts/monetize.py

Notes:

  • Worktree was clean before rerunning.
  • This covers the controller/renderer path, ERC-8004 types/client, kubectl apply helpers, CLI sell path, network/tunnel plumbing, x402 verifier/buyer path, and the Python registration metadata compatibility helpers.
  • I did not rerun the full live seller/buyer integration flows in this pass; this comment is reporting the automated test rerun only.

When OBOL_DEVELOPMENT=true, Docker builds from the project root pick up
.workspace/data/ directories that contain root-owned PVC mounts from
previous clusters, causing "permission denied" errors during context
scanning.
Exclude .workspace/ and .worktrees/ from the Docker build context via
.dockerignore.
Fixes#304
@bussyjd
bussyjdforce-pushed the codex/serviceoffer-controller branch from badcfc0 to 98fc024CompareMarch 30, 2026 05:33
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation after rerunning the flow suite on codex/serviceoffer-controller.

What I fixed while running the flows:

  • flow-07-sell-verify.sh: fixed tunnel URL extraction under set -euo pipefail
  • flow-08-buy.sh: fixed the same tunnel extraction bug and made the buy flow fall back to local obol.stack when the quick tunnel URL exists but does not actually return the expected 402 on the service route
  • flow-10-anvil-facilitator.sh: hardened local facilitator startup so it survives after the script exits, and made the cluster facilitator host alias probe the live cluster instead of hardcoding a mac/Linux assumption
  • internal/stack/stack.go: added dev-mode prewarm/import of external images so fresh OBOL_DEVELOPMENT=true clusters do not spend most of bootstrap waiting on internet pulls for third-party images

Flow status after those fixes:

  • flow-01-prerequisites.sh: pass
  • flow-02-stack-init-up.sh: pass
  • flow-03-inference.sh: pass
  • flow-04-agent.sh: pass (after importing the OpenClaw image into the k3d cluster cache)
  • flow-05-network.sh: pass
  • flow-06-sell-setup.sh: pass
  • flow-07-sell-verify.sh: pass
  • flow-10-anvil-facilitator.sh: pass
  • flow-08-buy.sh: pass
  • flow-09-lifecycle.sh: pass

Concrete seller/buyer proof from the successful rerun:

  • local 402: pass
  • tunnel 402: pass
  • paid inference: pass
  • buyer USDC: 1000000000 -> 999999000
  • seller USDC: 291036851 -> 291037851

Root cause of the earlier buy failure:

  • the facilitator path was not actually stable from the cluster’s point of view
  • the old flow hardcoded the cluster host alias and backgrounded the facilitator in a way that let it disappear after startup
  • once the script selected the reachable alias and the facilitator stayed resident, the verifier/facilitator path verified and settled payments successfully again

fix: exclude .workspace from Docker build context (#304)
@bussyjd
bussyjd requested a review from OisinKyneApril 7, 2026 06:16
@bussyjd
bussyjd marked this pull request as ready for review April 7, 2026 09:17
@bussyjd
bussyjd merged commit 979623b into feat/monetize-pathApr 7, 2026
bussyjd added a commit that referenced this pull request Apr 8, 2026
…t agent skill
The monetize skill was replaced by a dedicated Go controller in #299.
Update the Decision and Consequences sections to reflect the actual
implementation.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bussyjd