feat(registry): labeler assessment persistence and automated issuance#1976
Conversation
Adds the labeler's assessment storage model — subjects, assessments with a CHECK-constrained lifecycle vocabulary, the current-assessment pointer, findings, evidence objects, and the ingest/dead-letter tables discovery will use — with CAS-guarded state transitions and a deterministic runKey so redelivered triggers converge on one run. Finalization is exposed as a statement builder whose pointer update re-checks the state CAS inside the same batch, letting orchestration compose assessment completion and label issuance into one atomic transaction. The issuance boundary gains an automated-assessment action whose proposals are validated from the ratified policy fixture (release-only, CID rules, blocking values only from critical automated-block finding categories); manual issuance behavior is unchanged and its suite passes unmodified.
…recency Adversarial review findings. Automated issuance now refuses to negate a label whose active stream event was issued by a manual action (§10: automation cannot retract an action-backed human label) — a defense-in- depth guard at the issuance chokepoint, independent of the orchestrator's own candidate set. The current-assessment pointer upsert gains a recency guard so an older run finalizing after a newer one cannot regress it. CID presence is asserted unconditionally on the automated path per §20.2 rather than only when a policy rule requires it, and the atomicity test now fails on a runtime constraint violation so it genuinely proves batch rollback.
|
Scope checkThis PR changes 2,421 lines across 14 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-playground | 85531f1 | Jul 12 2026, 12:48 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 85531f1 | Jul 12 2026, 12:48 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 85531f1 | Jul 12 2026, 12:48 PM |
There was a problem hiding this comment.
This is a well-scoped storage and state-machine foundation for the labeler assessment lifecycle. The migration is clean, SQL is parameterized throughout, the CAS transition pattern is sound, and the test suite covers the important atomicity, recency, and policy-legality cases. It aligns with the approved RFC and does not touch admin UI or published packages, so Lingui/RTL/changeset checks are not applicable.
I found two correctness issues that should be addressed:
-
Automated-path release-rule validation is fragile to duplicate subject rules.
validateAutomatedProposaluses.find()to pick the firstreleaserule, but the policy parser does not reject duplicatesubjectentries. A label with two release rules can be mis-evaluated. -
The §10 automated-negation guard is racy. It runs as a pre-batch
SELECT, so a manual label can be committed between the check and the subsequentdb.batch. For a true defense-in-depth guarantee, the guard needs to live inside the same atomicINSERTstatement.
Both are fixable in targeted ways. I also noted a small code-health suggestion about the duplicated assessment-id regex.
| throw new TypeError("automated labels must target a release record"); | ||
| if (proposal.cid === undefined) | ||
| throw new TypeError("automated labels must include a release CID"); | ||
| const rule = definition.subjectRules.find((candidate) => candidate.subject === "release"); |
There was a problem hiding this comment.
[needs fixing] The automated-path validation uses .find() to pick the first release rule, but parseLabelDefinition does not reject duplicate subject entries. A label definition such as:
"subjectRules": [
{ "subject": "release", "cidRule": "required", "issuanceModes": ["reviewer"] },
{ "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] }
]would be rejected for automated issuance even though the policy explicitly permits it.
| const rule = definition.subjectRules.find((candidate) => candidate.subject === "release"); | |
| const canAutomateRelease = definition.subjectRules.some( | |
| (candidate) => | |
| candidate.subject === "release" && candidate.issuanceModes.includes("automated"), | |
| ); | |
| if (!canAutomateRelease) | |
| throw new TypeError(`${proposal.val} cannot be issued through the automated path`); |
Even better, also reject duplicate subject entries in policy.ts so the policy document is unambiguous.
|
|
||
| if (action.type === "automated-assessment" && proposal.neg === true) { | ||
| await assertAutomatedNegationAllowed(db, signer.issuerDid, proposal); |
There was a problem hiding this comment.
[needs fixing] The §10 guard is a SELECT that runs before the statements are returned to the caller for db.batch. Because the actual INSERT commits later in a separate batch, a concurrent manual label can be inserted after the guard reads the stream but before the automated negation commits. That would let automation retract a manually-issued label despite the comment claiming this is "defense in depth independent of whatever candidate set an orchestrator computes."
To make the guarantee real, move the guard into the issued_labels INSERT ... SELECT WHERE clause (around line 176) so the check and insert are atomic:
| if (action.type === "automated-assessment" && proposal.neg === true) { | |
| await assertAutomatedNegationAllowed(db, signer.issuerDid, proposal); | |
| AND ( | |
| ? = 0 -- 1 only for automated negations | |
| OR NOT EXISTS ( | |
| SELECT 1 FROM issued_labels l2 | |
| JOIN issuance_actions a2 ON a2.id = l2.action_id | |
| WHERE l2.src = ? AND l2.uri = ? AND l2.val = ? | |
| AND l2.sequence = (SELECT MAX(sequence) FROM issued_labels WHERE src = ? AND uri = ? AND val = ?) | |
| AND l2.neg = 0 | |
| AND a2.type <> 'automated-assessment' | |
| ) | |
| ) |
Bind an isAutomatedNegation flag followed by the matching src, uri, and val parameters. Keeping the existing pre-check is fine for diagnostics, but only an in-batch condition closes the race.
| type AssessmentState, | ||
| } from "./assessment-lifecycle.js"; | ||
|
|
||
| const ASSESSMENT_ID = /^asmt_[0-9A-HJKMNP-TV-Z]{26}$/; |
There was a problem hiding this comment.
[suggestion] ASSESSMENT_ID is defined identically in assessment-store.ts (line 10) and service.ts (line 17). Export it from a shared module such as assessment-lifecycle.ts so the two stay in sync.
…le lookup Bot review. The §10 negation guard now runs as a condition inside the issuance-action INSERT, not only as a pre-batch SELECT, closing the read-then-write race with a concurrent manual issuance; gating the action insert (not the label insert) leaves no orphan action when the guard fires. Automated-path release-rule selection uses .some() over all release rules so a duplicate reviewer-only rule can't mask an automated one, and the policy parser now rejects duplicate subjects outright. The assessment id pattern is shared from assessment-lifecycle rather than duplicated.
|
All three findings addressed in 8b03d37: Racy §10 guard (needs fixing) — fixed. The negation-provenance check now runs as a condition inside the Fragile release-rule lookup (needs fixing) — fixed. Switched from Duplicated ASSESSMENT_ID (suggestion) — taken. Exported from 75 tests pass; typecheck and lint clean. |
There was a problem hiding this comment.
Re-review of the labeler assessment persistence PR. Both prior findings are addressed: duplicate subject entries within a label are now rejected at policy parse time (so the automated-path .some() check is unambiguous), and the §10 negation guard has been moved into the same atomic INSERT statement in the batch.
The migration is clean, the CAS lifecycle transitions are sound, the current_assessments recency guard correctly prevents older runs from regressing newer pointers, and the automated proposal validation stays derived from the ratified policy fixture. Admin/Lingui/RTL/changeset items are not applicable for this private Worker app.
I found one remaining correctness/diagnostics issue in the post-batch error path: when the in-batch §10 guard suppresses an automated negation, issueLabel misdiagnoses the missing row as a signing-state problem. The guard itself is solid, but the error surfaced to callers is wrong and will fire misleading signing alerts. The fix is a targeted re-check in postCommit.
| if (!issued) { | ||
| const status = await getSigningStatusIfInitialized(db); | ||
| if (!status) throw new Error("label issuance did not persist"); | ||
| if (!signingStatus) { | ||
| await recordSigningAlert(db, "SIGNING_STATE_CHANGED", { | ||
| activeKeyVersion: status.activeKeyVersion, | ||
| targetKeyVersion: config.signingKeyVersion, | ||
| rotationId: status.rotationId, | ||
| }); | ||
| throw new Error("signing state changed; retry label issuance"); | ||
| } | ||
| if (status.phase === "paused") { | ||
| await recordSigningAlert(db, "ISSUANCE_PAUSED", { | ||
| activeKeyVersion: status.activeKeyVersion, | ||
| targetKeyVersion: config.signingKeyVersion, | ||
| rotationId: status.rotationId, | ||
| }); | ||
| throw new Error("label issuance is paused"); | ||
| } | ||
| await recordSigningAlert(db, "STALE_SIGNING_KEY", { | ||
| activeKeyVersion: status.activeKeyVersion, | ||
| targetKeyVersion: config.signingKeyVersion, | ||
| rotationId: status.rotationId, | ||
| }); | ||
| throw new Error("label signing key version is stale"); |
There was a problem hiding this comment.
[needs fixing] The postCommit hook treats every missing issued_labels row as a signing-state failure. If the in-batch §10 automated-negation guard suppresses the issuance_actions INSERT because a manual label was committed between the pre-check and the batch, getIssuedLabel returns null and this codepath records STALE_SIGNING_KEY / SIGNING_STATE_CHANGED alerts and throws signing-related errors instead of the policy violation.
Re-check the §10 guard in the no-row path before diagnosing signing state. The existing race test calls db.batch() directly, so it does not exercise this misdiagnosis; adding an issueAutomatedAssessmentLabel-level test for the same interleaving would catch it.
| if (!issued) { | |
| const status = await getSigningStatusIfInitialized(db); | |
| if (!status) throw new Error("label issuance did not persist"); | |
| if (!signingStatus) { | |
| await recordSigningAlert(db, "SIGNING_STATE_CHANGED", { | |
| activeKeyVersion: status.activeKeyVersion, | |
| targetKeyVersion: config.signingKeyVersion, | |
| rotationId: status.rotationId, | |
| }); | |
| throw new Error("signing state changed; retry label issuance"); | |
| } | |
| if (status.phase === "paused") { | |
| await recordSigningAlert(db, "ISSUANCE_PAUSED", { | |
| activeKeyVersion: status.activeKeyVersion, | |
| targetKeyVersion: config.signingKeyVersion, | |
| rotationId: status.rotationId, | |
| }); | |
| throw new Error("label issuance is paused"); | |
| } | |
| await recordSigningAlert(db, "STALE_SIGNING_KEY", { | |
| activeKeyVersion: status.activeKeyVersion, | |
| targetKeyVersion: config.signingKeyVersion, | |
| rotationId: status.rotationId, | |
| }); | |
| throw new Error("label signing key version is stale"); | |
| if (!issued) { | |
| if ( | |
| action.type === "automated-assessment" && | |
| proposal.neg === true | |
| ) { | |
| await assertAutomatedNegationAllowed( | |
| db, | |
| signer.issuerDid, | |
| proposal as AutomatedLabelProposal, | |
| ); | |
| } | |
| const status = await getSigningStatusIfInitialized(db); |
…lert Bot re-review: when the in-batch §10 guard suppresses an automated negation, the missing issued_labels row was misdiagnosed in postCommit as a signing-state failure, firing misleading alerts. postCommit now re-runs the negation guard first so the caller sees the policy violation.
|
Fixed in 704b733. |
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. |
There was a problem hiding this comment.
This is a re-review of the labeler assessment persistence PR. The one prior emdashbot[bot] finding is addressed: when the in-batch §10 guard suppresses an automated negation, postCommit now re-runs assertAutomatedNegationAllowed before falling through to signing-state diagnosis, so callers see the policy violation rather than a misleading signing alert (verified by the new in-batch race test).
Overall the approach is sound and the implementation matches the described architecture:
- CAS-guarded state transitions return typed
AssessmentTransitionConflictErrors instead of silently no-op'ing. buildFinalizationStatementsre-checks the assessment state inside the same batch, and thecurrent_assessmentsupsert carries a recency guard that prevents older runs from regressing newer pointers.- The §10 automated-negation guard is placed on the action insert, so a lost race leaves no orphan action.
- Automated issuance validation is derived from the ratified policy fixture, with unconditional release-record + CID requirements and critical-only blocking findings.
- The public/private evidence boundary is enforced both at compile time (
never-typed fields) and at runtime.
Admin i18n/RTL/changesets remain n/a for this private Worker app.
I found one non-blocking suggestion: the §10 guard performs a MAX(sequence) lookup over (src, uri, val) on issued_labels without a covering index. On a live labeler the stream per subject/value can grow, so adding an index would keep the guard cheap. Otherwise the diff looks clean.
| WHERE l.src = ? AND l.uri = ? AND l.val = ? | ||
| ORDER BY l.sequence DESC | ||
| LIMIT 1`, | ||
| ) |
There was a problem hiding this comment.
[suggestion] The §10 guard looks up the latest label for a (src, uri, val) stream with ORDER BY l.sequence DESC LIMIT 1 and a subquery SELECT MAX(sequence) FROM issued_labels WHERE src = ? AND uri = ? AND val = ?. The existing indexes on issued_labels are (sequence), (uri, sequence), and (src, sequence); none cover (src, uri, val, sequence). As the label stream grows, these lookups will scan or filter many rows per automated negation.
Add a covering index, either in 0003_assessment_lifecycle.sql (migrations can create indexes on pre-existing tables) or by updating the prior migration if this branch is still pre-release. For example:
CREATE INDEX idx_issued_labels_stream_max
ON issued_labels(src, uri, val, sequence);This single index serves both the outer guard filter and the MAX(sequence) subquery used in assertAutomatedNegationAllowed and the in-batch condition.
Bot suggestion: the automated-negation guard's latest-label lookup per (src, uri, val) had no covering index. Add one so it stays cheap as a subject's label stream grows.
|
Added the covering index in 9684daf — |
There was a problem hiding this comment.
This is a re-review of the assessment persistence PR. The prior emdashbot[bot] finding is addressed: postCommit now re-runs assertAutomatedNegationAllowed when the in-batch §10 guard suppresses an automated negation, and a test covers the lost-race case. The performance suggestion from the prior review is also implemented (idx_issued_labels_stream).
The overall approach remains sound: the migration adds the lifecycle tables with epoch-ms siblings, the CAS-guarded transitions return typed conflicts, buildFinalizationStatements keeps the state CAS and pointer update in one batch with a recency guard, and automated issuance validation is derived from the ratified policy fixture.
I did not run the test suite, linter, or builds (read-only static review). I found one remaining correctness issue: validateAutomatedProposal returns early for negations, bypassing the release-record and mandatory-CID checks that the PR description says are unconditional on the automated path. The fix is to move the if (proposal.neg === true) return; after those checks while still skipping the finding-category/severity requirement for negations. A test for this case should be added.
No AGENTS.md conventions are violated for this private Worker app: there is no admin UI, no published-package changeset is needed, and all SQL uses parameterised D1 statements.
| function validateAutomatedProposal(proposal: AutomatedLabelProposal): void { | ||
| const definition = getLabelDefinition(proposal.val); | ||
| if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`); | ||
| const record = REGISTRY_RECORD.exec(proposal.uri); | ||
| if (!record || !record[2]!.endsWith(".release")) | ||
| throw new TypeError("automated labels must target a release record"); | ||
| if (proposal.cid === undefined) | ||
| throw new TypeError("automated labels must include a release CID"); | ||
| const canAutomateRelease = definition.subjectRules.some( | ||
| (candidate) => candidate.subject === "release" && candidate.issuanceModes.includes("automated"), | ||
| ); | ||
| if (!canAutomateRelease) | ||
| throw new TypeError(`${proposal.val} cannot be issued through the automated path`); | ||
| if (proposal.neg === true) return; | ||
| if (definition.category === "automated-block") { | ||
| if (proposal.findingCategory === undefined) | ||
| throw new TypeError(`${proposal.val} requires a finding category`); | ||
| const findingDefinition = getLabelDefinition(proposal.findingCategory); | ||
| if (!findingDefinition || findingDefinition.category !== "automated-block") | ||
| throw new TypeError("finding category must be an allowed security/impersonation category"); | ||
| if (proposal.severity !== "critical") | ||
| throw new TypeError(`${proposal.val} requires a critical finding severity`); | ||
| } | ||
| } |
There was a problem hiding this comment.
[needs fixing] validateAutomatedProposal exits early on proposal.neg === true before checking that the target is a release record and that a CID is present. Because every automated label in the ratified fixture has subjectRules for a release with cidRule: "required", and the PR description states that CID is "unconditional on the automated path per §20.2", an automated negation can currently omit the CID or target a non-release subject.
Move the negation early-return after the release-record and CID gates; keep the finding-category/severity block skipped for negations. Add a test that an automated negation without a CID is rejected.
| function validateAutomatedProposal(proposal: AutomatedLabelProposal): void { | |
| const definition = getLabelDefinition(proposal.val); | |
| if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`); | |
| const record = REGISTRY_RECORD.exec(proposal.uri); | |
| if (!record || !record[2]!.endsWith(".release")) | |
| throw new TypeError("automated labels must target a release record"); | |
| if (proposal.cid === undefined) | |
| throw new TypeError("automated labels must include a release CID"); | |
| const canAutomateRelease = definition.subjectRules.some( | |
| (candidate) => candidate.subject === "release" && candidate.issuanceModes.includes("automated"), | |
| ); | |
| if (!canAutomateRelease) | |
| throw new TypeError(`${proposal.val} cannot be issued through the automated path`); | |
| if (proposal.neg === true) return; | |
| if (definition.category === "automated-block") { | |
| if (proposal.findingCategory === undefined) | |
| throw new TypeError(`${proposal.val} requires a finding category`); | |
| const findingDefinition = getLabelDefinition(proposal.findingCategory); | |
| if (!findingDefinition || findingDefinition.category !== "automated-block") | |
| throw new TypeError("finding category must be an allowed security/impersonation category"); | |
| if (proposal.severity !== "critical") | |
| throw new TypeError(`${proposal.val} requires a critical finding severity`); | |
| } | |
| } | |
| function validateAutomatedProposal(proposal: AutomatedLabelProposal): void { | |
| const definition = getLabelDefinition(proposal.val); | |
| if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`); | |
| const record = REGISTRY_RECORD.exec(proposal.uri); | |
| if (!record || !record[2]!.endsWith(".release")) | |
| throw new TypeError("automated labels must target a release record"); | |
| if (proposal.cid === undefined) | |
| throw new TypeError("automated labels must include a release CID"); | |
| const canAutomateRelease = definition.subjectRules.some( | |
| (candidate) => candidate.subject === "release" && candidate.issuanceModes.includes("automated"), | |
| ); | |
| if (!canAutomateRelease) | |
| throw new TypeError(`${proposal.val} cannot be issued through the automated path`); | |
| if (proposal.neg === true) return; | |
| if (definition.category === "automated-block") { | |
| if (proposal.findingCategory === undefined) | |
| throw new TypeError(`${proposal.val} requires a finding category`); | |
| const findingDefinition = getLabelDefinition(proposal.findingCategory); | |
| if (!findingDefinition || findingDefinition.category !== "automated-block") | |
| throw new TypeError("finding category must be an allowed security/impersonation category"); | |
| if (proposal.severity !== "critical") | |
| throw new TypeError(`${proposal.val} requires a critical finding severity`); | |
| } | |
| } |
The release-record and mandatory-CID checks already precede the negation early-return; only finding-category/severity is skipped. Lock that in.
|
I don't think this one holds — the release-record and CID checks already run before the negation early-return. Current
So a negation already gets the release-record and mandatory-CID checks; only the finding requirements (which don't apply to negations) are skipped — which is the intended behavior your suggested fix describes. Added a test in 85531f1 that proves it: a negation with a publisher-DID URI rejects with "must target a release record", and a negation missing a CID rejects with "must include a release CID". If you were reading an earlier revision where the CID check sat after the early-return, this is already resolved. |
There was a problem hiding this comment.
Re-review of the assessment persistence and automated-issuance PR.
The previous review’s findings are both addressed in the current revision:
-
postCommit§10 guard: when the in-batch automated-negation condition suppresses the action insert,postCommitnow re-runsassertAutomatedNegationAllowedso the caller gets the policy violation instead of a misleading signing-state error. A dedicated race test verifies both the insert suppression and the error surfaced bypostCommit. -
validateAutomatedProposalnegation ordering: theif (proposal.neg === true) return;guard is now positioned after the release-record, mandatory-CID, and release-automated-mode checks, while still skipping the finding-category/severity requirement for negations. A test explicitly confirms that negations are still rejected for non-release subjects and missing CIDs.
The overall approach remains sound for this private Worker app: the new migration adds subjects, assessments, current_assessments, findings, evidence_objects, and the ahead-of-time dead_letters/ingest_state tables; compared timestamps carry epoch-ms siblings; CAS-guarded transitions return typed AssessmentTransitionConflictErrors; buildFinalizationStatements keeps the state CAS and recency-guarded pointer update in one batch; shared buildIssuanceStatements backs both manual and automated paths; and the §10 automated-negation guard is enforced both pre-check and in-batch.
I did not run the test suite, linter, or builds (read-only static review). No AGENTS.md conventions are violated for apps/labeler: there is no admin UI, no published-package changeset is required, and all SQL uses parameterised D1 statements.
No remaining blocking or suggestion-level findings.
81acff9
into
feat/plugin-registry-labelling-service
What does this PR do?
Adds the labeler service's assessment persistence layer and automated label issuance — plan items W2.2–W2.5 plus the issuance-boundary extension the assessment orchestrator (a follow-up PR) needs. This is the storage and state-machine foundation; nothing in production issues an automated label yet (no code path drives a run to completion until discovery/orchestration lands).
subjects,assessments(state CHECK-constrained to the ten-state lifecycle vocabulary),current_assessments,findings,evidence_objects, plus thedead_lettersandingest_statetables the discovery PR will write to. Timestamp columns that get compared carry epoch-millisecond siblings so ordering never depends on RFC 3339 string comparison.issuance_actionsgains a nullableassessment_id(rather than a parallelactionstable) linking a label to the assessment that produced it.signing-rotationidiom — a lost race is a typed conflict, never a silent no-op), a deterministicrunKeyper spec §9.2 so a redelivered trigger converges on one run, andbuildFinalizationStatementswhose pointer update re-checks the state CAS inside the same batch, letting the orchestrator compose assessment completion and label issuance into one atomic transaction. The pointer update also carries a recency guard so a late-committing older run cannot regress a newer run's current pointer.automated-assessmentaction whose proposals are validated from the ratified policy fixture (release-only subject, mandatory CID, blocking values only from critical automated-block finding categories, manual-only values unreachable). One sharedbuildIssuanceStatementsbacks both manual and automated paths — manual issuance behavior is byte-identical and its existing suite passes unmodified. Automation is additionally forbidden from negating a label whose active stream event was issued manually (§10), enforced at the issuance chokepoint.Adversarial review (opus) findings all addressed pre-open: the §10 automated-negation guard (was reachable, now blocked + tested), the pointer-recency regression (guarded + tested with an older-commits-last ordering), unconditional CID on the automated path per §20.2, and a strengthened atomicity test that fails on a runtime constraint violation rather than a missing table.
Deferred by design (noted so reviewers don't flag them): the
notificationstable is W10 (nothing writes to it yet);assessment_idlives onissuance_actionsand is recovered by join rather than duplicated ontoissued_labelsper the spec DDL (a deliberate normalization).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 UI, not on npm).AI-generated code disclosure
Screenshots / test output
pnpm --filter @emdash-cms/labeler test: 73 pass (29 pre-existing manual-issuance tests unmodified; 44 new across migration, lifecycle/CAS-race, finalization atomicity, supersession/recency, automated-issuance legality, evidence boundary, policy parse)pnpm lint:quick0 diagnostics; type-aware lint clean on touched filesTry 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-assessment-persistence. Updated automatically when the playground redeploys.