@open-everywhere/provider-github 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +50 -2
  4. package/agent/channels/github-catch-up.ts +134 -0
  5. package/agent/channels/github-engagement.ts +381 -0
  6. package/agent/channels/github-identity.ts +376 -0
  7. package/agent/channels/github-installations.ts +152 -0
  8. package/agent/channels/github-message-ops.ts +62 -0
  9. package/agent/channels/github-origin-codec.ts +42 -0
  10. package/agent/channels/github-post-arm.ts +422 -0
  11. package/agent/channels/github-reaction-feedback.ts +489 -0
  12. package/agent/channels/github-reaction-scope.ts +312 -0
  13. package/agent/channels/github-read.ts +471 -0
  14. package/agent/channels/github-stop.ts +175 -0
  15. package/agent/channels/github-supervisor.ts +645 -0
  16. package/agent/channels/github-webhook-verifier.ts +56 -0
  17. package/agent/channels/github.ts +1458 -0
  18. package/agent/contributions.ts +31 -0
  19. package/agent/docs-generated.ts +15 -0
  20. package/agent/memory/memory.ts +265 -0
  21. package/agent/ops.ts +17 -0
  22. package/agent/provider-events.ts +114 -0
  23. package/agent/register.ts +130 -0
  24. package/agent/surface-parity.ts +116 -0
  25. package/dist/agent/channels/github-catch-up.d.ts +29 -0
  26. package/dist/agent/channels/github-catch-up.d.ts.map +1 -0
  27. package/dist/agent/channels/github-catch-up.js +105 -0
  28. package/dist/agent/channels/github-catch-up.js.map +1 -0
  29. package/dist/agent/channels/github-engagement.d.ts +223 -0
  30. package/dist/agent/channels/github-engagement.d.ts.map +1 -0
  31. package/dist/agent/channels/github-engagement.js +317 -0
  32. package/dist/agent/channels/github-engagement.js.map +1 -0
  33. package/dist/agent/channels/github-identity.d.ts +164 -0
  34. package/dist/agent/channels/github-identity.d.ts.map +1 -0
  35. package/dist/agent/channels/github-identity.js +295 -0
  36. package/dist/agent/channels/github-identity.js.map +1 -0
  37. package/dist/agent/channels/github-installations.d.ts +49 -0
  38. package/dist/agent/channels/github-installations.d.ts.map +1 -0
  39. package/dist/agent/channels/github-installations.js +114 -0
  40. package/dist/agent/channels/github-installations.js.map +1 -0
  41. package/dist/agent/channels/github-message-ops.d.ts +3 -0
  42. package/dist/agent/channels/github-message-ops.d.ts.map +1 -0
  43. package/dist/agent/channels/github-message-ops.js +58 -0
  44. package/dist/agent/channels/github-message-ops.js.map +1 -0
  45. package/dist/agent/channels/github-origin-codec.d.ts +7 -0
  46. package/dist/agent/channels/github-origin-codec.d.ts.map +1 -0
  47. package/dist/agent/channels/github-origin-codec.js +36 -0
  48. package/dist/agent/channels/github-origin-codec.js.map +1 -0
  49. package/dist/agent/channels/github-post-arm.d.ts +54 -0
  50. package/dist/agent/channels/github-post-arm.d.ts.map +1 -0
  51. package/dist/agent/channels/github-post-arm.js +339 -0
  52. package/dist/agent/channels/github-post-arm.js.map +1 -0
  53. package/dist/agent/channels/github-reaction-feedback.d.ts +97 -0
  54. package/dist/agent/channels/github-reaction-feedback.d.ts.map +1 -0
  55. package/dist/agent/channels/github-reaction-feedback.js +380 -0
  56. package/dist/agent/channels/github-reaction-feedback.js.map +1 -0
  57. package/dist/agent/channels/github-reaction-scope.d.ts +102 -0
  58. package/dist/agent/channels/github-reaction-scope.d.ts.map +1 -0
  59. package/dist/agent/channels/github-reaction-scope.js +296 -0
  60. package/dist/agent/channels/github-reaction-scope.js.map +1 -0
  61. package/dist/agent/channels/github-read.d.ts +135 -0
  62. package/dist/agent/channels/github-read.d.ts.map +1 -0
  63. package/dist/agent/channels/github-read.js +371 -0
  64. package/dist/agent/channels/github-read.js.map +1 -0
  65. package/dist/agent/channels/github-stop.d.ts +52 -0
  66. package/dist/agent/channels/github-stop.d.ts.map +1 -0
  67. package/dist/agent/channels/github-stop.js +145 -0
  68. package/dist/agent/channels/github-stop.js.map +1 -0
  69. package/dist/agent/channels/github-supervisor.d.ts +308 -0
  70. package/dist/agent/channels/github-supervisor.d.ts.map +1 -0
  71. package/dist/agent/channels/github-supervisor.js +339 -0
  72. package/dist/agent/channels/github-supervisor.js.map +1 -0
  73. package/dist/agent/channels/github-webhook-verifier.d.ts +5 -0
  74. package/dist/agent/channels/github-webhook-verifier.d.ts.map +1 -0
  75. package/dist/agent/channels/github-webhook-verifier.js +55 -0
  76. package/dist/agent/channels/github-webhook-verifier.js.map +1 -0
  77. package/dist/agent/channels/github.d.ts +139 -0
  78. package/dist/agent/channels/github.d.ts.map +1 -0
  79. package/dist/agent/channels/github.js +1232 -0
  80. package/dist/agent/channels/github.js.map +1 -0
  81. package/dist/agent/contributions.d.ts +12 -0
  82. package/dist/agent/contributions.d.ts.map +1 -0
  83. package/dist/agent/contributions.js +31 -0
  84. package/dist/agent/contributions.js.map +1 -0
  85. package/dist/agent/docs-generated.d.ts +3 -0
  86. package/dist/agent/docs-generated.d.ts.map +1 -0
  87. package/dist/agent/docs-generated.js +13 -0
  88. package/dist/agent/docs-generated.js.map +1 -0
  89. package/dist/agent/memory/memory.d.ts +54 -0
  90. package/dist/agent/memory/memory.d.ts.map +1 -0
  91. package/dist/agent/memory/memory.js +224 -0
  92. package/dist/agent/memory/memory.js.map +1 -0
  93. package/dist/agent/ops.d.ts +3 -0
  94. package/dist/agent/ops.d.ts.map +1 -0
  95. package/dist/agent/ops.js +16 -0
  96. package/dist/agent/ops.js.map +1 -0
  97. package/dist/agent/provider-events.d.ts +3 -0
  98. package/dist/agent/provider-events.d.ts.map +1 -0
  99. package/dist/agent/provider-events.js +112 -0
  100. package/dist/agent/provider-events.js.map +1 -0
  101. package/dist/agent/register.d.ts +4 -0
  102. package/dist/agent/register.d.ts.map +1 -0
  103. package/dist/agent/register.js +116 -0
  104. package/dist/agent/register.js.map +1 -0
  105. package/dist/agent/surface-parity.d.ts +3 -0
  106. package/dist/agent/surface-parity.d.ts.map +1 -0
  107. package/dist/agent/surface-parity.js +110 -0
  108. package/dist/agent/surface-parity.js.map +1 -0
  109. package/package.json +156 -4
  110. package/tsconfig.build.json +22 -0
  111. package/tsconfig.json +15 -0
@@ -0,0 +1,312 @@
1
+ // THE GITHUB REACTION-POLL SCOPE (OPE-1540) — the two gates that decide whether one delivery is
2
+ // allowed to spend REST calls looking for reactions, and the durable record the first gate reads.
3
+ //
4
+ // WHY A GATE EXISTS AT ALL. GitHub pushes no reaction webhook, so the feedback arm
5
+ // (channels/github-reaction-feedback.ts) POLLS: every signature-verified `issue_comment`/`issues`
6
+ // delivery made it read the issue and a comment page, and only then could it learn that the agent
7
+ // has never commented in that conversation and there is nothing to look at. That is 2–3 installation
8
+ // REST calls per delivery, on the SAME installation token the reply path mints. A repository taking
9
+ // 400 deliveries an hour spent ~800–1200 GETs an hour learning nothing, and the 429 pressure landed
10
+ // on the agent's own posts.
11
+ //
12
+ // ── GATE 1: THE CONVERSATION MUST BE ONE THE AGENT IS IN, AND IT READS TWO RECORDS
13
+ //
14
+ // A reaction the arm can record is a reaction on the AGENT's own comment, so a conversation the agent
15
+ // has never commented in can hold none. Gate 1 answers "is the agent in this conversation" from two
16
+ // durable records, and admits on EITHER. Both are keyed store reads; neither costs a REST call.
17
+ //
18
+ // 1. THE POST CLAIM, written by this bundle's own reply path (channels/github-post-arm.ts
19
+ // `liveGitHubPost`). That is the one place a GitHub comment leaves THIS BUNDLE — every `reply`
20
+ // tool call, every unified-window reply and every ambient-supervisor answer.
21
+ // 2. THE ENGAGEMENT ROW, written by the §2.5 seam (channels/github-engagement.ts) when somebody
22
+ // @mentions the app in a container.
23
+ //
24
+ // 🔴 THE SECOND READ EXISTS BECAUSE THIS BUNDLE IS NOT THE ONLY POSTER. eve's default posts the
25
+ // `input.requested` comment, which this bundle does not override, and it still posts a NATIVE
26
+ // terminal completion on the paths that fall back to it — a review thread's inline reply, and a
27
+ // state with no post anchor (`channels/github.ts`). (An ordinary native terminal completion claims
28
+ // here now: it posts through `liveGitHubPost` since OPE-1681, so that example is no longer one.) Those comments carry the app's login and attract reactions exactly as a `reply`
29
+ // does, and NOTHING in this bundle sees them go out — so a post-claim-only gate would refuse those
30
+ // conversations forever, not merely until the next reply. The engagement row is what covers them: the
31
+ // native leg wakes only on an @mention (which marks the row) or on a plain comment in a container
32
+ // already marked, so every conversation eve can post in has one.
33
+ //
34
+ // 🔴 IT ADMITS A LITTLE MORE THAN "HAS POSTED", ON PURPOSE. A container somebody @mentioned the app
35
+ // in where the app then said nothing is admitted and costs a poll. That is the safe direction — the
36
+ // alternative is a reaction the deployment can never record — and gate 2 caps what it can cost.
37
+ //
38
+ // 🔴 THE POST CLAIM IS A `notice_claim`, AND THE LIFETIME IS THE POINT. "The agent has commented
39
+ // here" never stops being true, so the record must never expire: a TTL would blind the poll to a
40
+ // quiet conversation's reactions the moment the TTL lapsed, and re-writing the mark needs a post
41
+ // nobody is going to make. `claimNotice`/`noticeClaimed` (operational/store.ts) is the tree's
42
+ // at-most-once claim with no TTL and no prune, which is exactly this shape. Growth is one row per
43
+ // conversation the agent has ever commented in — bounded by conversations, never by deliveries.
44
+ //
45
+ // 🔴 WHAT THE CUT-OVER COSTS, STATED. Both records start from what already exists: engagement rows
46
+ // are already there for every container an @mention ever reached, and the post claim starts EMPTY. So
47
+ // a conversation the agent only ever answered ambiently — no @mention, hence no engagement row — and
48
+ // has not answered since this shipped is not polled until it answers there again. The reaction arm
49
+ // landed one day before this gate (OPE-1502, PR #587), so the window of reactions that can fall into
50
+ // that gap is one day wide. It is a real gap, not a theoretical one.
51
+ //
52
+ // FAIL-OPEN on a store error. An unreadable record answers "the agent is in this conversation", so a
53
+ // store blip costs REST calls rather than silently stopping the feedback signal. It is the opposite
54
+ // posture from the reaction DEDUPE next door (channels/github-reaction-feedback.ts), which fails
55
+ // CLOSED — these two gates bound a COST, and that one bounds what the audit log claims happened.
56
+ //
57
+ // ── GATE 2: A PER-REPOSITORY HOURLY BUDGET
58
+ //
59
+ // Gate 1 bounds the poll to conversations the agent is in, which is the big cut. It does NOT bound
60
+ // what one very busy conversation can spend: a hundred comments an hour in a pull request the agent
61
+ // replied to is a hundred polls. So a second gate caps the polls one REPOSITORY may run in one clock
62
+ // hour, and the trip is AUDITED — a silently capped feedback signal reads exactly like a feature that
63
+ // stopped working.
64
+ //
65
+ // 🔴 THE COUNTER IS APPROXIMATE UNDER CONCURRENCY, AND THAT IS ACCEPTED. It is a read-then-write over
66
+ // the opaque `task_result` KV, not an atomic increment, so two deliveries racing on the same repo can
67
+ // both read N and write N+1 — the hour's true count can exceed the budget by roughly the number of
68
+ // concurrent deliveries. A rate budget is a cost bound, not a security control: over-counting would
69
+ // drop feedback that should have been recorded, under-counting costs a few extra GETs. The cheap,
70
+ // non-atomic direction is the one whose failure mode is the harmless one.
71
+ //
72
+ // 🔴 THE TTL IS COUPLED, SO IT IS NOT A KNOB. `task_result`'s prune is ORG-WIDE and uses the CALLING
73
+ // writer's own ttl (operational/store.ts, the task_result section), so every consumer must pass the
74
+ // same six hours. {@link GITHUB_POLL_BUDGET_TTL_MS} exists to be that number and nothing else — a
75
+ // shorter one here would start deleting `model/turn-state.ts`'s live rows on every delivery.
76
+
77
+ import {
78
+ appendAudit,
79
+ deploymentOrg,
80
+ getOperationalStore,
81
+ } from "@open-everywhere/core/agent/adapter-api.js";
82
+ import { githubConversationKey } from "./github-identity.js";
83
+
84
+ /** How many reaction polls one repository may run in one clock hour. Sized so an ordinarily busy
85
+ * repository never reaches it — the gate is a ceiling on a runaway conversation, not a throttle on
86
+ * normal traffic. One ADMITTED poll costs up to 13 GETs (the issue read, one or two comment pages,
87
+ * and up to `GITHUB_REACTION_POLL_COMMENT_BUDGET` reaction reads), so this bounds a repository's
88
+ * worst hour to ~780 GETs on the installation token the reply path shares. The everyday cost is far
89
+ * below that: most polls read three. */
90
+ export const GITHUB_REACTION_POLL_HOURLY_BUDGET = 60;
91
+
92
+ /** The `task_result` retention every consumer of that table shares. See the header: it is coupled,
93
+ * not tunable. Six hours, the same window `model/turn-state.ts` and the three other consumers use. */
94
+ const GITHUB_POLL_BUDGET_TTL_MS = 6 * 60 * 60 * 1000;
95
+
96
+ /** The audit action a tripped budget writes. Queryable over the control plane's audit route, the
97
+ * same way `github.ambient_skipped` answers "why did the ambient leg stay quiet". */
98
+ export const GITHUB_REACTION_POLL_BUDGET_ACTION = "github.reaction_poll_budget";
99
+
100
+ /** The audit action an UNREADABLE CLAIM STORE writes, once per poll. The dedupe next door fails
101
+ * CLOSED, so a store that cannot answer "is this recorded" stops every feedback row for as long as
102
+ * it stays down — and a silent zero reads exactly like a quiet week. This row is the signal. It
103
+ * carries the same audit ACTION shape as the budget trip above, so an operator reads both off the
104
+ * control plane's audit route; the GRAIN differs, and {@link auditGitHubReactionClaimUnreadable}
105
+ * says how. */
106
+ export const GITHUB_REACTION_CLAIM_UNREADABLE_ACTION = "github.reaction_claim_unreadable";
107
+
108
+ /** How long a reply may wait on the best-effort post claim below.
109
+ *
110
+ * 🔴 THE CEILING IS THE POINT, NOT THE NUMBER. `liveGitHubPost` marks the conversation AFTER the
111
+ * comment has already landed, so this write can only ever delay the reply tool's RETURN — and with
112
+ * no ceiling a slow store held that return for as long as it liked, on the turn path. Two seconds is
113
+ * far more than a keyed claim needs and far less than a turn can notice. */
114
+ export const GITHUB_POSTED_MARK_TIMEOUT_MS = 2000;
115
+
116
+ /** How long the trip row's once-per-hour guard holds. It is the bucket width, not a dedupe window:
117
+ * the key already carries the hour, so this only has to outlive the hour it names. */
118
+ const BUDGET_AUDIT_TTL_MS = 2 * 60 * 60 * 1000;
119
+
120
+ /** The claim key for "the agent has commented in this conversation". `githubConversationKey` is the
121
+ * SAME `owner/repo#N` anchor the engagement registry and the stop registry address, so the mark and
122
+ * every other per-conversation record in this bundle name the container the same way. */
123
+ function githubPostedClaimKey(owner: string | undefined, repo: string | undefined, number: number | undefined): string | undefined {
124
+ const anchor = githubConversationKey(owner, repo, number);
125
+ return anchor ? `github-posted:${anchor}` : undefined;
126
+ }
127
+
128
+ /** The hourly budget's key for one repository. The clock hour rides IN the key, so the counter needs
129
+ * no reset and a stale bucket simply stops being read. */
130
+ export function githubPollBudgetKey(owner: string, repo: string, atIso: string): string {
131
+ return `github-reaction-poll-budget:${owner}/${repo}:${atIso.slice(0, 13)}`;
132
+ }
133
+
134
+ /**
135
+ * Record that the agent has commented in this conversation. Best-effort and idempotent — a second
136
+ * post in the same conversation re-claims a key that is already claimed, which the store answers
137
+ * false to and this function ignores. NEVER THROWS: a lost mark costs a later poll, never a reply.
138
+ *
139
+ * 🔴 IT IS ALSO BOUNDED, WHICH IS THE OTHER HALF OF THAT CONTRACT. The one caller is
140
+ * `liveGitHubPost`, on the reply tool's own path, and it calls this AFTER the comment has landed. An
141
+ * unbounded await therefore could not lose a reply but could hold the tool's return for as long as
142
+ * the store took — a real hazard on a tree that already stalls in its post-turn closeout. So the
143
+ * write RACES {@link GITHUB_POSTED_MARK_TIMEOUT_MS}: a store slower than that is treated exactly
144
+ * like the store that throws, and costs a later poll rather than the turn.
145
+ *
146
+ * The abandoned write is not cancelled — nothing here can cancel a store call — so it carries its
147
+ * own settled handler and may still land after this returns. That is the harmless direction: the
148
+ * claim is idempotent and its only reader is the next poll.
149
+ */
150
+ export async function markGitHubConversationPosted(owner: string | undefined, repo: string | undefined, number: number | undefined, atIso: string): Promise<void> {
151
+ const key = githubPostedClaimKey(owner, repo, number);
152
+ if (!key) return;
153
+ let write: Promise<void>;
154
+ try {
155
+ // The `.then(_, _)` is what keeps an abandoned rejection from surfacing as an unhandled one:
156
+ // after the race gives up, nothing else is holding this promise.
157
+ write = getOperationalStore()
158
+ .claimNotice(deploymentOrg(), key, atIso)
159
+ .then(
160
+ () => undefined,
161
+ () => undefined,
162
+ );
163
+ } catch {
164
+ // A store that cannot even be resolved. The reply already landed; there is nothing to wait on.
165
+ return;
166
+ }
167
+ // `unref` so the ceiling never keeps a host alive on its own, and `clearTimeout` so a write that
168
+ // beat it leaves no pending timer behind.
169
+ let timer: ReturnType<typeof setTimeout> | undefined;
170
+ const ceiling = new Promise<void>((resolve) => {
171
+ timer = setTimeout(resolve, GITHUB_POSTED_MARK_TIMEOUT_MS);
172
+ timer.unref?.();
173
+ });
174
+ try {
175
+ await Promise.race([write, ceiling]);
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Is the agent IN this conversation — has this bundle's reply path posted here, or did an @mention
183
+ * engage the container? Either answers yes; see the header for why both reads are needed.
184
+ *
185
+ * FAIL-OPEN on a store error, and on a delivery that names no container: an unanswerable question
186
+ * admits the poll, whose own parse then refuses a delivery that names nothing.
187
+ */
188
+ export async function githubConversationInScope(surfaceKey: string, owner: string | undefined, repo: string | undefined, number: number | undefined): Promise<boolean> {
189
+ const key = githubPostedClaimKey(owner, repo, number);
190
+ const anchor = githubConversationKey(owner, repo, number);
191
+ if (!key || !anchor) return true;
192
+ const org = deploymentOrg();
193
+ try {
194
+ if (await getOperationalStore().noticeClaimed(org, key)) return true;
195
+ // 🔴 THE STORE, NOT `getThreadEngagement`. That helper swallows its own failure and answers
196
+ // null, so an unreadable registry would be indistinguishable from an un-engaged container and
197
+ // this gate would fail CLOSED on a store blip — the one direction the header forbids. Reading
198
+ // the store directly lets the catch below take the fail-open answer for BOTH records. The
199
+ // coordinates are the helper's own: `p.org` and `p.surfaceKey`, keyed by the container anchor.
200
+ return (await getOperationalStore().getEngagement(org, surfaceKey, anchor)) !== null;
201
+ } catch {
202
+ return true;
203
+ }
204
+ }
205
+
206
+ /** What one budget check decided, and what it counted. `polls` is the count INCLUDING this one when
207
+ * the check admitted it, so a caller can log the pressure without a second read. */
208
+ export interface GitHubPollBudgetVerdict {
209
+ readonly admitted: boolean;
210
+ readonly polls: number;
211
+ }
212
+
213
+ /**
214
+ * Spend one poll from this repository's hourly budget.
215
+ *
216
+ * Admits and counts while the hour has room; refuses once the budget is spent, and writes ONE
217
+ * {@link GITHUB_REACTION_POLL_BUDGET_ACTION} row for that hour rather than one per refused delivery —
218
+ * the refusals are the loud part of a busy hour, and an unthrottled row per delivery would be the
219
+ * same flood in the audit log that the budget exists to stop in GitHub's API.
220
+ *
221
+ * FAIL-OPEN on a store error: an unreadable counter admits the poll. A budget is a cost bound, so a
222
+ * store blip must never silence the feedback signal.
223
+ */
224
+ export async function spendGitHubPollBudget(owner: string, repo: string, atIso: string, budget = GITHUB_REACTION_POLL_HOURLY_BUDGET): Promise<GitHubPollBudgetVerdict> {
225
+ const org = deploymentOrg();
226
+ const key = githubPollBudgetKey(owner, repo, atIso);
227
+ let used = 0;
228
+ try {
229
+ const raw = await getOperationalStore().getTaskResult(org, key);
230
+ const parsed = raw ? (JSON.parse(raw) as { readonly polls?: unknown }) : undefined;
231
+ used = typeof parsed?.polls === "number" && Number.isFinite(parsed.polls) ? parsed.polls : 0;
232
+ } catch {
233
+ return { admitted: true, polls: 0 };
234
+ }
235
+ if (used >= budget) {
236
+ await auditBudgetTrip(org, owner, repo, atIso, used, budget);
237
+ return { admitted: false, polls: used };
238
+ }
239
+ const polls = used + 1;
240
+ try {
241
+ await getOperationalStore().putTaskResult(org, key, JSON.stringify({ polls }), atIso, GITHUB_POLL_BUDGET_TTL_MS);
242
+ } catch {
243
+ // The count is lost for this delivery; the poll still runs. Under-counting costs GETs, never a row.
244
+ }
245
+ return { admitted: true, polls };
246
+ }
247
+
248
+ /**
249
+ * Write the one row that says this poll's claim store could not be read.
250
+ *
251
+ * 🔴 WHY IT EXISTS. The reaction dedupe (channels/github-reaction-feedback.ts) fails CLOSED on an
252
+ * unreadable claim — an unknown answer counts as ALREADY RECORDED — which is the right posture for
253
+ * what the audit log claims happened, and a silent one. A store that stays unreadable therefore
254
+ * records nothing at all, forever, and the operator sees a feature that looks healthy and reports
255
+ * zero. This row is the difference between "nobody reacted" and "the store is down".
256
+ *
257
+ * ONE ROW PER POLL, never one per reaction: a busy comment page holds dozens of reactions and one
258
+ * broken store would write a row for each. `skipped` carries the count instead, so the row is as
259
+ * loud as the flood would have been. That is a DIFFERENT GRAIN from the budget trip below, which
260
+ * `markEventSeen` throttles to one row per repository per hour — this one has no throttle, because a
261
+ * poll that could not read the store is a distinct event and the count is the thing being reported.
262
+ *
263
+ * 🔴 WHAT BOUNDS THE VOLUME, HONESTLY. The poll reaches here only past gate 2, so an ordinary hour
264
+ * holds at most {@link GITHUB_REACTION_POLL_HOURLY_BUDGET} of these per repository — but only WHILE
265
+ * THE BUDGET COUNTER IS WRITABLE. Gate 2 fails open on a store error, so a fault that breaks the
266
+ * `task_result` write while `audit_log` inserts still land gives one row per delivery instead. That
267
+ * is the same trade the gate already made, and both directions of it are a cost, never a lost row.
268
+ *
269
+ * 🔴 AND IT IS NOT A SIGNAL FOR EVERY OUTAGE. `appendAudit` writes through the SAME operational
270
+ * store whose claim read just threw, so a whole-store outage loses this row too — best-effort, one
271
+ * retry, then a drop-counter tick and a `[open-everywhere:audit] dropped a …` warn (core
272
+ * agent/audit/audit.ts). This row is what makes a PARTIAL fault legible — a missing `notice_claim`
273
+ * table, a table-level grant — and the audit drop counter is what makes the total one legible. The
274
+ * operator doc states the same split.
275
+ *
276
+ * Best-effort, like every other write in this module: nothing here may cost the ingress its ack.
277
+ */
278
+ export async function auditGitHubReactionClaimUnreadable(owner: string, repo: string, atIso: string, skipped: number): Promise<void> {
279
+ try {
280
+ await appendAudit({
281
+ ts: atIso,
282
+ org: deploymentOrg(),
283
+ platform: "github",
284
+ workspace: owner,
285
+ surface: `github/${owner}/${repo}`,
286
+ action: GITHUB_REACTION_CLAIM_UNREADABLE_ACTION,
287
+ detail: { skipped, hour: atIso.slice(0, 13) },
288
+ });
289
+ } catch {
290
+ // Nothing here may cost the ingress its ack.
291
+ }
292
+ }
293
+
294
+ /** The trip row, at most once per repository per hour. Both the throttle and the write are
295
+ * best-effort: a budget that cannot audit itself still refuses the poll. */
296
+ async function auditBudgetTrip(org: string, owner: string, repo: string, atIso: string, used: number, budget: number): Promise<void> {
297
+ try {
298
+ const first = await getOperationalStore().markEventSeen(org, `${githubPollBudgetKey(owner, repo, atIso)}:tripped`, atIso, BUDGET_AUDIT_TTL_MS);
299
+ if (!first) return;
300
+ await appendAudit({
301
+ ts: atIso,
302
+ org,
303
+ platform: "github",
304
+ workspace: owner,
305
+ surface: `github/${owner}/${repo}`,
306
+ action: GITHUB_REACTION_POLL_BUDGET_ACTION,
307
+ detail: { budget, polls: used, hour: atIso.slice(0, 13) },
308
+ });
309
+ } catch {
310
+ // Nothing here may cost the ingress its ack.
311
+ }
312
+ }