feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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
 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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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 > 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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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

feat(monetize): replace pause annotation with ERC-8004-friendly drain - #535

Closed
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause
Closed

feat(monetize): replace pause annotation with ERC-8004-friendly drain#535
bussyjd wants to merge 2 commits into
mainfrom
feat/drain-replaces-pause

Conversation

@bussyjd

@bussyjdbussyjd commented May 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Two things were broken about the legacy "pause" path:

  1. Pause was a route on/off switch masquerading as "pause your business."
    obol.org/paused: "true" made the controller delete the HTTPRoute
    immediately. From a remote x402 buyer's perspective that's
    indistinguishable from a crash — and ERC-8004 reputation scorers
    that watch a seller's /.well-known/agent-registration.json see an
    abrupt disappearance with no advertised wind-down.
  2. obol sell stop was broken. It patched status.conditions[Ready]=False,
    which the controller overwrote on the next reconcile. The CLI looked
    like it worked; in practice the offer stayed live.

Design

Replace pause with a real drain:

  • New spec fields advertise the wind-down via discovery before the
    route disappears, so external observers can react gracefully.
  • The HTTPRoute + payment gate stay up during the grace window so
    buyers can complete in-flight payments.
  • After the grace period expires, the controller tears down the route
    and marks Draining=False reason=Drained. The CR stays — obol sell delete is still the canonical removal command.

Pure-additive wire shape

Drain is purely additive in the catalog. Active offers serialize
identically to pre-drain releases: no new fields, no shape change. The
only new wire surface is drainEndsAt, which is set on draining offers
only. Consumers detect drain with:

if(entry.drainEndsAt){/* draining; migrate before this time */}

There is no available field. Presence of drainEndsAt is the signal.
This was an explicit design review outcome (commit dd89750): a
separate boolean was redundant and would have been a schema-breaking
change for strict consumers. Now there is zero schema breakage.

API

ServiceOfferSpec:

FieldTypeDefaultBehavior
drainAt*metav1.TimenilWhen set, offer is draining.
drainGracePeriod*metav1.Duration1hHow long after drainAt the route stays up. 0s tears down on the next reconcile.

ServiceOffer helpers: IsDraining(), DrainEndsAt() time.Time,
DrainExpired(now time.Time) bool.

CLI:

obol sell stop <name> -n <ns> # default: drainAt=now, 1h grace
obol sell stop <name> -n <ns> --grace 30m # custom grace
obol sell stop <name> -n <ns> --force # alias: --now; zero grace, abrupt teardown

Discovery surfaces:

  • /api/services.json: draining entries gain a single drainEndsAt: <RFC3339>
    key. Active entries serialize unchanged.
  • /skill.md: per-service detail block adds a - **Drain ends at**:
    bullet only for draining offers. The table gains a Status column
    (active: , draining: draining · ends <RFC3339>).

Drain lifecycle

sequenceDiagram
autonumber
participant Op as Operator
participant CR as ServiceOffer CR
participant Ctl as serviceoffer-controller
participant Disc as /skill.md +<br/>/.well-known/agent-registration.json
participant Route as HTTPRoute + x402 Middleware
participant Buyer as Remote buyer
Op->>CR: obol sell stop my-svc<br/>(patch spec.drainAt=now,<br/>drainGracePeriod=1h)
CR-->>Ctl: Update event
Ctl->>Ctl: IsDraining=true,<br/>DrainExpired=false
Ctl->>Disc: emit drainEndsAt=T+1h<br/>(no `available` field)
Ctl->>Route: KEEP UP
Ctl->>CR: Draining=True reason=Draining
Ctl->>Ctl: AddAfter(T+1h)
Buyer->>Disc: poll catalog
Disc-->>Buyer: drainEndsAt set → migrate
Buyer->>Route: in-flight paid request
Route-->>Buyer: 200 OK
Note over Ctl: ...grace period elapses...
Ctl->>Ctl: DrainExpired=true
Ctl->>Route: deleteRouteChildren()
Ctl->>CR: Draining=False reason=Drained,<br/>PaymentGateReady=False,<br/>RoutePublished=False
Op->>CR: obol sell delete (later, canonical removal)
Loading

Why ERC-8004 reputation matters

ERC-8004 makes seller reputation an on-chain signal that buyers and
discovery agents can score. An abrupt route teardown looks identical to
a process crash or upstream outage — a negative reputation event.
Advertising a planned wind-down (drainEndsAt) lets buyers and scorers
distinguish "this seller is gracefully retiring this offer" from
"this seller's infrastructure is unreliable." Even short grace windows
(a few minutes) move the signal from "outage" to "planned maintenance."

Migration

If you were setting obol.org/paused: "true" directly, the annotation
no longer has any effect. To match the old abrupt-teardown semantics:

obol sell stop <name> -n <ns> --force

For the recommended graceful behavior, drop --force and let buyers
see the wind-down via discovery.

Test plan

  • go build ./...
  • go test ./internal/monetizeapi/... ./internal/serviceoffercontroller/... ./internal/x402/... ./cmd/obol/... ./internal/schemas/...
  • Unit: ServiceOffer.IsDraining, DrainEndsAt, DrainExpired (nil, mid-drain, expired, --force zero-grace)
  • Render: pre-drain (no drainEndsAt, no available), mid-drain (only drainEndsAt), drain-expired (filtered from catalog)
  • Render: per-service /skill.md detail block carries no Available bullet on active offers; only draining offers get a Drain-ends-at bullet
  • x402 verifier source: drain-expired offer skipped from RouteRules; mid-drain offer kept
  • CLI: obol sell stop has --grace (default 1h) and --force (alias --now)
  • Pure-additivity invariant on raw JSON: active entries have NO available or drainEndsAt keys
  • Manual: obol sell stop my-svc -n llm → confirm /api/services.json entry gains a drainEndsAt, /skill.md shows the drain banner, paid requests still 200 OK
  • Manual: wait grace, confirm kubectl get httproute -n llm no longer shows the offer's route
  • Manual: obol sell stop my-svc -n llm --force → confirm route disappears on the next reconcile

bussyjd added 2 commits May 24, 2026 12:49
The legacy obol.org/paused annotation tore down HTTPRoutes immediately,
which is indistinguishable from a crash to remote x402 buyers and ERC-8004
reputation scorers. obol sell stop was also broken: it patched
status.conditions which the controller immediately overwrote.
This replaces both with a real drain:
- New ServiceOffer spec.drainAt (date-time) + spec.drainGracePeriod
(duration; default 1h) mark an offer as winding down.
- While draining, /skill.md and /.well-known/agent-registration.json
advertise the offer with available=false and drainEndsAt set, so
external discovery can react before traffic disappears.
- The HTTPRoute + payment gate stay up until DrainEndsAt, letting
in-flight buyers complete payments.
- After the grace period, the controller tears down the route, sets
Draining=False reason=Drained, and leaves the CR (delete is the
canonical removal command).
obol sell stop sets spec.drainAt, supports --grace <duration> and
--force/--now (zero grace = abrupt teardown for behavior parity with
the old annotation).
…e drain signal
Design review concluded the `available` boolean was redundant — the
presence of `drainEndsAt` is sufficient to signal drain state. This
makes the drain wire shape purely additive: active offers serialize
identically to pre-drain releases.
Wire changes:
- ServiceCatalogEntry.Available field removed.
- DrainEndsAt is the only drain signal. Consumers detect drain with
`if (entry.drainEndsAt) { /* draining */ }`.
- /skill.md detail block: no Available bullet on active offers; only
draining offers get a "Drain ends at" bullet.
- /skill.md table column renamed Available → Status; active rows show
"—", draining rows show "draining · ends <RFC3339>".
JSON Schema: `available` removed from required and from properties;
`drainEndsAt` description updated to "Presence = draining."
Tests updated to assert active entries carry NO `available` or
`drainEndsAt` keys in the raw JSON, and the markdown detail block for
active offers contains no Available line.
@bussyjd

Copy link
Copy Markdown
ContributorAuthor

Superseded by bundle PR #536 — closing in favor of the consolidated merge target. Original branch and history preserved.

@bussyjdbussyjd closed this May 24, 2026
bussyjd added a commit that referenced this pull request May 24, 2026
feat: x402 marketplace + architecture review bundle (#513-#535)
OisinKyne pushed a commit that referenced this pull request May 25, 2026
…e drain signal (re-amend of #535)
drain becomes purely additive — active offers serialize identically to
pre-drain main. The only new wire field is `drainEndsAt`, set on draining
offers only. Consumers detect drain with `if (entry.drainEndsAt) { /* draining */ }`.
No schema-breaking change for any consumer that was reading the catalog
before drain landed.
This re-ships an amendment that was originally pushed as commit dd89750 on
`feat/drain-replaces-pause` for PR #535. The amendment didn't survive the
bundle PR #536's merge into main, so the controller is shipping the
un-amended `Available bool` shape today.
- ServiceCatalogEntry: remove `Available bool`; keep `DrainEndsAt string omitempty`
- service-catalog.schema.json: drop `available` from `required` + `properties`
- buildServiceCatalogJSON: stop setting Available; only set DrainEndsAt on drain
- buildSkillCatalogMarkdown: rename `Available` table column to `Status` (active
rows show `—`; draining rows show `draining · ends <RFC3339>`). Drop the
per-service `- **Available**:` bullet entirely; draining services keep only
the `- **Drain ends at**:` bullet.
- serviceDefWithDrain: stop setting the (already-additive) `Available *bool`
on erc8004.ServiceDef during drain; signal via DrainEndsAt only.
- Tests:
- TestBuildServiceCatalogJSON_ExcludesNonReady: replace
`services[0].Available == true` with raw-JSON map walk asserting
`available` and `drainEndsAt` keys are absent on active entries.
- TestBuildServiceCatalogJSON_DrainLifecycle: rewrite to raw-map walk;
assert active entries have neither `available` nor `drainEndsAt`, mid-drain
entries have only `drainEndsAt` (no `available`).
- TestBuildRegistration{,Identity}Services_IncludesDrainMetadata: replace
`svc.Available == &false` checks with `svc.Available == nil` (DrainEndsAt
is now the sole drain marker).
- Add TestBuildSkillCatalogMarkdown_DrainAdditiveDetail: asserts no
`- **Available**:` bullet appears for any offer, that draining offers
keep their `- **Drain ends at**:` bullet, and that the table header
uses `Status` not `Available`.
@OisinKyne
OisinKyne deleted the feat/drain-replaces-pause branch July 1, 2026 12:33
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