feat(registry): public assessment XRPC API#1989
Conversation
|
Scope checkThis PR changes 2,445 lines across 12 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. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 640bee5 | Jul 12 2026, 07:43 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 640bee5 | Jul 12 2026, 07:43 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 640bee5 | Jul 12 2026, 07:43 PM |
There was a problem hiding this comment.
This is the right PR for W10’s public-query half: it adds four unauthenticated, cacheable com.emdashcms.experimental.labeler.* XRPC queries through @atcute/xrpc-server, with a clean public/private serializer split, keyset pagination, and CORS. The implementation closely follows the aggregator’s router idiom and the gated contracts.
I read the changed labeler source, the relevant lexicon schemas, the gate-0 contracts under .opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md, and the new test files. Two real issues surfaced:
-
getActiveLabelStateselects the winner from CID-filtered rows only, which conflicts with the ratified stream semantics. The contracts say the current-state key is(src, uri, val)(CID is not part of the key), and that stream reduction must include all CID variants; CID applicability is checked only after the winner is selected, and a newer event at a different CID must be able to retract an older URI-wide event. The current query(cid IS NULL OR cid = ?)drops mismatched CID events before reduction, so it can both restore older events and miss cross-CID negations. -
Cursor decoding does not reject a structurally valid cursor with a non-ISO
createdAt. The PR explicitly promises that any mangled cursor returnsInvalidCursor. A tampered base64url cursor like{ v: 1, createdAt: "not-a-date", id: "x", filterHash: "…" }passes version and filter-hash checks and reaches pagination withDate.parse("not-a-date")→NaN. This does not triggerInvalidCursorError.
Both are fixable in a handful of lines. The tests pass, but note that label-state.test.ts currently encodes the CID-stream behavior described in (1), so it will need to be flipped once the code is corrected.
| const rows = await db | ||
| .prepare( | ||
| `SELECT val, neg, cid, cts, exp, sequence | ||
| FROM issued_labels | ||
| WHERE src = ? AND uri = ? AND (cid IS NULL OR cid = ?) | ||
| ORDER BY sequence ASC`, | ||
| ) | ||
| .bind(input.src, input.uri, input.cid) | ||
| .all<{ | ||
| val: string; | ||
| neg: number; | ||
| cid: string | null; | ||
| cts: string; | ||
| exp: string | null; | ||
| sequence: number; | ||
| }>(); | ||
| const winners = new Map<string, LabelStreamWinner>(); | ||
| for (const row of rows.results ?? []) { | ||
| const active = row.neg === 0 && (row.exp === null || Date.parse(row.exp) > now.getTime()); | ||
| winners.set(row.val, { | ||
| val: row.val, | ||
| cid: row.cid, | ||
| cts: row.cts, | ||
| exp: row.exp, | ||
| sequence: row.sequence, | ||
| active, | ||
| }); | ||
| } | ||
| return winners; |
There was a problem hiding this comment.
[needs fixing] The active-label winner is chosen from a CID-filtered stream, which violates the gate-0 contract for current label state.
Contracts §“Current label state” says the key is (src, uri, val) and “CID is not part of the stream key.” Stream reduction must run over every event for that (src, uri, val), and only the selected winner is then checked for CID applicability (§“Subject and CID applicability”). The current query (cid IS NULL OR cid = ?) drops mismatched-CID events before selecting the winner, so it can both incorrectly revive an older event when a newer event targets a different CID, and miss a newer CID-specific negation that should retract an older URI-wide positive.
| const rows = await db | |
| .prepare( | |
| `SELECT val, neg, cid, cts, exp, sequence | |
| FROM issued_labels | |
| WHERE src = ? AND uri = ? AND (cid IS NULL OR cid = ?) | |
| ORDER BY sequence ASC`, | |
| ) | |
| .bind(input.src, input.uri, input.cid) | |
| .all<{ | |
| val: string; | |
| neg: number; | |
| cid: string | null; | |
| cts: string; | |
| exp: string | null; | |
| sequence: number; | |
| }>(); | |
| const winners = new Map<string, LabelStreamWinner>(); | |
| for (const row of rows.results ?? []) { | |
| const active = row.neg === 0 && (row.exp === null || Date.parse(row.exp) > now.getTime()); | |
| winners.set(row.val, { | |
| val: row.val, | |
| cid: row.cid, | |
| cts: row.cts, | |
| exp: row.exp, | |
| sequence: row.sequence, | |
| active, | |
| }); | |
| } | |
| return winners; | |
| const rows = await db | |
| .prepare( | |
| `SELECT val, neg, cid, cts, exp, sequence | |
| FROM issued_labels | |
| WHERE src = ? AND uri = ? | |
| ORDER BY sequence ASC`, | |
| ) | |
| .bind(input.src, input.uri) | |
| .all<{ | |
| val: string; | |
| neg: number; | |
| cid: string | null; | |
| cts: string; | |
| exp: string | null; | |
| sequence: number; | |
| }>(); | |
| const winners = new Map<string, LabelStreamWinner>(); | |
| for (const row of rows.results ?? []) { | |
| const active = | |
| row.neg === 0 && | |
| (row.cid === null || row.cid === input.cid) && | |
| (row.exp === null || Date.parse(row.exp) > now.getTime()); | |
| winners.set(row.val, { | |
| val: row.val, | |
| cid: row.cid, | |
| cts: row.cts, | |
| exp: row.exp, | |
| sequence: row.sequence, | |
| active, | |
| }); | |
| } | |
| return winners; |
This also means the test “excludes a CID-mismatched event from the queried stream even at a higher sequence” in label-state.test.ts is encoding a contract deviation and will need to be updated.
| if (!isPlainObject(parsed)) throw new InvalidCursorError("cursor must decode to a JSON object"); | ||
| if (parsed.v !== CURSOR_VERSION) throw new InvalidCursorError("unsupported cursor version"); | ||
| const { createdAt, id, filterHash } = parsed; | ||
| if (typeof createdAt !== "string" || typeof id !== "string" || typeof filterHash !== "string") |
There was a problem hiding this comment.
[needs fixing] decodeCursor promises that any mangled cursor returns InvalidCursorError, but it accepts a structurally valid JSON payload whose createdAt is not a parseable timestamp.
A cursor such as { v: 1, createdAt: "not-a-date", id: "x", filterHash: "…" } passes version and filter-hash checks and reaches getAssessmentsPage, where Date.parse("not-a-date") returns NaN. That does not trigger the guaranteed InvalidCursor response.
| if (typeof createdAt !== "string" || typeof id !== "string" || typeof filterHash !== "string") | |
| if ( | |
| typeof createdAt !== "string" || | |
| Number.isNaN(Date.parse(createdAt)) || | |
| typeof id !== "string" || | |
| typeof filterHash !== "string" | |
| ) | |
| throw new InvalidCursorError("cursor is missing required fields or has an invalid createdAt"); |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
fac1c30 to
a2ce879
Compare
There was a problem hiding this comment.
This is the right PR for W10’s public-query half and the two real issues from the prior review are now fixed: getActiveLabelState reduces the full (src, uri, val) stream and applies CID applicability after winner selection, and decodeCursor rejects structurally-valid cursors with non-parseable createdAt via Number.isFinite(Date.parse(...)). The tests have been updated to cover both behaviours.
However, two new concerns surfaced on this pass:
- The public lexicon no longer matches the ratified gate-0 contract. This PR removes
coverage.dependenciesandpublicAssessment.scannerVersionsfrompackages/registry-lexicons/lexicons/com/emdashcms/experimental/labeler/defs.json(and the code/serializer), but.opencode/plans/plugin-registry-labelling-service/gate-0/contracts.mdstill lists both as required (lines 69/73 and 88/96). That is both a contract conformance gap and a breaking change to the published@emdash-cms/registry-lexiconspackage (v0.1.1) without a version bump or a changeset that calls out the break. - Module-scope singleton convention.
apps/labeler/src/xrpc-router.tsuses a plain module-levellet cachedRouterfor the per-isolate router cache. AGENTS.md requires module-scope singletons to live onglobalThisvia aSymbol.forkey.
Everything else looks solid: the public/private serializer split, keyset pagination, CORS, cache-control handling, batched per-page label lookup, and the extensive test matrix are all in good shape.
| "coverage": { | ||
| "type": "object", | ||
| "required": ["code", "metadata", "images", "dependencies"], | ||
| "required": ["code", "metadata", "images"], |
There was a problem hiding this comment.
[needs fixing] The #coverage shape no longer includes dependencies, but .opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md lines 69/73 still declare coverage.dependencies as a required field. Removing a required property from this published lexicon is a breaking change to @emdash-cms/registry-lexicons without a changeset or version bump. Restore the field, or update the contract and add a changeset that explicitly calls out the break.
| "required": ["code", "metadata", "images"], | |
| "required": ["code", "metadata", "images", "dependencies"], | |
| "properties": { | |
| "code": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, | |
| "metadata": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, | |
| "images": { | |
| "type": "string", | |
| "knownValues": ["complete", "not-present", "partial", "unavailable"] | |
| }, | |
| "dependencies": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] } | |
| } |
| @@ -79,7 +70,6 @@ | |||
| "labels", | |||
| "policyVersion", | |||
There was a problem hiding this comment.
[needs fixing] The publicAssessment required list and properties no longer include scannerVersions, yet gate-0/contracts.md lines 88/96 still require it. This contradicts the ratified contract and is a breaking change to the published @emdash-cms/registry-lexicons package without a changeset/version bump. Either restore scannerVersions (and serializer support) or update the contract and ship a changeset that calls out the removal.
| "publicAssessment": { | |
| "type": "object", | |
| "required": [ | |
| "id", | |
| "src", | |
| "subject", | |
| "state", | |
| "summary", | |
| "coverage", | |
| "labels", | |
| "policyVersion", | |
| "assessmentSchemaVersion", | |
| "scannerVersions", | |
| "createdAt", | |
| "reconsiderationUrl" | |
| ], | |
| "properties": { | |
| "id": { "type": "string", "minLength": 1, "maxLength": 64 }, | |
| "src": { "type": "string", "format": "did" }, | |
| "subject": { "type": "ref", "ref": "#assessmentSubject" }, | |
| "artifact": { "type": "ref", "ref": "#artifact" }, | |
| "state": { | |
| "type": "string", | |
| "knownValues": ["pending", "passed", "warned", "blocked", "error", "superseded"] | |
| }, | |
| "summary": { "type": "string", "minLength": 1, "maxLength": 4096 }, | |
| "coverage": { "type": "ref", "ref": "#coverage" }, | |
| "labels": { | |
| "type": "array", | |
| "maxLength": 64, | |
| "items": { "type": "ref", "ref": "#labelSummary" } | |
| }, | |
| "policyVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, | |
| "assessmentSchemaVersion": { "type": "integer", "minimum": 1 }, | |
| "model": { "type": "ref", "ref": "#model" }, | |
| "scannerVersions": { | |
| "type": "array", | |
| "maxLength": 64, | |
| "items": { "type": "ref", "ref": "#scannerVersion" } | |
| }, |
| let cachedRouter: XRPCRouter | null = null; | ||
| let cachedEnvRef: Env | null = null; | ||
| function getRouter(env: Env, config: LabelerConfig): XRPCRouter { | ||
| if (cachedRouter && cachedEnvRef === env) return cachedRouter; | ||
| cachedRouter = createRouter(env, config); | ||
| cachedEnvRef = env; | ||
| return cachedRouter; |
There was a problem hiding this comment.
[needs fixing] AGENTS.md requires module-scope singletons to live on globalThis (via a Symbol.for key) so they survive Vite SSR module duplication. The router is only used in a Worker today, but the repo convention is explicit. Move the cache to globalThis keyed by a symbol instead of using module-level lets.
| let cachedRouter: XRPCRouter | null = null; | |
| let cachedEnvRef: Env | null = null; | |
| function getRouter(env: Env, config: LabelerConfig): XRPCRouter { | |
| if (cachedRouter && cachedEnvRef === env) return cachedRouter; | |
| cachedRouter = createRouter(env, config); | |
| cachedEnvRef = env; | |
| return cachedRouter; | |
| const ROUTER_CACHE = Symbol.for("emdash:labeler:xrpc-router"); | |
| type RouterCache = { router: XRPCRouter; env: Env } | undefined; | |
| function getRouter(env: Env, config: LabelerConfig): XRPCRouter { | |
| const cache = (globalThis as Record<symbol, RouterCache>)[ROUTER_CACHE]; | |
| if (cache && cache.env === env) return cache.router; | |
| const router = createRouter(env, config); | |
| (globalThis as Record<symbol, RouterCache>)[ROUTER_CACHE] = { router, env }; | |
| return router; | |
| } |
a2ce879 to
640bee5
Compare
|
Thanks — both prior findings confirmed fixed. On the two new concerns: 1(a) — contracts.md vs lexicon mismatch: real, now resolved. 1(b) — 'breaking change to published v0.1.1': this is not a breaking change to anything published. The 2 — module-scope singleton: applied. Green on 640bee5: labeler 190/14, typecheck clean, lint 19 (0 in scope). |
There was a problem hiding this comment.
This is the right PR for W10’s public-query half and the two real implementation issues from the prior review are fixed. After re-reading the ratified contract and tracing the code, the lexicon shape is now contract-conformant, and the module-scope router cache follows the globalThis/Symbol.for convention.
What I checked:
- Lexicon vs. contract.
.opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md(ratified 2026-07-10) definescoveragewith onlycode,metadata, andimages, andpublicAssessmenthas noscannerVersions. The PR’spackages/registry-lexicons/lexicons/com/emdashcms/experimental/labeler/defs.jsonmatches that shape, so the prior mismatch concern was based on a misread of the contract, not a code defect. - Module-scope singleton.
apps/labeler/src/xrpc-router.tsnow caches the router onglobalThisusingSymbol.for("emdash:labeler-xrpc-router")and keys the cache onenvidentity. The previous plainlet cachedRouteris gone. - Cursor safety.
decodeCursorrejects structurally valid cursors with non-parseablecreatedAtviaNumber.isFinite(Date.parse(...)), and the cursor filter-hash bindsuri/cid/state. - Active-label reduction.
getActiveLabelStatereduces per(src, uri, val)by monotonic sequence and applies CID applicability after winner selection, exactly as the contract and prior review required. - Serializer boundary, supersession, batching, CORS/cache control, SQL parameterization, indexes. All look solid; the public/private split is enforced by both runtime shape and a compile-time type assertion, and the store’s per-page label lookup is batched.
Headline conclusion: no blocking correctness or security issues. The only remaining item is process: @emdash-cms/registry-lexicons is a published package and this PR changes its public lexicon shape, so the pending changeset should reflect that before release. I’d still merge once that’s addressed.
| "coverage": { | ||
| "type": "object", | ||
| "required": ["code", "metadata", "images", "dependencies"], | ||
| "required": ["code", "metadata", "images"], |
There was a problem hiding this comment.
[suggestion] The public lexicon shape changed in this PR (coverage.dependencies and publicAssessment.scannerVersions removed). While the result matches the ratified gate-0 contract, @emdash-cms/registry-lexicons is a published package, and the PR checklist says a changeset is required when a published package changes. The existing pending changeset (.changeset/lucky-labelers-contract.md) only says it “adds” the experimental contracts; it should also call out this shape change so the release notes are accurate.
| "required": ["code", "metadata", "images"], | |
| --- | |
| "@emdash-cms/registry-lexicons": patch | |
| --- | |
| Adds experimental labeler assessment and policy query contracts. | |
| The public assessment and coverage shapes have been finalised against the ratified gate-0 contract: `coverage.dependencies` and `publicAssessment.scannerVersions` are no longer part of the experimental surface. |
|
On the remaining process item: the changeset is already in place. Worth being precise about the shape claim: that namespace is net-new — it isn't in the published 0.1.1, and With that clarified I believe the only open item is resolved. Re-triggering for a final pass. |
There was a problem hiding this comment.
This is the right PR for W10's public-query half, and the two implementation issues from the prior review are genuinely fixed: the per-page label lookup is now batched via getLabelsForAssessments, and the router is cached on globalThis with a Symbol.for key instead of a module-scope let. The supersession derivation, active-label reduction, cursor safety, serializer boundary, and SQL parameterization all remain solid.
However, one item from the prior review is still unaddressed: the registry-lexicons shape change has no adequate changeset. @emdash-cms/registry-lexicons is a published npm package, and this PR removes a required coverage field (dependencies) and the entire scannerVersions array/type from the public publicAssessment contract. The existing .changeset/lucky-labelers-contract.md is a patch-level entry that only says "Adds experimental labeler assessment and policy query contracts" — it doesn't mention that public fields were removed or that the package's contract shape changed.
Per AGENTS.md, breaking changes to published packages need a changeset that clearly calls out the break (and a minor bump while pre-1.0). I can't verify whether 0.1.1 has already shipped, but the changeset should describe the released shape as it stands, including the removals.
I'll sign off once the changeset is updated or a new one is added to cover the lexicon contract break.
| "coverage": { | ||
| "type": "object", | ||
| "required": ["code", "metadata", "images", "dependencies"], | ||
| "required": ["code", "metadata", "images"], | ||
| "properties": { | ||
| "code": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, | ||
| "metadata": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, | ||
| "images": { | ||
| "type": "string", | ||
| "knownValues": ["complete", "not-present", "partial", "unavailable"] | ||
| }, | ||
| "dependencies": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] } | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
[needs fixing] This PR removes dependencies from the coverage required set (line 34) and drops the scannerVersions array/scannerVersion type from publicAssessment (lines 72–95 area), but no changeset describes that public-contract break.
@emdash-cms/registry-lexicons is a published package. AGENTS.md requires breaking changes to published packages to have a changeset that clearly calls out the break. The existing .changeset/lucky-labelers-contract.md only says "Adds experimental labeler assessment and policy query contracts" and is scoped as a patch, so it does not communicate that two public fields were removed from the ratified shape.
| "coverage": { | |
| "type": "object", | |
| "required": ["code", "metadata", "images", "dependencies"], | |
| "required": ["code", "metadata", "images"], | |
| "properties": { | |
| "code": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, | |
| "metadata": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] }, | |
| "images": { | |
| "type": "string", | |
| "knownValues": ["complete", "not-present", "partial", "unavailable"] | |
| }, | |
| "dependencies": { "type": "string", "knownValues": ["complete", "partial", "unavailable"] } | |
| } | |
| } | |
| }, | |
| # Add or update a changeset such as: | |
| --- | |
| "@emdash-cms/registry-lexicons": minor | |
| --- | |
| Updates the experimental labeler public-assessment contract: removes the `dependencies` field from `coverage` and the `scannerVersions` array (and its `scannerVersion` type) from `publicAssessment`. |
82c1aa9
into
feat/plugin-registry-labelling-service
What does this PR do?
Adds the labeler's public assessment read API — plan workstream W10's public-query half. Four unauthenticated
com.emdashcms.experimental.labeler.*XRPC queries, served through@atcute/xrpc-server's typedXRPCRouter(the same idiom the aggregator already runs):getAssessment— one immutable public assessment byid.getCurrentAssessment— effective state for an exact(uri, cid):current,pending,activeLabels, and (reserved)override.listAssessments— keyset-paged public assessments under boundedsrc/uri/cid/statefilters.getPolicy— the machine-readablelabelerPolicydocument (the ratified fixture).The generated lexicon bindings already existed and are frozen; the only new dependency is
@atcute/xrpc-server(the labeler already gained@atcute/lexiconsand@emdash-cms/registry-lexiconsvia #1978).index.tsintercepts the atproto label NSIDs (queryLabels/subscribeLabels/createReport) and the.well-knownpaths exactly as before, then falls through to the typed router for the four new queries.Key design points, all against the gate-0 contracts (
.opencode/plans/plugin-registry-labelling-service/gate-0/contracts.md) and RFC #694:public-assessment.ts) —toPublicAssessmentis the sole constructor of the public view; no internal column (trigger, run key, prompt provenance, private findings) has a structural path to the wire. Malformedcoverage_json/scanner_versions_jsondegrade to a valid safe shape rather than 500-ing.observed/verifying/stale/cancelledare non-public (NotFound/ excluded).supersededis derived strictly by the ratified back-pointer rule (contracts §96): a row is superseded only when a newer completed assessment names it viasupersedes_assessment_idand owns the current pointer — never merely for not being current.getActiveLabelState) — one shared helper backs bothlabels[].activeandgetCurrentAssessment.activeLabels: winner per(src, uri, cid-applicable, val)by monotonic sequence, active iff not negated, not expired, and CID-applicable.(created_at, id)DESC, exclusive; a cursor is emitted only when a further row exists; changed filters, unknown version, or a mangled cursor all returnInvalidCursor(never a silent page one). The cursor's filter-hash bindsuri/cid/state.getAssessment/getCurrentAssessment/listAssessmentsarepublic, max-age=60;getPolicyispublic, max-age=300; router-generated errors areno-store. CORS*(public, credential-free reads).Reviewed before opening
Design + contract review plus an independent adversarial (opus) pass. The adversarial pass found no correctness bug above LOW; every high-risk area (superseded direction, active-label winner, cursor keyset, serializer leaks, SQL parameterization, router regression) was checked and cleared. Addressed pre-open: an N+1 in
listAssessments(per-row label fetch → one batchedWHERE assessment_id IN (…)), and a stray\x00cache-key delimiter that made the router file an unreviewable git-binary. ThegetActiveLabelStateval-grouping invariant is now documented.Two design notes for the maintainer (no code change — both contract-conformant)
supersededis derivation-ready but not yet driven end-to-end. The public API surfacesstate: "superseded"correctly whensupersedes_assessment_idis populated, but the assessment orchestrator (feat(registry): labeler discovery and assessment orchestration #1978, test-only behind the production boundary) does not yet populate that column on a superseding finalize. So no live path produces asupersededview until W7/W8 wires real issuance. Flagging in case the supersession link should land alongside this — I'd treat it as W7/W8 scope, consistent with the orchestrator shipping test-only.(uri,cid)re-assessment chain (a←b←d), oncedowns the pointer,bshowssupersededbut the oldestareverts to its raw decision state (e.g.passed), because onlybnamesaandbis no longer current. This matches the ratified contract exactly; flagging only because it's a surprising public-facing behavior worth a conscious sign-off. If you'd prefer "any non-current completed assessment with a current successor shows superseded," that's a contract change touching both the spec and this derivation — say the word and I'll open it as a follow-up.The notifications / contact-resolution half of W10 is deferred pending a separate decision;
getCurrentAssessment.overrideis reserved (always absent) until manual-action storage lands.Targets
feat/plugin-registry-labelling-service. Related to #1909 and RFC #694.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.Admin i18n and changeset are n/a —
apps/labeleris a private, unpublished Worker app (no admin UI, not on npm). Server-side error messages are English-only per the repo convention.AI-generated code disclosure
Screenshots / test output
pnpm --filter @emdash-cms/labeler test: 190 pass, 14 files (60 net-new for this PR across the router HTTP matrix, the label-state winner unit tests, the serializer boundary + compile-time leak assertion, the cursor/keyset paths, and the batched-label grouping proof)pnpm lint:jsondiagnostics unchanged at 19 (all pre-existing, zero inapps/labeler)listAssessmentsfull-page label queries: batched (1 chunkedIN (…)), not per-rowTry 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-public-api. Updated automatically when the playground redeploys.