Skip to content

Commit dbc22fd

Browse files
d-csclaude
andcommitted
feat(webapp): bound the active mint list against the configured shard descriptor keys
computeMintShard now intersects the active shard set with routableKeys (the RUN_OPS_SHARDS descriptor keys), so a stored key with no descriptor is never minted into and falls back to gen-1. The empty-set check runs first, so an unconfigured deployment is unchanged. Inert until the gen-2 write path wires in resolveMintShard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f908f14 commit dbc22fd

3 files changed

Lines changed: 53 additions & 1 deletion

File tree

apps/webapp/app/v3/runOpsMigration/mintShardAssignment.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,37 @@ describe("computeMintShard — the global override wins the complete cutover", (
473473
);
474474
});
475475
});
476+
477+
describe("routableKeys bound (the shard descriptor keys this deployment can route)", () => {
478+
it("drops an active key that is not routable, so the hash never returns it", () => {
479+
// "z" is in the active list but not configured as a descriptor -> only "a" is selectable.
480+
const ids = envIds(200);
481+
for (const id of ids) {
482+
const shard = computeMintShard({ id }, deps({ set: ["a", "z"] }, { routableKeys: ["a"] }));
483+
expect(shard).toBe("a");
484+
}
485+
});
486+
487+
it("returns new when the active list holds only non-routable keys (fail-safe to gen-1)", () => {
488+
expect(
489+
computeMintShard({ id: "env_1" }, deps({ set: ["z"] }, { routableKeys: ["a"] }))
490+
).toBe("new");
491+
});
492+
493+
it("rejects a per-org pin to a non-routable key and falls through to the hash", () => {
494+
const shard = computeMintShard(
495+
{ id: "env_1" },
496+
deps({ set: ["a", "z"] }, { ...orgFlags({ runOpsMintShard: "z" }), routableKeys: ["a"] })
497+
);
498+
expect(shard).toBe("a");
499+
});
500+
501+
it("with no routableKeys given, behaviour is unchanged", () => {
502+
const ids = envIds(200);
503+
for (const id of ids) {
504+
const withBound = computeMintShard({ id }, deps({ set: ["a", "b"] }, { routableKeys: ["a", "b"] }));
505+
const without = computeMintShard({ id }, deps({ set: ["a", "b"] }));
506+
expect(withBound).toBe(without);
507+
}
508+
});
509+
});

apps/webapp/app/v3/runOpsMigration/mintShardAssignment.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export type MintShardDeps = {
1919
nowMs: number;
2020
graceMs: number;
2121
orgFeatureFlags: unknown;
22+
// The shard keys this deployment can actually route (the RUN_OPS_SHARDS descriptor keys). The
23+
// active set is bounded to these, so a stored key with no descriptor is never minted into.
24+
// Undefined means "no bound" (today's behaviour).
25+
routableKeys?: readonly string[];
2226
onPinRejected?: (info: { environmentId: string; pin: string; activeSet: string[] }) => void;
2327
onOverrideRejected?: (info: { override: string; activeSet: string[] }) => void;
2428
};
@@ -94,7 +98,17 @@ function hrwSelect(environmentId: string, activeSet: string[]): string {
9498
// would leak the drain the active list performs, and throwing would fail customer triggers
9599
// whenever a pinned shard drains.
96100
export function computeMintShard(environment: { id: string }, deps: MintShardDeps): ShardKey {
97-
const activeSet = effectiveMintShardSet(deps.resolution, deps.nowMs, deps.graceMs);
101+
const rawActiveSet = effectiveMintShardSet(deps.resolution, deps.nowMs, deps.graceMs);
102+
// Empty check BEFORE the bound, so an unconfigured deployment returns "new" exactly as today.
103+
if (rawActiveSet.length === 0) {
104+
return "new";
105+
}
106+
107+
// Bound the active set to the keys this deployment can route. A stored key with no descriptor is
108+
// dropped, never minted into. If nothing survives, fall back to gen-1 (fail-safe, never a throw).
109+
const activeSet = deps.routableKeys
110+
? rawActiveSet.filter((key) => deps.routableKeys!.includes(key))
111+
: rawActiveSet;
98112
if (activeSet.length === 0) {
99113
return "new";
100114
}
@@ -148,6 +162,7 @@ export type ResolveMintShardDeps = {
148162
ttlMs: number;
149163
graceMs: number;
150164
orgFeatureFlags: unknown;
165+
routableKeys?: readonly string[];
151166
onPinRejected?: (info: { environmentId: string; pin: string; activeSet: string[] }) => void;
152167
onOverrideRejected?: (info: { override: string; activeSet: string[] }) => void;
153168
onReadFailed?: (error: unknown) => void;
@@ -200,6 +215,7 @@ export async function resolveMintShardWith(
200215
nowMs: deps.nowMs,
201216
graceMs: deps.graceMs,
202217
orgFeatureFlags: deps.orgFeatureFlags,
218+
routableKeys: deps.routableKeys,
203219
onPinRejected: deps.onPinRejected,
204220
onOverrideRejected: deps.onOverrideRejected,
205221
});

apps/webapp/app/v3/runOpsMigration/runOpsMintShard.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export async function resolveMintShard(environment: {
8484
ttlMs: env.RUN_OPS_MINT_FLAG_CACHE_TTL_MS,
8585
graceMs: env.RUN_OPS_MINT_FLIP_GRACE_MS,
8686
orgFeatureFlags: environment.orgFeatureFlags,
87+
// Bound the active list to the shards this deployment can actually route.
88+
routableKeys: env.RUN_OPS_SHARDS.map((shard) => shard.key),
8789
onPinRejected: reportPinRejected,
8890
onOverrideRejected: reportOverrideRejected,
8991
onReadFailed: (error) =>

0 commit comments

Comments
 (0)