Skip to content

Commit 432e488

Browse files
committed
Derive dead-grant expired verdicts at read time instead of repairing rows
1 parent 4f2c97d commit 432e488

2 files changed

Lines changed: 103 additions & 41 deletions

File tree

packages/core/sdk/src/connections.test.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,9 +1453,9 @@ describe("agent read revalidation (coreTools connections.list)", () => {
14531453
}),
14541454
);
14551455

1456-
it.effect("check now repairs a dead-grant verdict buried by a racing writer", () =>
1456+
it.effect("a buried dead-grant verdict presents expired on every read, without a write", () =>
14571457
Effect.gen(function* () {
1458-
const { executor, counters, stamp, persisted } = yield* makeHealthHarness();
1458+
const { executor, counters, stamp, persisted, rawRow } = yield* makeHealthHarness();
14591459
const detail = "invalid_grant: Grant not found";
14601460
// A racing writer's verdict landed after the recorder's: the row reads
14611461
// "degraded" while provider_state still records the dead grant.
@@ -1467,9 +1467,11 @@ describe("agent read revalidation (coreTools connections.list)", () => {
14671467
detail: "Tool sync failing: upstream rejected the credential",
14681468
},
14691469
});
1470+
const before = yield* rawRow();
14701471

1471-
// Still no probe — the dead grant refuses those — but the served
1472-
// verdict is the authoritative expired one, not the buried degraded.
1472+
// Check now: still no probe — the dead grant refuses those — and the
1473+
// served verdict is the authoritative expired one, not the buried
1474+
// degraded.
14731475
const manual = yield* executor.connections.checkHealth({
14741476
owner: "org",
14751477
integration: INTEG,
@@ -1479,11 +1481,58 @@ describe("agent read revalidation (coreTools connections.list)", () => {
14791481
expect(manual.detail).toBe(detail);
14801482
expect(counters.probes).toBe(0);
14811483

1482-
// And it re-persisted, so plain row reads agree with what every health
1483-
// read serves.
1484+
// connections.get and the agent list present the same derivation.
14841485
const row = yield* persisted();
1485-
expect(row?.lastHealth?.status).toBe("expired");
1486-
expect(row?.lastHealth?.detail).toBe(detail);
1486+
expect(row?.lastHealth).toMatchObject({ status: "expired", detail });
1487+
const out = (yield* executor.execute(CORE_LIST, {})) as ListedConnections;
1488+
const listed = out.connections.find((c) => c.name === "main");
1489+
expect(listed?.lastHealth?.status).toBe("expired");
1490+
expect(counters.probes).toBe(0);
1491+
1492+
// Derivation, not repair: no read wrote anything back. A repair write
1493+
// could race a concurrent reconnect and stamp the old grant's expired
1494+
// verdict onto the fresh connection; presenting from `provider_state`
1495+
// needs no write, so there is nothing to race.
1496+
const after = yield* rawRow();
1497+
expect(after?.updated_at).toEqual(before?.updated_at);
1498+
expect(after?.last_health).toEqual(before?.last_health);
1499+
}),
1500+
);
1501+
1502+
it.effect("reconnect clearing the dead grant ends the derivation on reads", () =>
1503+
Effect.gen(function* () {
1504+
const { executor, counters, stamp, persisted } = yield* makeHealthHarness();
1505+
yield* stamp({
1506+
provider_state: {
1507+
oauthReauthRequiredAt: Date.now(),
1508+
oauthReauthRequiredDetail: "invalid_grant",
1509+
},
1510+
last_health: {
1511+
status: "degraded",
1512+
checkedAt: Date.now(),
1513+
detail: "Tool sync failing: upstream rejected the credential",
1514+
},
1515+
});
1516+
const buried = yield* persisted();
1517+
expect(buried?.lastHealth?.status).toBe("expired");
1518+
1519+
// The reconnect mint rewrites `provider_state` wholesale and clears the
1520+
// old grant's verdict. That alone must end the expired presentation —
1521+
// no repair write exists to resurrect the old grant's verdict onto the
1522+
// fresh connection.
1523+
yield* stamp({ provider_state: null, last_health: null, updated_at: new Date() });
1524+
1525+
const fresh = yield* persisted();
1526+
expect(fresh?.lastHealth).toBeNull();
1527+
1528+
// And probing is re-opened for the new grant.
1529+
const check = yield* executor.connections.checkHealth({
1530+
owner: "org",
1531+
integration: INTEG,
1532+
name: ConnectionName.make("main"),
1533+
});
1534+
expect(check.status).toBe("healthy");
1535+
expect(counters.probes).toBe(1);
14871536
}),
14881537
);
14891538

packages/core/sdk/src/executor.ts

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,42 @@ const healthProbeGateFor = (rootDb: object): HealthProbeGate => {
941941
const healthProbeGateKey = (tenant: string, row: ConnectionRow): string =>
942942
JSON.stringify([tenant, row.owner, row.subject, row.integration, row.name]);
943943

944+
/** The verdict a recorded dead grant answers every health read with. The
945+
* persisted `expired` verdict (written together with the dead-grant
946+
* state) is served as-is; a row whose verdict a racing writer buried (or
947+
* that somehow lacks one) gets an expired verdict synthesized from the
948+
* recorded rejection, unpersisted. */
949+
const deadGrantVerdict = (
950+
reauthState: OAuthReauthRequiredState,
951+
row: ConnectionRow,
952+
): HealthCheckResult => {
953+
const cached = Option.getOrNull(decodeLastHealth(row.last_health));
954+
if (cached !== null && cached.status === "expired") return cached;
955+
return {
956+
status: "expired",
957+
checkedAt: reauthState.oauthReauthRequiredAt,
958+
detail:
959+
reauthState.oauthReauthRequiredDetail ??
960+
"The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue.",
961+
};
962+
};
963+
964+
/** The health a connection row presents on every API read. Derived, never
965+
* written back: while `provider_state` records a dead grant, the row
966+
* presents the dead grant's expired verdict regardless of what a racing
967+
* writer left in `last_health`, so a buried verdict cannot mislead any
968+
* reader. A repair WRITE here instead would race the reconnect mint — a
969+
* stale repair that observed the pre-reconnect dead grant can pass the
970+
* verdict CAS inside one SQLite `updated_at` second and stamp the OLD
971+
* grant's expired verdict onto the fresh connection. The reconnect mint
972+
* rewrites `provider_state` wholesale, which ends this derivation with no
973+
* write to race. */
974+
const presentedLastHealth = (row: ConnectionRow): HealthCheckResult | null => {
975+
const reauthState = oauthReauthRequiredFromProviderState(row.provider_state);
976+
if (reauthState !== null) return deadGrantVerdict(reauthState, row);
977+
return Option.getOrNull(decodeLastHealth(row.last_health));
978+
};
979+
944980
const rowToConnection = (row: ConnectionRow): Connection => {
945981
const owner = row.owner as Owner;
946982
const integration = IntegrationSlug.make(row.integration);
@@ -960,7 +996,7 @@ const rowToConnection = (row: ConnectionRow): Connection => {
960996
row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner),
961997
oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
962998
missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state),
963-
lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
999+
lastHealth: presentedLastHealth(row),
9641000
};
9651001
};
9661002

@@ -3993,25 +4029,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
39934029
: {}),
39944030
});
39954031

3996-
/** The verdict a recorded dead grant answers every health read with. The
3997-
* persisted `expired` verdict (written together with the dead-grant
3998-
* state) is served as-is; a row that somehow lacks one gets an expired
3999-
* verdict synthesized from the recorded rejection, unpersisted. */
4000-
const deadGrantVerdict = (
4001-
reauthState: OAuthReauthRequiredState,
4002-
row: ConnectionRow,
4003-
): HealthCheckResult => {
4004-
const cached = Option.getOrNull(decodeLastHealth(row.last_health));
4005-
if (cached !== null && cached.status === "expired") return cached;
4006-
return {
4007-
status: "expired",
4008-
checkedAt: reauthState.oauthReauthRequiredAt,
4009-
detail:
4010-
reauthState.oauthReauthRequiredDetail ??
4011-
"The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue.",
4012-
};
4013-
};
4014-
40154032
/** Persist a probe verdict unless the grant died while the probe was in
40164033
* flight: a concurrent refresh discovering invalid_grant writes the
40174034
* authoritative dead-grant state (with its own `expired` verdict), and a
@@ -4063,21 +4080,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
40634080
// lapses — with read-time revalidation then trusting the lie forever.
40644081
// Serve the dead-grant verdict and probe nothing; this covers the
40654082
// manual "Check now" too. Only the reconnect mint, which rewrites
4066-
// `provider_state` wholesale, re-opens probing. The one write here is
4067-
// a repair: if some racing writer buried the recorder's `expired`
4068-
// verdict (the recorder itself cannot lose — it writes provider_state
4069-
// and last_health in one statement — but e.g. a failing tool sync can
4070-
// land after it), re-assert it so plain row reads agree with what
4071-
// every health read serves. CAS'd on the observed stamps like every
4072-
// guarded verdict write, so anything newer than this read still wins,
4073-
// and a re-check then repairs against THAT row.
4083+
// `provider_state` wholesale, re-opens probing. Nothing is written:
4084+
// a buried `expired` verdict (e.g. a failing tool sync landing after
4085+
// the recorder) is already re-derived on every read through
4086+
// `presentedLastHealth`, so plain row reads agree with what this
4087+
// serves — and a repair write here could observe a pre-reconnect
4088+
// dead grant, pass the verdict CAS inside one SQLite `updated_at`
4089+
// second, and stamp the OLD grant's expired verdict onto the freshly
4090+
// reconnected row.
40744091
const reauthState = oauthReauthRequiredFromProviderState(connectionRow.provider_state);
40754092
if (reauthState !== null) {
40764093
const result = deadGrantVerdict(reauthState, connectionRow);
4077-
const persisted = Option.getOrNull(decodeLastHealth(connectionRow.last_health));
4078-
if (persisted === null || persisted.status !== "expired") {
4079-
yield* persistHealthResult(ref, connectionRow, result);
4080-
}
40814094
yield* annotateHealthVerdict("dead_grant", result);
40824095
return result;
40834096
}
@@ -5710,7 +5723,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
57105723
integration: IntegrationSlug.make(row.integration),
57115724
name: ConnectionName.make(row.name),
57125725
oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
5713-
lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)),
5726+
lastHealth: presentedLastHealth(row),
57145727
};
57155728
};
57165729

0 commit comments

Comments
 (0)