Skip to content

feat(labeler): aggregator service binding + read-only client (W8.4 slice-3 foundation)#2070

Merged
ascorbic merged 4 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-aggregator-binding
Jul 16, 2026
Merged

feat(labeler): aggregator service binding + read-only client (W8.4 slice-3 foundation)#2070
ascorbic merged 4 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-aggregator-binding

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds the read-only labeler→aggregator service binding and a typed client — the foundation the ratified W8.4 slice-3 read path (and W10.4 slice A contact resolution) build on. No consumer wiring yet; this lands the mechanism.

The aggregator is an HTTP/XRPC-only module Worker (no RPC entrypoint), so consumption is fetch-over-binding, matching the repo's one existing service-binding example (infra/perf-monitor). AggregatorClient wraps the AGGREGATOR Fetcher with three methods over the existing XRPC reads — getPackage, getLatestRelease, listReleases — typed against @emdash-cms/registry-lexicons view types. NSIDs are fixed per-method constants; only query-param values carry caller data and every value is encodeURIComponent-encoded, so no caller data can reach the host/path. One fetch per call, no caching (honors W10.4's fresh-read-at-notification rule). XRPC NotFoundnull; any other non-2xx or a transport failure throws (callers treat a throw as transient).

Moderation-semantics note (flagged for maintainer): the client sends a blank atproto-accept-labelers header to obtain a genuinely unfiltered view. This is deliberate and counter-intuitive: omitting the header makes the aggregator apply its default trusted-labeler redaction, so a redacted/taken-down subject returns NotFoundnull and silently blinds the labeler's own analysis to the subjects it most needs to see. The labeler is the moderation authority performing analysis, so it must read unfiltered. The re-exposure risk (an unfiltered read reaching a public surface) is bounded at the serving layer, not here — see the ratified sync.getRecord / artifact-mirror serving decisions. This was caught by an adversarial review pass (the original code omitted the header under a comment claiming the opposite).

Part of the plugin-registry labelling service (RFC #694, umbrella #1909). Targets the integration branch.

Type of change

Covered by the approved RFC #694 umbrella.

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (zero diagnostics in changed files; pre-existing baseline unchanged)
  • pnpm test passes (aggregator-client 11/11; full labeler suite green)
  • pnpm format has been run (touched paths; worktree path is gitignored so local oxfmt skips it — CI format-checks the branch checkout)
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a, no UI
  • I have added a changeset (if this PR changes a published package) — n/a, @emdash-cms/labeler is unpublished (private: true)
  • New features link to an approved Discussion — RFC RFC: Decentralized Plugin Registry #694

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (implementation and adversarial review)

Screenshots / test output

vitest run test/aggregator-client.test.ts → 11/11 pass (URL/param encoding per method, typed parse, NotFound→null, 5xx and transport-failure throw, and the blank-header assertion). A note for the consumer-wiring slices: verify via a real-binding integration test that the blank header survives the service-binding transport and yields the unfiltered view — if empty-valued headers are dropped in transport, escalate to an explicit aggregator internal-read affordance.


Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks feat/labeler-aggregator-binding. Updated automatically when the playground redeploys.

ascorbic added 2 commits July 16, 2026 11:52
Add the AGGREGATOR service binding to the labeler Worker and a typed,
read-only client over the aggregator's XRPC surface (getPackage,
getLatestRelease, listReleases). Fetch-over-binding matching the
perf-monitor pattern; the aggregator stays HTTP/XRPC-only with no RPC
entrypoint.

Each method does exactly one fetch per call (no caching/retry/memoization,
per W10.4), sends no atproto-accept-labelers header so internal reads see
the unfiltered view, and URL-encodes every param value against fixed-constant
NSIDs. XRPC NotFound resolves to null; other non-2xx and transport failures
throw.

Stub the binding in vitest.config.ts so the workerd pool can start.
…red aggregator reads

Omitting the header applies the aggregator's default trusted-labeler redaction, so a redacted subject presents as NotFound/null — silently blinding the labeler's own analysis to the subjects it most needs to see. Send a blank value to opt out. Documents the analysis-only boundary and the checksum re-verification downstream.
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f135583

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet size/XL labels Jul 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 924 lines across 5 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground f135583 Jul 16 2026, 11:53 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache f135583 Jul 16 2026, 11:53 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do f135583 Jul 16 2026, 11:54 AM

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR lands a small, well-scoped foundation: a read-only AggregatorClient for the labeler that reaches the aggregator over a Cloudflare service binding, plus the binding declaration, generated types, and a stub for tests. The approach mirrors the existing infra/perf-monitor pattern and the code is careful about fixed NSIDs, URL-encoding all caller-supplied query values, mapping NotFound to null, and throwing everything else.

The blank-atproto-accept-labelers design is also correctly reasoned: the aggregator’s request-policy.ts treats an absent header as “use the deployment’s default trusted-labeler redaction” and an empty header as “no accepted labelers → no redaction,” so the labeler must send "" to avoid being blinded to redacted subjects. That logic is protected by existing aggregator tests, but only through SELF.fetch (an in-process Request), not the actual binding transport. This is the main open correctness gap.

I checked the changed files, the aggregator read/policy handlers, the registry-lexicons types, the test coverage, and the generated-type diff. pnpm typecheck/pnpm test are claimed clean and I have no static reason to doubt them. I do not think this should block merge, but it should not move to consumer wiring until the binding-level header survival is proven.

Headline: solid foundation, but the critical transport property is still assumed, not demonstrated.

Comment on lines +119 to +123
/** One fetch, no retry. `NotFound` → `null`; any other non-2xx or a
* transport failure throws. */
async #query<T>(nsid: string, url: string): Promise<T | null> {
const response = await this.#fetcher.fetch(url, {
headers: { [ACCEPT_LABELERS_HEADER]: "" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] The unfiltered-read strategy depends on an empty-valued atproto-accept-labelers header surviving the service-binding transport. The aggregator’s own tests prove the parser handles an empty header correctly, but they use SELF.fetch, which is an in-process Request. They do not prove the header is still present after the labeler calls env.AGGREGATOR.fetch(url, { headers: { [ACCEPT_LABELERS_HEADER]: "" } }).

If Cloudflare’s service-binding transport drops empty-valued headers, the aggregator sees an absent header, falls back to its default trusted-labeler redaction, and redacted/taken-down subjects return NotFound → null. The labeler would then be silently blinded to exactly the subjects it most needs to see.

Add a workerd-level test in apps/labeler/test that exercises the real env.AGGREGATOR binding (or a serviceBindings stub in vitest.config.ts) and asserts the inbound Request carries atproto-accept-labelers: "".

Comment on lines +125 to +126
if (response.ok) {
return response.json<T>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The client casts the response body directly to the registry-lexicons type via response.json<T>() without runtime validation. The module doc explains this away because the aggregator is a first-party binding and downstream code verifies artifact checksums, but the view shape itself (field presence, cursors, etc.) is never checked at the trust boundary. A malformed or unexpectedly-shaped response could propagate into labeler analysis code before any checksum verification runs.

Consider validating against the generated output schema before returning, at least for the fresh-read path:

const raw = await response.json();
const view = AggregatorGetPackage.mainSchema.output.schema.parse(raw);
return view as AggregatorDefs.PackageView;

This would turn aggregator contract violations into explicit, catchable parse errors at the client boundary.

*/

import type { AggregatorDefs, AggregatorListReleases } from "@emdash-cms/registry-lexicons";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] ACCEPT_LABELERS_HEADER is defined locally here and also locally in apps/aggregator/src/routes/xrpc/request-policy.ts. Since @emdash-cms/registry-moderation is already a dependency of the labeler app and owns the labeler-header parsing logic, consider exporting the constant from packages/registry-moderation/src/labeler-headers.ts and importing it in both places so the two sides of the binding cannot drift out of sync.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 16, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 16, 2026
@ascorbic

ascorbic commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review:

  • [needs fixing] binding-transport header survival — added a workerd-level test (AGGREGATOR binding transport) that calls the real env.AGGREGATOR service binding with a blank atproto-accept-labelers header and asserts it arrives as "" (the vitest stub echoes the inbound value as a marker, so a dropped empty header would read absent, not empty). This proves the unfiltered-read contract holds across the workerd binding transport, not just that the client sets the header. The stub still fails loud (501) for any accidental call.

Declining the two suggestions, with rationale:

  • Runtime response validation — the client intentionally treats the aggregator as a first-party, in-process binding and re-verifies artifact bytes by checksum downstream; the module doc states this trust assumption. Adding .parse() at the boundary couples the client to lexicon-schema internals and adds per-call cost without defending against the only actor that could return a schema-valid-but-wrong view (a compromised first-party aggregator). Better revisited if a specific consumer needs strict shape guarantees than baked into the foundation.
  • Sharing ACCEPT_LABELERS_HEADER from registry-moderation — it's a fixed ATProto protocol string with negligible drift risk; the shared-export refactor would touch a published package (+ changeset) and the aggregator's request-policy.ts, which is out of scope for this read-only foundation. Noted as a reasonable future tidy-up.

~ 🤖 Claude Fable 5

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approach judgment: this is the right foundation for the slice. The labeler needs a first-party, read-only path to the aggregator, and a fetch-over-service-binding client is the idiomatic Cloudflare pattern (mirroring infra/perf-monitor). The deliberately blank atproto-accept-labelers header is correctly reasoned: the aggregator's request-policy.ts treats an absent header as "use the deployment's default trusted-labeler redaction" and an empty header as "no accepted labelers → no redaction", so the labeler must send "" to avoid being blinded to redacted subjects. The code is careful about fixed NSIDs, URL-encoding every caller-supplied value, mapping only NotFoundnull, and throwing everything else.

What's improved since the prior review: the PR now adds an AGGREGATOR service-binding stub in vitest.config.ts and a binding-transport test that proves miniflare preserves the empty-valued header. That closes the local-runtime gap.

Remaining concerns (non-blocking for this foundation):

  1. The binding-transport test still only exercises miniflare's stub, not the actual workerd service-binding hop. The PR description already flags this as a follow-up for consumer wiring, but the unfiltered-read contract truly depends on the header surviving production transport.
  2. Regenerating worker-configuration.d.ts for the single new AGGREGATOR binding pulled in a large amount of unrelated workerd runtime type churn, making the diff noisier than necessary.
  3. listReleases passes an empty cursor string through as cursor=, which the aggregator will decode as a malformed cursor and reject with 400.

Overall: solid, scoped foundation. Don't move to consumer wiring without a real-binding integration test that proves the blank header survives the service-binding hop in workerd.

Comment on lines +33 to +42
serviceBindings: {
AGGREGATOR: (request) => {
const raw = request.headers.get("atproto-accept-labelers");
const marker = raw === null ? "absent" : raw === "" ? "empty" : `value:${raw}`;
return new Response("AGGREGATOR is stubbed in tests", {
status: 501,
headers: { "x-test-accept-labelers": marker },
});
},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The isolated AGGREGATOR stub and the AGGREGATOR binding transport test confirm that miniflare preserves an empty-valued atproto-accept-labelers header, but they don't exercise the actual workerd service-binding hop. The PR description already flags the follow-up ("verify via a real-binding integration test that the blank header survives the service-binding transport"), and it remains a correctness assumption: if the production binding drops or normalizes empty-valued headers, the aggregator falls back to default redaction and the labeler is silently blinded to the redacted subjects it most needs to see. Non-blocking for this foundation, but make sure a real-binding/wrangler integration test lands before this client is wired into any consumer that depends on the unfiltered view.

Comment thread apps/labeler/src/aggregator-client.ts Outdated
cursor?: string,
): Promise<AggregatorListReleases.$output | null> {
const params: Record<string, string> = { did, package: pkg };
if (cursor !== undefined) params["cursor"] = cursor;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] listReleases only omits cursor when the argument is undefined; an empty string is still passed through as cursor=. The aggregator's decodeListCursor treats a provided empty value as malformed and returns 400 InvalidRequest (only an absent cursor means "first page"). Consider treating an empty cursor the same as no cursor:

Suggested change
if (cursor !== undefined) params["cursor"] = cursor;
if (cursor) params["cursor"] = cursor;

Or, if callers are expected to only pass opaque aggregator cursors, document that in the method's JSDoc.

// Generated by Wrangler by running `wrangler types` (hash: ccfd2a4d903b4c76107d4323cc123869)
// Runtime types generated with workerd@1.20260611.1 2026-02-24 nodejs_compat
// Generated by Wrangler by running `wrangler types` (hash: e21c438219f0e3a8c154bb09b450524b)
// Runtime types generated with workerd@1.20260708.1 2026-02-24 nodejs_compat

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] Regenerating this file for the single new AGGREGATOR binding pulled in a lot of unrelated workerd runtime type churn (new jurisdiction hints, image-transformation field reordering, ExecutionContext/Workflow changes, etc.). The only semantic addition is AGGREGATOR: Fetcher. If the new runtime types are required by a wrangler/version bump in the base branch, that's fine, but consider keeping runtime type updates in a separate dependency/tooling PR so this foundation diff stays focused on the binding and client.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 16, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 16, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approach judgment: This is the right foundation for the W8.4 slice and W10.4 read path. The aggregator is HTTP/XRPC-only, so a fetch-over-service-binding client is the idiomatic EmDash/Cloudflare pattern. The deliberately blank atproto-accept-labelers header is correctly reasoned: the aggregator's request-policy.ts resolves a missing header to the deployment's default trusted-labeler policy (redact: true), while parseAcceptLabelersHeader("") returns an empty accepted set, which filterAvailable keeps empty and which label-enforcement.ts treats as a no-op, so only a blank value gives the labeler the genuinely unfiltered view it needs for moderation analysis. The re-exposure risk is bounded at the serving layer, consistent with the ratified sync.getRecord decisions.

What's improved since the prior review: The empty-cursor bug is fixed (if (cursor) now prevents sending cursor= to listReleases), and the PR adds a miniflare serviceBindings stub plus a binding-transport test that proves an empty-valued header survives at least the local runtime hop.

What I checked: I read the new client and tests, the vitest and wrangler config changes, the generated type file diff, and the aggregator's request-policy.ts / label-enforcement.ts to verify the blank-header semantics. I confirmed the package is private so no changeset is required. No SQL, UI, localization, or public API surface is involved.

Headline conclusion: Clean, scoped foundation. No blocking issues. I do have two non-blocking observations to keep on record:

  1. Production transport caveat remains: the binding-transport test still exercises miniflare only; the real concern is whether workerd drops empty-valued headers on an actual service-binding hop. You call this out already as a follow-up for the consumer-wiring slice, and it remains the right place to prove the contract with a real-binding integration test.
  2. Generated-type churn: regenerating worker-configuration.d.ts for one binding pulled in a lot of unrelated workerd runtime churn. See the anchored suggestion.

Recommended: ship this slice, but don't move to consumer wiring without the real-binding integration test that proves the blank header survives in workerd.

NOTIFICATION_HASH_PEPPER: string;
LABEL_SUBSCRIPTION: DurableObjectNamespace<import("./src/index").LabelSubscriptionDO>;
LABELER_DISCOVERY_DO: DurableObjectNamespace<import("./src/index").LabelerDiscoveryDO>;
AGGREGATOR: Fetcher /* emdash-aggregator */;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The only intentional change in this generated file is the new AGGREGATOR binding, but the regeneration also pulled in unrelated workerd runtime type churn (ExecutionContext.tracing accessor change, new CloudflareAccessContext, extra DO jurisdictions/location hints, DurableObjectFacets.clone, container exec types, etc.).

This makes the diff noisier than necessary and blurs scope discipline. If feasible, regenerate against the same workerd version the integration branch already uses, or commit only the AGGREGATOR binding line plus the header hash update; revert the rest so the diff stays focused on this slice.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 16, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 16, 2026
@ascorbic ascorbic merged commit 201cdc0 into feat/plugin-registry-labelling-service Jul 16, 2026
13 checks passed
@ascorbic ascorbic deleted the feat/labeler-aggregator-binding branch July 16, 2026 11:56
@ascorbic ascorbic mentioned this pull request Jul 16, 2026
81 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed review/approved Approved; no new commits since size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant