Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions services/sla_enforcement/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A configurable Linear agent that protects issues from unauthorized changes. Auto
|---------|-------------|
| **Protected Labels** | Configure any labels (e.g., "Vulnerability", "Security Critical") that cannot be removed by unauthorized users |
| **SLA Protection** | Monitors all 5 SLA fields: type, start date, medium risk, high risk, breach date |
| **SLA Created-At Baseline** | Enforces that `slaStartedAt` always equals the issue's creation date — catches silent resets from priority changes or workflow triggers |
| **Priority Protection** | Prevent unauthorized priority changes (Urgent, High, Normal, Low) |
| **Label Hierarchy** | Detects labels in both top-level and label groups |
| **Allowlist** | Define authorized users by email or Linear user ID |
Expand Down Expand Up @@ -70,7 +71,8 @@ Edit `config/config.json`:
"protectedFields": {
"label": true,
"sla": true,
"priority": true
"priority": true,
"slaCreatedAtBaseline": false
},
"allowlist": [
{ "email": "security-lead@yourcompany.com", "name": "Security Lead" },
Expand Down Expand Up @@ -151,13 +153,28 @@ Add any label names you want to protect. Case-sensitive.
"protectedFields": {
"label": true,
"sla": true,
"priority": true
"priority": true,
"slaCreatedAtBaseline": false
}
}
```

Set individual fields to `false` to disable protection for that field type.

#### `slaCreatedAtBaseline`

When set to `true`, the agent enforces that `slaStartedAt` always equals the issue's `createdAt` (the date the issue was created in Linear). This is the strictest form of SLA clock protection.

**Why this matters:** Linear can silently reset `slaStartedAt` when other fields change — for example, changing an issue's priority may trigger a workflow that recalculates and overwrites the SLA start date. The standard `sla` protection only catches changes that appear explicitly in the webhook's `updatedFrom` payload. `slaCreatedAtBaseline` catches *all* drift, including silent resets, by comparing the current `slaStartedAt` against the cached `createdAt` on every webhook.

**How it works:**
1. On startup, the agent fetches `createdAt` for every issue with a protected label and stores it as an immutable baseline in its cache.
2. On every subsequent webhook for a protected issue, the agent compares `slaStartedAt` against the cached `createdAt`.
3. If they differ and the actor is not in the allowlist, the agent reverts `slaStartedAt` back to `createdAt`.
4. The baseline is never overwritten — even authorized changes do not update the `createdAt` target.

**When to use:** Enable this when you need to guarantee that the SLA clock always reflects the true issue creation date and cannot be gamed by indirect changes (e.g., priority bumps, label swaps, or workflow triggers).

### Allowlist

Users who are authorized to make changes to protected fields:
Expand Down
3 changes: 2 additions & 1 deletion services/sla_enforcement/config/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"protectedFields": {
"label": true,
"sla": true,
"priority": true
"priority": true,
"slaCreatedAtBaseline": false
},
"allowlist": [
{
Expand Down
93 changes: 74 additions & 19 deletions services/sla_enforcement/src/enforcement-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface CacheEntry {
slaStartedAt: string | null;
slaBreachesAt: string | null;
priority?: number;
createdAt: string | null; // immutable baseline — never overwritten after first cache
cachedAt: string; // ISO string for JSON serialization
}

Expand All @@ -37,12 +38,13 @@ interface PersistentCache {
export class EnforcementEngine {
// In-memory cache of SLA and priority states for protected issues
// Maps issueId -> { slaType, slaStartedAt, slaBreachesAt, priority, cachedAt }
private slaCache: Map<string, {
slaType: string | null;
private slaCache: Map<string, {
slaType: string | null;
slaStartedAt: string | null;
slaBreachesAt: string | null;
priority?: number;
cachedAt: Date
createdAt: string | null; // immutable baseline — set once, never updated
cachedAt: Date
}> = new Map();

private cacheFilePath: string;
Expand Down Expand Up @@ -75,6 +77,7 @@ export class EnforcementEngine {
slaStartedAt: entry.slaStartedAt,
slaBreachesAt: entry.slaBreachesAt,
priority: entry.priority,
createdAt: entry.createdAt ?? null,
cachedAt: new Date(entry.cachedAt)
});
}
Expand Down Expand Up @@ -119,43 +122,47 @@ export class EnforcementEngine {
const issues = await this.linearClient.getIssuesWithLabel(label.id);

for (const issue of issues) {
if (issue.slaType && issue.slaStartedAt && issue.slaBreachesAt) {
const existingEntry = this.slaCache.get(issue.id);

// Update cache if:
// 1. Issue not in cache yet, OR
// 2. Current SLA in Linear is DIFFERENT from cached (Linear was updated)
const shouldUpdate = !existingEntry ||
const existingEntry = this.slaCache.get(issue.id);

// Always cache if issue has slaStartedAt or createdAt — we need
// createdAt as the immutable baseline even when SLA is not fully set.
if (issue.slaType && issue.slaStartedAt && issue.slaBreachesAt || issue.createdAt) {
// Update SLA/priority fields if they changed, but NEVER overwrite createdAt
// once it has been set — it is the immutable baseline.
const shouldUpdate = !existingEntry ||
existingEntry.slaBreachesAt !== issue.slaBreachesAt ||
existingEntry.priority !== issue.priority;

if (shouldUpdate) {
this.slaCache.set(issue.id, {
slaType: issue.slaType,
slaStartedAt: issue.slaStartedAt,
slaBreachesAt: issue.slaBreachesAt,
slaType: issue.slaType || null,
slaStartedAt: issue.slaStartedAt || null,
slaBreachesAt: issue.slaBreachesAt || null,
priority: issue.priority,
// Preserve existing createdAt if already cached — never overwrite
createdAt: existingEntry?.createdAt ?? issue.createdAt ?? null,
cachedAt: new Date()
});
cachedCount++;
logger.info('Cached/updated issue SLA on startup', {

logger.info('Cached/updated issue on startup', {
issueId: issue.id,
identifier: issue.identifier,
slaType: issue.slaType,
slaBreachesAt: issue.slaBreachesAt,
createdAt: issue.createdAt,
priority: issue.priority,
wasUpdate: !!existingEntry
});
} else {
logger.debug('Issue SLA unchanged, keeping cached values', {
logger.debug('Issue unchanged, keeping cached values', {
issueId: issue.id,
identifier: issue.identifier
});
}
} else {
skippedCount++;
logger.debug('Issue has no SLA, skipping', {
logger.debug('Issue has no SLA or createdAt, skipping', {
issueId: issue.id,
identifier: issue.identifier
});
Expand Down Expand Up @@ -202,6 +209,7 @@ export class EnforcementEngine {
slaStartedAt: entry.slaStartedAt,
slaBreachesAt: entry.slaBreachesAt,
priority: entry.priority,
createdAt: entry.createdAt ?? null,
cachedAt: entry.cachedAt.toISOString()
};
}
Expand Down Expand Up @@ -538,11 +546,14 @@ export class EnforcementEngine {
slaBreachesAt?: string | null;
priority?: number;
}): void {
// Preserve createdAt — it is the immutable baseline and must never be overwritten
const existing = this.slaCache.get(issueId);
this.slaCache.set(issueId, {
slaType: values.slaType || null,
slaStartedAt: values.slaStartedAt || null,
slaBreachesAt: values.slaBreachesAt || null,
priority: values.priority,
createdAt: existing?.createdAt ?? null,
cachedAt: new Date()
});

Expand Down Expand Up @@ -809,6 +820,37 @@ export class EnforcementEngine {
}
}

// Canonical slaStartedAt baseline check
// When enabled, slaStartedAt must always equal the issue's createdAt.
// This catches silent resets (e.g. from priority-triggered workflows) that
// don't appear in updatedFrom and would otherwise go undetected.
if (this.config.protectedFields.slaCreatedAtBaseline) {
const cached = this.slaCache.get(current.id);
if (cached?.createdAt && current.slaStartedAt !== cached.createdAt) {
const alreadyDetected = changes.find(c => c.field === 'slaStartedAt');
if (alreadyDetected) {
// Override the revert target to createdAt rather than the previous value
alreadyDetected.oldValue = cached.createdAt;
alreadyDetected.description = `SLA start date changed from issue creation date`;
alreadyDetected.revertDescription = `Restored SLA start date to issue creation date (${cached.createdAt})`;
} else {
// Not caught by the updatedFrom check — add it now
changes.push({
field: 'slaStartedAt',
oldValue: cached.createdAt,
newValue: current.slaStartedAt,
description: `SLA start date (${current.slaStartedAt ?? 'unset'}) differs from issue creation date (${cached.createdAt})`,
revertDescription: `Restored SLA start date to issue creation date (${cached.createdAt})`
});
logger.info('Canonical baseline: detected slaStartedAt drift', {
issueId: current.id,
currentSlaStartedAt: current.slaStartedAt,
expectedCreatedAt: cached.createdAt
});
}
}
}

return changes;
}

Expand Down Expand Up @@ -895,11 +937,24 @@ export class EnforcementEngine {
}
// NO FALLBACK to currentState - that has the wrong (workflow-generated) values!

// When baseline mode is on, always use createdAt as slaStartedAt regardless
// of what previous state or cache says
if (this.config.protectedFields.slaCreatedAtBaseline) {
const cached = this.slaCache.get(issueId);
if (cached?.createdAt) {
slaStartedAt = cached.createdAt;
logger.info('slaCreatedAtBaseline: overriding slaStartedAt with createdAt for priority revert', {
issueId,
createdAt: cached.createdAt
});
}
}

if (slaType && slaStartedAt && slaBreachesAt) {
update.slaType = slaType;
update.slaStartedAt = typeof slaStartedAt === 'string' ? new Date(slaStartedAt) : slaStartedAt;
update.slaBreachesAt = typeof slaBreachesAt === 'string' ? new Date(slaBreachesAt) : slaBreachesAt;

logger.info('Restoring SLA to prevent workflow recalculation after priority revert', {
issueId,
slaType: update.slaType,
Expand Down
2 changes: 2 additions & 0 deletions services/sla_enforcement/src/linear-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export class LinearClient {
slaMediumRiskAt
slaHighRiskAt
slaBreachesAt
createdAt
labels {
nodes {
id
Expand All @@ -513,6 +514,7 @@ export class LinearClient {
slaMediumRiskAt: issue.slaMediumRiskAt || null,
slaHighRiskAt: issue.slaHighRiskAt || null,
slaBreachesAt: issue.slaBreachesAt || null,
createdAt: issue.createdAt || null,
labels: issue.labels?.nodes?.map((l: any) => ({
id: l.id,
name: l.name
Expand Down
1 change: 1 addition & 0 deletions services/sla_enforcement/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Config {
label: boolean;
sla: boolean;
priority: boolean;
slaCreatedAtBaseline?: boolean;
};
allowlist: AllowlistUser[];
agent: AgentConfig;
Expand Down
Loading