Skip to content

Commit befce6d

Browse files
authored
fix(registry): exempt proven first releases from holdback (#3078)
* fix(registry): exempt proven first releases from holdback * fix(admin): preserve registry policy evidence * test(admin): cover release policy fallback
1 parent 27e432e commit befce6d

27 files changed

Lines changed: 688 additions & 36 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@emdash-cms/registry-lexicons": minor
3+
"@emdash-cms/registry-client": minor
4+
"@emdash-cms/admin": patch
5+
"emdash": patch
6+
---
7+
8+
Adds a fail-closed first-release exemption to the plugin registry's optional minimum release age policy. A package's first release can install immediately only when the aggregator reports exactly one retained release and confirms that it continuously observed the package's release history.
9+
10+
Existing packages, backfilled packages, and packages with missing or incomplete history remain subject to the configured holdback. Deleted releases still count, and explicit publisher or package exemptions continue to work.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Retain package-level release history evidence separately from the current
2+
-- profile/release projections. Existing rows came from an initial backfill or
3+
-- an earlier deployment whose cursor continuity cannot be proven, so they
4+
-- start incomplete and remain subject to the configured release-age holdback.
5+
CREATE TABLE IF NOT EXISTS package_release_history (
6+
did TEXT NOT NULL,
7+
package TEXT NOT NULL,
8+
release_history_complete INTEGER NOT NULL CHECK (release_history_complete IN (0, 1)),
9+
first_observed_at TEXT NOT NULL,
10+
first_observed_source TEXT NOT NULL CHECK (
11+
first_observed_source IN ('jetstream', 'backfill', 'unknown')
12+
),
13+
PRIMARY KEY (did, package)
14+
);
15+
16+
INSERT OR IGNORE INTO package_release_history (
17+
did,
18+
package,
19+
release_history_complete,
20+
first_observed_at,
21+
first_observed_source
22+
)
23+
SELECT
24+
did,
25+
slug,
26+
0,
27+
COALESCE(indexed_at, verified_at),
28+
'unknown'
29+
FROM packages;

apps/aggregator/src/backfill.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ async function paginateAndEnqueue(opts: PaginateOpts): Promise<number> {
395395
rkey: parsed.rkey,
396396
operation: "create",
397397
cid: record.cid,
398+
source: "backfill",
398399
},
399400
});
400401
}

apps/aggregator/src/env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export interface RecordsJob {
1313
rkey: string;
1414
operation: "create" | "update" | "delete";
1515
cid: string;
16+
/**
17+
* Identifies whether the aggregator observed this operation from its live,
18+
* cursor-backed stream or reconstructed current state through backfill.
19+
* Missing values come from an older producer during a rolling deployment
20+
* and must be treated as incomplete history.
21+
*/
22+
source?: "jetstream" | "backfill";
1623
/**
1724
* The Jetstream-supplied (unverified) record bytes. Compared against the
1825
* verified PDS copy after fetch as a Jetstream-correctness signal; the

apps/aggregator/src/jetstream-ingestor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export class JetstreamIngestor {
239239
rkey: event.commit.rkey,
240240
operation: event.commit.operation,
241241
cid: event.commit.operation === "delete" ? "" : event.commit.cid,
242+
source: "jetstream",
242243
...(event.commit.operation !== "delete" ? { jetstreamRecord: event.commit.record } : {}),
243244
};
244245

apps/aggregator/src/records-consumer.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -477,11 +477,25 @@ export async function ingestPackageProfile(
477477
updated_at = excluded.updated_at`,
478478
)
479479
.bind(job.did, slug, verified.cid, nowIso);
480+
const retainReleaseHistory = db
481+
.prepare(
482+
`INSERT INTO package_release_history
483+
(did, package, release_history_complete, first_observed_at, first_observed_source)
484+
VALUES (?, ?, ?, ?, ?)
485+
ON CONFLICT(did, package) DO NOTHING`,
486+
)
487+
.bind(
488+
job.did,
489+
slug,
490+
job.source === "jetstream" && job.operation === "create" ? 1 : 0,
491+
nowIso,
492+
job.source ?? "unknown",
493+
);
480494

481495
// The revision must exist before the current pointer moves. D1 batches are
482496
// transactional, so a failure leaves both the old pointer and old mutable
483497
// compatibility row intact.
484-
await db.batch([retainRevision, updateCurrentPackage, moveCurrentPointer]);
498+
await db.batch([retainRevision, updateCurrentPackage, retainReleaseHistory, moveCurrentPointer]);
485499
}
486500

487501
export async function ingestPackageRelease(
@@ -618,10 +632,21 @@ export async function ingestPackageRelease(
618632
// roll back together and the message retries to a clean state. Without
619633
// the batch, an insert-success / refresh-failure could leave
620634
// `packages.latest_version` permanently stale.
621-
const batchResults = await db.batch([
622-
insertStmt,
623-
refreshPackageLatestStmt(db, job.did, record.package),
624-
]);
635+
const batchStatements = [insertStmt, refreshPackageLatestStmt(db, job.did, record.package)];
636+
if (job.source !== "jetstream") {
637+
// A release first encountered outside the cursor-backed stream proves
638+
// that the aggregator cannot claim continuous history for this package.
639+
batchStatements.push(
640+
db
641+
.prepare(
642+
`UPDATE package_release_history
643+
SET release_history_complete = 0
644+
WHERE did = ? AND package = ?`,
645+
)
646+
.bind(job.did, record.package),
647+
);
648+
}
649+
const batchResults = await db.batch(batchStatements);
625650
const insertResult = batchResults[0];
626651
if (!insertResult) {
627652
// Defensive: D1.batch() guarantees one result per statement; if it
@@ -1053,14 +1078,29 @@ async function writeDeadLetter(
10531078
// envelope of operation+cid so the row is still inspectable.
10541079
const payload = JSON.stringify(job.jetstreamRecord ?? { operation: job.operation, cid: job.cid });
10551080
const payloadBytes = new TextEncoder().encode(payload);
1056-
await db
1081+
const retainDeadLetter = db
10571082
.prepare(
10581083
`INSERT INTO dead_letters
10591084
(did, collection, rkey, reason, detail, payload, received_at)
10601085
VALUES (?, ?, ?, ?, ?, ?, ?)`,
10611086
)
1062-
.bind(job.did, job.collection, job.rkey, reason, detail, payloadBytes, now.toISOString())
1063-
.run();
1087+
.bind(job.did, job.collection, job.rkey, reason, detail, payloadBytes, now.toISOString());
1088+
const releaseIdentity =
1089+
job.collection === NSID.packageRelease ? parseReleaseRkey(job.rkey) : null;
1090+
if (!releaseIdentity) {
1091+
await retainDeadLetter.run();
1092+
return;
1093+
}
1094+
const markHistoryIncomplete = db
1095+
.prepare(
1096+
`INSERT INTO package_release_history
1097+
(did, package, release_history_complete, first_observed_at, first_observed_source)
1098+
VALUES (?, ?, 0, ?, ?)
1099+
ON CONFLICT(did, package) DO UPDATE SET
1100+
release_history_complete = 0`,
1101+
)
1102+
.bind(job.did, releaseIdentity.pkg, now.toISOString(), job.source ?? "unknown");
1103+
await db.batch([retainDeadLetter, markHistoryIncomplete]);
10641104
}
10651105

10661106
// ─── Production wiring ─────────────────────────────────────────────────────

apps/aggregator/src/routes/xrpc/listing-query.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ import {
1313
} from "../../listing-policy.js";
1414
import { type PackageRow, packageColumns } from "./views.js";
1515

16+
const RELEASE_HISTORY_COLUMNS_SQL = `
17+
(SELECT COUNT(*)
18+
FROM releases release_history
19+
WHERE release_history.did = p.did
20+
AND release_history.package = p.slug) AS historical_release_count,
21+
COALESCE(
22+
(SELECT history.release_history_complete
23+
FROM package_release_history history
24+
WHERE history.did = p.did AND history.package = p.slug),
25+
0
26+
) AS release_history_complete
27+
`;
28+
1629
export type PackageLookupResult =
1730
| { state: "visible"; row: PackageRow }
1831
| { state: "unavailable" }
@@ -28,7 +41,7 @@ export async function lookupPackage(
2841
if (policy.mode === "projection") {
2942
const row = await session
3043
.prepare(
31-
`SELECT ${packageColumns("p.")}, p.labels_json
44+
`SELECT ${packageColumns("p.")}, p.labels_json, ${RELEASE_HISTORY_COLUMNS_SQL}
3245
FROM public_projection_state projection_state
3346
${ACTIVE_PROJECTION_JOINS_SQL}
3447
JOIN public_packages p ON p.generation = projection_state.active_generation
@@ -52,7 +65,7 @@ export async function lookupPackage(
5265

5366
const row = await session
5467
.prepare(
55-
`SELECT ${packageColumns("p.")}
68+
`SELECT ${packageColumns("p.")}, ${RELEASE_HISTORY_COLUMNS_SQL}
5669
FROM packages p
5770
WHERE p.did = ? AND p.slug = ?
5871
AND ${ACTIVE_PROFILE_SQL}

apps/aggregator/src/routes/xrpc/views.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface PackageRow {
4646
verified_at: string;
4747
indexed_at: string | null;
4848
labels_json?: string;
49+
historical_release_count?: number;
50+
release_history_complete?: number;
4951
}
5052

5153
/** Subset of columns from `releases` we read for `releaseView`. */
@@ -141,6 +143,12 @@ export function packageView(row: PackageRow): AggregatorDefs.PackageView {
141143
if (row.latest_version !== null) {
142144
view.latestVersion = row.latest_version;
143145
}
146+
if (row.historical_release_count !== undefined) {
147+
view.historicalReleaseCount = row.historical_release_count;
148+
}
149+
if (row.release_history_complete !== undefined) {
150+
view.releaseHistoryComplete = row.release_history_complete === 1;
151+
}
144152
return view;
145153
}
146154

apps/aggregator/test/backfill.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ describe("processBackfillJob", () => {
208208
rkey: "demo",
209209
operation: "create",
210210
cid: "bafyc1",
211+
source: "backfill",
211212
});
212213
// jetstreamRecord intentionally not set on backfill jobs — the
213214
// consumer's DLQ payload field would otherwise mislabel

apps/aggregator/test/jetstream-ingestor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ describe("JetstreamIngestor", () => {
126126
rkey: "p",
127127
operation: "create",
128128
cid: "bafyrecord",
129+
source: "jetstream",
129130
jetstreamRecord: { slug: "p", license: "MIT" },
130131
});
131132
expect(h.ingestor.currentCursor).toBe(event.time_us);
@@ -314,6 +315,7 @@ describe("JetstreamIngestor", () => {
314315
rkey: "p",
315316
operation: "delete",
316317
cid: "",
318+
source: "jetstream",
317319
});
318320
expect(h.queue.jobs[0]?.jetstreamRecord).toBeUndefined();
319321

0 commit comments

Comments
 (0)