Skip to content

Commit 04ddf37

Browse files
committed
feat(agent-actions): generalize the review-nag cooldown into a per-command rate limit
The only throttle on the @gittensory command surface was maybeThrottleReviewNagPing, deliberately narrow (thread's own author only, review-nag policy only). Every other command in the catalog -- ask, preflight, blockers, packet, the maintainer queue-digest commands, etc. -- had no cooldown at all, and most dispatch to real, AI-cost-bearing orchestrator calls a careless or malicious actor could spam dozens of times on the same thread. Adds maybeThrottleGittensoryCommand, generalizing review-nag's audit-ledger counting pattern (countRecentAuditEventsForActorAndTarget) to any command -- keyed by (actor, command, targetKey) so each command gets its own independent counter. Config-driven per repo via commandRateLimitPolicy ("off"/"hold"), off by default so existing repos see no behavior change. AI-cost-bearing commands (ask/blockers/preflight/reviewability/packet/duplicate-check/ next-action/repo-fit) get a tighter default limit than cheap, cache-only commands. A rate-limited invocation gets a clear cooldown reply, never silence. Independent of and complementary to review-nag's own narrower thread-author- only, close-capable cooldown -- this generalizes the pattern without replacing it. Closes #2560.
1 parent bcc19ea commit 04ddf37

11 files changed

Lines changed: 349 additions & 0 deletions

File tree

.gittensory.yml.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,12 @@ settings:
333333
# above will reuse this list too), on top of the standing owner/admin/automation-bot exemption.
334334
# List of GitHub logins. Default: [] (no additional exemptions).
335335
# autoCloseExemptLogins: [some-trusted-regular]
336+
337+
# Per-command @gittensory rate limit (#2560, anti-abuse): generalizes review-nag's cooldown pattern to
338+
# EVERY @gittensory command (help/ask/preflight/blockers/... and the maintainer queue-digest commands),
339+
# not just review-request pings — independent of review-nag's own thread-author-only scope. "hold"
340+
# replies with a cooldown notice and skips the command's own dispatch. Off by default.
341+
# commandRateLimitPolicy: off # off | hold. Default: off.
342+
# commandRateLimitMaxPerWindow: 20 # Positive integer. Per-command invocation limit for a CHEAP command (cache-only, no AI call) within the window. Default: 20.
343+
# commandRateLimitAiMaxPerWindow: 5 # Positive integer. Tighter limit for an AI-cost-bearing command (ask/blockers/preflight/reviewability/packet/duplicate-check/next-action/repo-fit). Default: 5.
344+
# commandRateLimitWindowHours: 24 # Positive integer. Rolling window (hours) both limits above count against. Default: 24.

apps/gittensory-ui/public/openapi.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8688,6 +8688,28 @@
86888688
"nullable": true,
86898689
"minimum": 0,
86908690
"exclusiveMinimum": true
8691+
},
8692+
"commandRateLimitPolicy": {
8693+
"type": "string",
8694+
"enum": [
8695+
"off",
8696+
"hold"
8697+
]
8698+
},
8699+
"commandRateLimitMaxPerWindow": {
8700+
"type": "integer",
8701+
"minimum": 0,
8702+
"exclusiveMinimum": true
8703+
},
8704+
"commandRateLimitAiMaxPerWindow": {
8705+
"type": "integer",
8706+
"minimum": 0,
8707+
"exclusiveMinimum": true
8708+
},
8709+
"commandRateLimitWindowHours": {
8710+
"type": "integer",
8711+
"minimum": 0,
8712+
"exclusiveMinimum": true
86918713
}
86928714
},
86938715
"required": [
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Per-command @gittensory rate limit (#2560, anti-abuse): generalizes the review-nag cooldown's audit-ledger
2+
-- counting pattern to EVERY @gittensory command, not just review-request pings. Independent of and complementary
3+
-- to review-nag (that stays scoped to the thread's own author; this covers any actor invoking any command).
4+
-- Defaults are byte-identical to today: command_rate_limit_policy defaults to 'off' (disabled), so existing
5+
-- repos see no behavior change until they opt in. The AI-cost-bearing commands (ask/blockers/preflight/
6+
-- reviewability/packet/duplicate-check/next-action/repo-fit) get their own tighter default limit than the
7+
-- cheap, cache-only commands (help/miner-context/the maintainer queue-digest commands).
8+
ALTER TABLE repository_settings ADD COLUMN command_rate_limit_policy TEXT NOT NULL DEFAULT 'off';
9+
ALTER TABLE repository_settings ADD COLUMN command_rate_limit_max_per_window INTEGER NOT NULL DEFAULT 20;
10+
ALTER TABLE repository_settings ADD COLUMN command_rate_limit_ai_max_per_window INTEGER NOT NULL DEFAULT 5;
11+
ALTER TABLE repository_settings ADD COLUMN command_rate_limit_window_hours INTEGER NOT NULL DEFAULT 24;

src/db/repositories.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,10 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
511511
reviewNagLabel: "review-nag-cooldown",
512512
autoCloseExemptLogins: [],
513513
requireFreshRebaseWindowMinutes: null,
514+
commandRateLimitPolicy: "off",
515+
commandRateLimitMaxPerWindow: 20,
516+
commandRateLimitAiMaxPerWindow: 5,
517+
commandRateLimitWindowHours: 24,
514518
};
515519
}
516520
return {
@@ -564,6 +568,10 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
564568
reviewNagLabel: row.reviewNagLabel,
565569
autoCloseExemptLogins: parseAutoCloseExemptLogins(row.autoCloseExemptLoginsJson),
566570
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(row.requireFreshRebaseWindowMinutes),
571+
commandRateLimitPolicy: normalizeCommandRateLimitPolicy(row.commandRateLimitPolicy),
572+
commandRateLimitMaxPerWindow: normalizePositiveIntWithDefault(row.commandRateLimitMaxPerWindow, 20),
573+
commandRateLimitAiMaxPerWindow: normalizePositiveIntWithDefault(row.commandRateLimitAiMaxPerWindow, 5),
574+
commandRateLimitWindowHours: normalizePositiveIntWithDefault(row.commandRateLimitWindowHours, 24),
567575
createdAt: row.createdAt,
568576
updatedAt: row.updatedAt,
569577
};
@@ -649,6 +657,10 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
649657
reviewNagLabel: settings.reviewNagLabel ?? "review-nag-cooldown",
650658
autoCloseExemptLogins: normalizeAutoCloseExemptLogins(settings.autoCloseExemptLogins).logins,
651659
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(settings.requireFreshRebaseWindowMinutes),
660+
commandRateLimitPolicy: normalizeCommandRateLimitPolicy(settings.commandRateLimitPolicy),
661+
commandRateLimitMaxPerWindow: normalizePositiveIntWithDefault(settings.commandRateLimitMaxPerWindow, 20),
662+
commandRateLimitAiMaxPerWindow: normalizePositiveIntWithDefault(settings.commandRateLimitAiMaxPerWindow, 5),
663+
commandRateLimitWindowHours: normalizePositiveIntWithDefault(settings.commandRateLimitWindowHours, 24),
652664
};
653665
const db = getDb(env.DB);
654666
await db
@@ -704,6 +716,10 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
704716
reviewNagLabel: resolved.reviewNagLabel,
705717
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
706718
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
719+
commandRateLimitPolicy: resolved.commandRateLimitPolicy,
720+
commandRateLimitMaxPerWindow: resolved.commandRateLimitMaxPerWindow,
721+
commandRateLimitAiMaxPerWindow: resolved.commandRateLimitAiMaxPerWindow,
722+
commandRateLimitWindowHours: resolved.commandRateLimitWindowHours,
707723
updatedAt: nowIso(),
708724
})
709725
.onConflictDoUpdate({
@@ -760,6 +776,10 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
760776
reviewNagLabel: resolved.reviewNagLabel,
761777
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
762778
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
779+
commandRateLimitPolicy: resolved.commandRateLimitPolicy,
780+
commandRateLimitMaxPerWindow: resolved.commandRateLimitMaxPerWindow,
781+
commandRateLimitAiMaxPerWindow: resolved.commandRateLimitAiMaxPerWindow,
782+
commandRateLimitWindowHours: resolved.commandRateLimitWindowHours,
763783
updatedAt: nowIso(),
764784
},
765785
});
@@ -5703,6 +5723,10 @@ function normalizeReviewNagPolicy(value: string | null | undefined): "off" | "ho
57035723
return value === "hold" || value === "close" ? value : "off";
57045724
}
57055725

5726+
function normalizeCommandRateLimitPolicy(value: string | null | undefined): "off" | "hold" {
5727+
return value === "hold" ? value : "off";
5728+
}
5729+
57065730
// A review-nag threshold/window is a discrete positive count, not a score — reuses the same non-clamping,
57075731
// non-rounding shape as contributorOpenPrCap's normalizeOpenItemCap (#2270): an invalid value (fractional,
57085732
// non-positive, non-finite) falls back to the given default rather than being silently coerced.

src/db/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ export const repositorySettings = sqliteTable("repository_settings", {
102102
// Force-rebase-before-merge window in minutes (#2552): null = never force (default). Enforcement lands in
103103
// runAgentMaintenancePlanAndExecute, not here.
104104
requireFreshRebaseWindowMinutes: integer("require_fresh_rebase_window_minutes"),
105+
// Per-command @gittensory rate limit (#2560, anti-abuse): generalizes review-nag's cooldown pattern to every
106+
// command, keyed by (actor, command, targetKey) independent of review-nag's own thread-author-only scope.
107+
commandRateLimitPolicy: text("command_rate_limit_policy").notNull().default("off"),
108+
commandRateLimitMaxPerWindow: integer("command_rate_limit_max_per_window").notNull().default(20),
109+
commandRateLimitAiMaxPerWindow: integer("command_rate_limit_ai_max_per_window").notNull().default(5),
110+
commandRateLimitWindowHours: integer("command_rate_limit_window_hours").notNull().default(24),
105111
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
106112
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
107113
});

src/github/commands.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,26 @@ export function isMaintainerOnlyCommand(command: GittensoryMentionCommandName):
201201
return isMaintainerQueueDigestCommand(command);
202202
}
203203

204+
// Commands that dispatch to a real AI orchestrator call (planNextWork / explainBlockersWithAgent /
205+
// preflightBranchWithAgent / preparePrPacketWithAgent in buildMentionCommandBundle), as opposed to `help`,
206+
// `miner-context` (both no-op), and every maintainer queue-digest command (cache-only DB reads via
207+
// buildMaintainerQueueDigestForCommand, no AI call at all) (#2560). Used to apply a tighter per-command rate
208+
// limit to the AI-cost-bearing surface than the cheap one.
209+
const AI_COST_BEARING_COMMANDS = new Set<GittensoryMentionCommandName>([
210+
"ask",
211+
"blockers",
212+
"preflight",
213+
"reviewability",
214+
"packet",
215+
"duplicate-check",
216+
"next-action",
217+
"repo-fit",
218+
]);
219+
220+
export function isAiCostBearingCommand(command: GittensoryMentionCommandName): boolean {
221+
return AI_COST_BEARING_COMMANDS.has(command);
222+
}
223+
204224
export function isAuthorizedCommandActor(args: {
205225
commandName?: GittensoryMentionCommandName | undefined;
206226
commenterLogin?: string | null | undefined;

src/openapi/schemas.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,10 @@ export const RepositorySettingsSchema = z
650650
reviewNagCooldownDays: z.number().int().positive().optional(),
651651
reviewNagLabel: z.string().optional(),
652652
autoCloseExemptLogins: z.array(z.string()).optional(),
653+
commandRateLimitPolicy: z.enum(["off", "hold"]).optional(),
654+
commandRateLimitMaxPerWindow: z.number().int().positive().optional(),
655+
commandRateLimitAiMaxPerWindow: z.number().int().positive().optional(),
656+
commandRateLimitWindowHours: z.number().int().positive().optional(),
653657
createdAt: z.string().nullable().optional(),
654658
updatedAt: z.string().nullable().optional(),
655659
})

src/queue/processors.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import {
131131
buildMaintainerQueueDigest,
132132
buildPublicAgentCommandComment,
133133
type GittensoryMentionCommandName,
134+
isAiCostBearingCommand,
134135
isAuthorizedCommandActor,
135136
isMaintainerQueueDigestCommand,
136137
parseAgentCommandFeedbackContext,
@@ -8827,6 +8828,90 @@ async function maybeThrottleReviewNagPing(
88278828
return true;
88288829
}
88298830

8831+
// Audit eventType for one recorded @gittensory command invocation (#2560). Shared between the recorder below
8832+
// and the cooldown-window count query so a naming drift can't silently under/over-count.
8833+
const COMMAND_RATE_LIMIT_EVENT_TYPE = "github_app.command_invocation";
8834+
8835+
/**
8836+
* Per-command @gittensory rate limit (#2560, anti-abuse): generalizes review-nag's audit-ledger counting
8837+
* pattern (`countRecentAuditEventsForActorAndTarget`) to EVERY `@gittensory` Q&A command, not just
8838+
* review-request pings. Keyed by `(actor, command, targetKey)` — the command name is folded into targetKey so
8839+
* repeatedly invoking ONE command never counts against a DIFFERENT command's own limit. Independent of, and
8840+
* complementary to, `maybeThrottleReviewNagPing` above: that one stays scoped to the thread's OWN author and
8841+
* can close a PR; this covers ANY authorized actor invoking ANY command and only ever holds (declines with a
8842+
* notice), never closes. Off (`commandRateLimitPolicy: "off"`, the default) is a complete no-op.
8843+
*/
8844+
async function maybeThrottleGittensoryCommand(
8845+
env: Env,
8846+
args: {
8847+
deliveryId: string;
8848+
repoFullName: string;
8849+
issueNumber: number;
8850+
installationId: number;
8851+
commenter: string;
8852+
command: GittensoryMentionCommandName;
8853+
settings: RepositorySettings;
8854+
mode: ReturnType<typeof resolveAgentActionMode>;
8855+
},
8856+
): Promise<boolean> {
8857+
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete "off"/"hold"; the undefined side is defensive against the field's optional TS type. */
8858+
const policy = args.settings.commandRateLimitPolicy ?? "off";
8859+
if (policy === "off") return false;
8860+
8861+
const targetKey = `${args.repoFullName}#${args.issueNumber}#${args.command}`;
8862+
const aiCostBearing = isAiCostBearingCommand(args.command);
8863+
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete positive integer; the undefined side is defensive against the field's optional TS type. */
8864+
const maxPerWindow = aiCostBearing
8865+
? (args.settings.commandRateLimitAiMaxPerWindow ?? 5)
8866+
: (args.settings.commandRateLimitMaxPerWindow ?? 20);
8867+
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete positive integer; the undefined side is defensive against the field's optional TS type. */
8868+
const windowHours = args.settings.commandRateLimitWindowHours ?? 24;
8869+
const sinceIso = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
8870+
const priorInvocations = await countRecentAuditEventsForActorAndTarget(env, args.commenter, COMMAND_RATE_LIMIT_EVENT_TYPE, targetKey, sinceIso);
8871+
const invocationCount = priorInvocations + 1; // this invocation counts too
8872+
8873+
// Always record the invocation first so the running count reflects reality even when the rest of this
8874+
// handler short-circuits below (a failed recordAuditEvent must never block command dispatch).
8875+
await recordAuditEvent(env, {
8876+
eventType: COMMAND_RATE_LIMIT_EVENT_TYPE,
8877+
actor: args.commenter,
8878+
targetKey,
8879+
outcome: "completed",
8880+
detail: `invocation ${invocationCount}/${maxPerWindow} within ${windowHours}h window`,
8881+
metadata: { deliveryId: args.deliveryId, repoFullName: args.repoFullName, command: args.command, aiCostBearing },
8882+
}).catch(
8883+
/* v8 ignore next -- fail-safe: an audit write failure never blocks command dispatch */
8884+
() => undefined,
8885+
);
8886+
8887+
if (invocationCount <= maxPerWindow) return false; // under threshold — normal dispatch proceeds unchanged
8888+
8889+
if (args.mode === "live") {
8890+
await createIssueComment(
8891+
env,
8892+
args.installationId,
8893+
args.repoFullName,
8894+
args.issueNumber,
8895+
`@${args.commenter} the \`${args.command}\` command has reached its rate limit (${maxPerWindow} within ${windowHours}h). Please wait for the window to pass before trying again. This is an automated maintenance action.`,
8896+
).catch(
8897+
/* v8 ignore next -- fail-safe: a comment-post failure must not crash the throttle decision itself */
8898+
() => undefined,
8899+
);
8900+
}
8901+
await recordAuditEvent(env, {
8902+
eventType: "github_app.command_rate_limit_applied",
8903+
actor: "gittensory",
8904+
targetKey,
8905+
outcome: args.mode === "live" ? "completed" : "denied",
8906+
detail: `hold applied: ${args.commenter} invoked ${args.command} ${invocationCount} times (limit ${maxPerWindow})`,
8907+
metadata: { deliveryId: args.deliveryId, repoFullName: args.repoFullName, mode: args.mode, command: args.command },
8908+
}).catch(
8909+
/* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */
8910+
() => undefined,
8911+
);
8912+
return true;
8913+
}
8914+
88308915
async function maybeProcessGittensoryMentionCommand(
88318916
env: Env,
88328917
deliveryId: string,
@@ -9024,6 +9109,21 @@ async function maybeProcessGittensoryMentionCommand(
90249109
return true;
90259110
}
90269111

9112+
if (
9113+
await maybeThrottleGittensoryCommand(env, {
9114+
deliveryId,
9115+
repoFullName,
9116+
issueNumber: issue.number,
9117+
installationId,
9118+
commenter,
9119+
command: command.name,
9120+
settings,
9121+
mode: mentionMode,
9122+
})
9123+
) {
9124+
return true;
9125+
}
9126+
90279127
const answerId = crypto.randomUUID();
90289128
const login = pullRequestAuthor ?? commenter;
90299129
const maintainerDigest = isMaintainerQueueDigestCommand(command.name)

src/signals/focus-manifest.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ export type FocusManifestSettings = Partial<
150150
| "reviewNagCooldownDays"
151151
| "reviewNagLabel"
152152
| "autoCloseExemptLogins"
153+
| "commandRateLimitPolicy"
154+
| "commandRateLimitMaxPerWindow"
155+
| "commandRateLimitAiMaxPerWindow"
156+
| "commandRateLimitWindowHours"
153157
>
154158
>;
155159

@@ -864,6 +868,15 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
864868
warnings.push(...exemptWarnings);
865869
if (logins.length > 0) out.autoCloseExemptLogins = logins;
866870
}
871+
// Per-command @gittensory rate limit (#2560): generalizes review-nag's cooldown pattern to every command.
872+
const commandRateLimitPolicy = normalizeOptionalEnum(r.commandRateLimitPolicy, "settings.commandRateLimitPolicy", ["off", "hold"] as const, warnings);
873+
if (commandRateLimitPolicy !== null) out.commandRateLimitPolicy = commandRateLimitPolicy;
874+
const commandRateLimitMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitMaxPerWindow, "settings.commandRateLimitMaxPerWindow", warnings);
875+
if (commandRateLimitMaxPerWindow !== null) out.commandRateLimitMaxPerWindow = commandRateLimitMaxPerWindow;
876+
const commandRateLimitAiMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitAiMaxPerWindow, "settings.commandRateLimitAiMaxPerWindow", warnings);
877+
if (commandRateLimitAiMaxPerWindow !== null) out.commandRateLimitAiMaxPerWindow = commandRateLimitAiMaxPerWindow;
878+
const commandRateLimitWindowHours = normalizeOptionalPositiveInteger(r.commandRateLimitWindowHours, "settings.commandRateLimitWindowHours", warnings);
879+
if (commandRateLimitWindowHours !== null) out.commandRateLimitWindowHours = commandRateLimitWindowHours;
867880
return out;
868881
}
869882

src/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,28 @@ export type RepositorySettings = {
666666
* force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other
667667
* settings field (`.gittensory.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */
668668
requireFreshRebaseWindowMinutes?: number | null | undefined;
669+
/** Per-command @gittensory rate limit (#2560, anti-abuse): generalizes the review-nag cooldown's counting
670+
* pattern (the audit-events ledger) to EVERY `@gittensory` command, keyed by `(actor, command, targetKey)` --
671+
* independent of, and complementary to, review-nag's own narrower thread-author-only scope. `"off"` (default)
672+
* is a no-op; `"hold"` posts a deterministic cooldown reply and skips the command's own dispatch. Always
673+
* populated by the DB layer (default `"off"`); optional so existing settings fixtures/callers need not be
674+
* touched. */
675+
commandRateLimitPolicy?: "off" | "hold" | undefined;
676+
/** Per-command rate limit (#2560): how many invocations of a single command an actor may make within
677+
* {@link commandRateLimitWindowHours} before the (N+1)th is throttled -- for a CHEAP command (cache-only,
678+
* no AI orchestrator call). Always populated by the DB layer (default `20`); optional so existing settings
679+
* fixtures/callers need not be touched. Only meaningful when {@link commandRateLimitPolicy} is not `"off"`. */
680+
commandRateLimitMaxPerWindow?: number | undefined;
681+
/** Per-command rate limit (#2560): the same threshold as {@link commandRateLimitMaxPerWindow}, but for an
682+
* AI-cost-bearing command (dispatches to a real orchestrator call: `ask`, `blockers`, `preflight`,
683+
* `reviewability`, `packet`, `duplicate-check`, `next-action`, `repo-fit`). Deliberately tighter than the
684+
* cheap-command default. Always populated by the DB layer (default `5`); optional so existing settings
685+
* fixtures/callers need not be touched. */
686+
commandRateLimitAiMaxPerWindow?: number | undefined;
687+
/** Per-command rate limit (#2560): the rolling window (in hours) both {@link commandRateLimitMaxPerWindow}
688+
* and {@link commandRateLimitAiMaxPerWindow} count against. Always populated by the DB layer (default `24`);
689+
* optional so existing settings fixtures/callers need not be touched. */
690+
commandRateLimitWindowHours?: number | undefined;
669691
/** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default
670692
* `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers
671693
* need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */

0 commit comments

Comments
 (0)