Skip to content

Commit 0fc18f7

Browse files
committed
fix(provenance): record why a resolved-secret registry became incomplete
Incompleteness is one-way: once any guard trips, every later model projection in the run fails and the user is left with a single opaque sentence. Every guard could set it and none recorded which, in a file that imported no logger at all, so the cause could not be recovered after the fact. Name each guard with a static reason literal. Originating causes log at error because they permanently fail the run and error is the only level that survives every default the logger falls back to; reasons that merely carry an upstream fault forward log at warn so one fault does not read as several. The decrypt catch no longer discards its cause. No behaviour change. Reasons are static literals and the logged input path is block/field names; no resolved value is recorded.
1 parent cdeb83d commit 0fc18f7

2 files changed

Lines changed: 159 additions & 25 deletions

File tree

apps/sim/executor/utils/resolved-secret-trace-registry.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

3-
const { mockDecryptSecret } = vi.hoisted(() => ({
3+
const { mockDecryptSecret, mockLogger } = vi.hoisted(() => ({
44
mockDecryptSecret: vi.fn(),
5+
mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
56
}))
67

78
vi.mock('@/lib/core/security/encryption', () => ({
89
decryptSecret: mockDecryptSecret,
910
}))
1011

12+
vi.mock('@sim/logger', () => ({
13+
createLogger: () => mockLogger,
14+
}))
15+
1116
import {
1217
ANONYMOUS_SECRET_TRACE_REPLACEMENT,
1318
createResolvedSecretTraceRegistry,
@@ -1279,3 +1284,57 @@ describe('ResolvedSecretTraceRegistry', () => {
12791284
expect(registry.getModelEgressSnapshot()).toEqual({ complete: false })
12801285
})
12811286
})
1287+
1288+
describe('incompleteness diagnostics', () => {
1289+
const scope = { userId: 'user-1', workspaceId: 'workspace-1' }
1290+
1291+
beforeEach(() => {
1292+
mockLogger.warn.mockClear()
1293+
mockLogger.error.mockClear()
1294+
})
1295+
1296+
it('reports an originating incompleteness at error so the default log level cannot hide it', () => {
1297+
const registry = new ResolvedSecretTraceRegistry([], scope)
1298+
1299+
registry.markIncomplete('projection-mismatch')
1300+
1301+
expect(mockLogger.warn).not.toHaveBeenCalled()
1302+
expect(mockLogger.error).toHaveBeenCalledWith(
1303+
'Resolved secret registry marked incomplete',
1304+
expect.objectContaining({ reason: 'projection-mismatch' })
1305+
)
1306+
})
1307+
1308+
it('reports an inherited incompleteness at warn so one fault does not read as several', () => {
1309+
const registry = new ResolvedSecretTraceRegistry([], scope)
1310+
1311+
registry.markIncomplete('inherited-incomplete-source')
1312+
1313+
expect(mockLogger.error).not.toHaveBeenCalled()
1314+
expect(mockLogger.warn).toHaveBeenCalledWith(
1315+
'Resolved secret registry marked incomplete',
1316+
expect.objectContaining({ reason: 'inherited-incomplete-source' })
1317+
)
1318+
})
1319+
1320+
it('names the guard that tripped rather than reporting unspecified', () => {
1321+
const registry = new ResolvedSecretTraceRegistry([], scope)
1322+
1323+
registry.recordResolved('MISSING', 'value-not-in-catalog')
1324+
1325+
expect(mockLogger.error).toHaveBeenCalledWith(
1326+
'Resolved secret registry marked incomplete',
1327+
expect.objectContaining({ reason: 'unverified-resolved-entry' })
1328+
)
1329+
})
1330+
1331+
it('records no secret material alongside the reason', () => {
1332+
const registry = new ResolvedSecretTraceRegistry([], scope)
1333+
1334+
registry.recordResolved('MISSING', 'super-secret-value')
1335+
1336+
const logged = JSON.stringify(mockLogger.error.mock.calls)
1337+
expect(logged).not.toContain('super-secret-value')
1338+
expect(logged).not.toContain('MISSING')
1339+
})
1340+
})

apps/sim/executor/utils/resolved-secret-trace-registry.ts

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
13
import { decryptSecret } from '@/lib/core/security/encryption'
24
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
35
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
@@ -10,6 +12,49 @@ import {
1012
} from '@/executor/utils/resolved-secret-matcher'
1113
import { getResolvedSecretMatcherCapacityFailure } from '@/executor/utils/resolved-secret-matcher-capacity'
1214

15+
const logger = createLogger('ResolvedSecretTraceRegistry')
16+
17+
/**
18+
* Why a registry stopped being able to vouch for what it projects.
19+
*
20+
* Incompleteness is one-way and fails every later model projection in the run, surfacing to the
21+
* user as a single opaque sentence. Recording which guard tripped is the only way to tell a
22+
* genuine containment from a matcher that merely could not decide — the reasons are static
23+
* literals and the logged path is block/field names, never a resolved value.
24+
*/
25+
type ResolvedSecretIncompletenessReason =
26+
| 'untrusted-provenance'
27+
| 'source-provenance-incomplete'
28+
| 'entry-decrypt-failed'
29+
| 'unverified-resolved-entry'
30+
| 'projection-mismatch'
31+
| 'unresolved-placeholder'
32+
| 'provenance-capacity-exceeded'
33+
| 'restored-checkpoint-unavailable'
34+
| 'constructed-incomplete'
35+
| 'inherited-incomplete-source'
36+
| 'inherited-incomplete-input-path'
37+
| 'tool-call-scope-mismatch'
38+
| 'value-provenance-untrusted'
39+
| 'value-provenance-import-failed'
40+
| 'value-provenance-filter-incomplete'
41+
| 'unspecified'
42+
43+
/**
44+
* Reasons that carry an upstream fault forward rather than originating one.
45+
*
46+
* The origin already reported itself, so these log a level lower to stop one fault from reading
47+
* as several. Every other reason logs at error: incompleteness is one-way, so it permanently
48+
* fails the run's remaining model projections, and error is the only level that survives every
49+
* default the logger falls back to — production, test, and a self-hosted chart that sets no
50+
* `LOG_LEVEL` at all.
51+
*/
52+
const PROPAGATED_INCOMPLETENESS_REASONS = new Set<ResolvedSecretIncompletenessReason>([
53+
'inherited-incomplete-source',
54+
'inherited-incomplete-input-path',
55+
'source-provenance-incomplete',
56+
])
57+
1358
export const ANONYMOUS_SECRET_TRACE_REPLACEMENT = OPAQUE_RESOLVED_SECRET_REPLACEMENT
1459
export const RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION = 1
1560

@@ -552,7 +597,7 @@ export class ResolvedSecretTraceRegistry {
552597
this.scope = scope ? cloneProvenanceScope(scope) : undefined
553598
this.completeProvenanceEnvelopeBytes = serializedProvenanceEnvelopeByteSize(true, this.scope)
554599
if (this.completeProvenanceEnvelopeBytes > MAX_SERIALIZED_PROVENANCE_BYTES) {
555-
this.markIncomplete()
600+
this.markIncomplete('provenance-capacity-exceeded')
556601
}
557602
let catalogEntriesSeen = 0
558603
for (const entry of catalogEntries) {
@@ -580,7 +625,7 @@ export class ResolvedSecretTraceRegistry {
580625
}
581626
this.copyResolvedInputPathsTo(fork)
582627
this.copyIncompleteInputPathsTo(fork)
583-
if (!this.complete) fork.markIncomplete()
628+
if (!this.complete) fork.markIncomplete('inherited-incomplete-source')
584629
return fork
585630
}
586631

@@ -591,12 +636,12 @@ export class ResolvedSecretTraceRegistry {
591636
): ResolvedSecretTraceRegistry {
592637
const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope)
593638
if (!this.complete) {
594-
fork.markIncomplete()
639+
fork.markIncomplete('inherited-incomplete-source')
595640
return fork
596641
}
597642

598643
if (this.hasIncompleteInputPathOverlapping(paths)) {
599-
fork.markIncomplete()
644+
fork.markIncomplete('inherited-incomplete-input-path')
600645
return fork
601646
}
602647

@@ -620,14 +665,14 @@ export class ResolvedSecretTraceRegistry {
620665
fork.addActiveEntry({ ...entry }, { propagated: true })
621666
}
622667
}
623-
if (this.isPermanentlyIncomplete()) fork.markIncomplete()
668+
if (this.isPermanentlyIncomplete()) fork.markIncomplete('inherited-incomplete-source')
624669
return fork
625670
}
626671

627672
/** Merges one settled tool-call registry into the turn-scoped registry. */
628673
mergeToolCallRegistry(child: ResolvedSecretTraceRegistry): void {
629674
if (!scopesMatch(this.scope, child.scope) || !child.isComplete()) {
630-
this.markIncomplete()
675+
this.markIncomplete('tool-call-scope-mismatch')
631676
return
632677
}
633678

@@ -649,7 +694,7 @@ export class ResolvedSecretTraceRegistry {
649694
if (resolvedValue.length === 0) return false
650695
const entry = this.getVerifiedResolvedEntry(name, resolvedValue)
651696
if (!entry) {
652-
this.markIncomplete()
697+
this.markIncomplete('unverified-resolved-entry')
653698
return false
654699
}
655700

@@ -669,7 +714,7 @@ export class ResolvedSecretTraceRegistry {
669714

670715
const entry = this.getVerifiedResolvedEntry(name, resolvedValue)
671716
if (!entry) {
672-
this.markInputPathIncomplete(path)
717+
this.markInputPathIncomplete(path, 'unverified-resolved-entry')
673718
return false
674719
}
675720

@@ -790,7 +835,7 @@ export class ResolvedSecretTraceRegistry {
790835
typeof projectedValue === 'string' &&
791836
state.projectedValue !== projectedValue
792837
) {
793-
this.markInputPathIncomplete(path)
838+
this.markInputPathIncomplete(path, 'projection-mismatch')
794839
return
795840
}
796841
for (const entryKey of entryKeys) state.entryKeys.add(entryKey)
@@ -846,7 +891,7 @@ export class ResolvedSecretTraceRegistry {
846891
if (current.raw !== null && typeof current.raw === 'object') {
847892
const standaloneName = canonicalPlaceholderName(current.projected as string)
848893
if (!standaloneName || !entryKeysByName.has(standaloneName)) {
849-
this.markInputPathIncomplete(current.path)
894+
this.markInputPathIncomplete(current.path, 'unresolved-placeholder')
850895
return
851896
}
852897
recordProjectedMarkerAcrossRawLeaves(
@@ -994,12 +1039,12 @@ export class ResolvedSecretTraceRegistry {
9941039
options: ImportResolvedSecretTraceProvenanceOptions
9951040
): Promise<boolean> {
9961041
if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) {
997-
this.markIncomplete()
1042+
this.markIncomplete('untrusted-provenance')
9981043
return false
9991044
}
10001045

10011046
if (!provenance.complete) {
1002-
this.markIncomplete()
1047+
this.markIncomplete('source-provenance-incomplete')
10031048
}
10041049

10051050
const sameScope = scopesMatch(provenance.scope, this.scope)
@@ -1016,9 +1061,14 @@ export class ResolvedSecretTraceRegistry {
10161061
},
10171062
{ propagated: true }
10181063
)
1019-
} catch {
1064+
} catch (error) {
10201065
importedAll = false
1021-
this.markIncomplete()
1066+
logger.error('Provenance entry could not be decrypted', {
1067+
error: getErrorMessage(error, 'Unknown error'),
1068+
named: entry.name !== undefined,
1069+
scopeWorkspaceId: this.scope?.workspaceId,
1070+
})
1071+
this.markIncomplete('entry-decrypt-failed')
10221072
}
10231073
}
10241074

@@ -1058,19 +1108,19 @@ export class ResolvedSecretTraceRegistry {
10581108
options: { trusted: boolean; inputPath?: ResolvedSecretInputPath }
10591109
): Promise<ImportResolvedSecretTraceProvenanceForValueResult> {
10601110
if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) {
1061-
this.markInputPathIncomplete(options.inputPath)
1111+
this.markInputPathIncomplete(options.inputPath, 'value-provenance-untrusted')
10621112
return { success: false, matched: false }
10631113
}
10641114

10651115
const sourceRegistry = new ResolvedSecretTraceRegistry([], provenance.scope)
10661116
const sourceImported = await sourceRegistry.importProvenance(provenance, { trusted: true })
10671117
const filteredProvenance = sourceRegistry.exportProvenanceForValue(value)
10681118
if (!sourceImported) {
1069-
this.markInputPathIncomplete(options.inputPath)
1119+
this.markInputPathIncomplete(options.inputPath, 'value-provenance-import-failed')
10701120
return { success: false, matched: false }
10711121
}
10721122
if (!filteredProvenance.complete) {
1073-
this.markInputPathIncomplete(options.inputPath)
1123+
this.markInputPathIncomplete(options.inputPath, 'value-provenance-filter-incomplete')
10741124
return { success: true, matched: false }
10751125
}
10761126
const filteredImported = await this.importProvenance(filteredProvenance, { trusted: true })
@@ -1214,10 +1264,19 @@ export class ResolvedSecretTraceRegistry {
12141264
return !this.complete || this.incompleteInputPaths.size > 0
12151265
}
12161266

1217-
markIncomplete(): void {
1267+
markIncomplete(reason: ResolvedSecretIncompletenessReason = 'unspecified'): void {
12181268
if (!this.complete) return
12191269
this.complete = false
12201270
this.modelEgressRevision += 1
1271+
const details = {
1272+
reason,
1273+
scopeWorkspaceId: this.scope?.workspaceId,
1274+
activeEntryCount: this.activeEntries.size,
1275+
incompleteInputPathCount: this.incompleteInputPaths.size,
1276+
}
1277+
const message = 'Resolved secret registry marked incomplete'
1278+
if (PROPAGATED_INCOMPLETENESS_REASONS.has(reason)) logger.warn(message, details)
1279+
else logger.error(message, details)
12211280
}
12221281

12231282
/**
@@ -1388,7 +1447,11 @@ export class ResolvedSecretTraceRegistry {
13881447
matcher = createResolvedSecretMatcher(
13891448
[...candidatesByScanLiteral.keys()].map((plaintext) => ({ plaintext, replacement: '' }))
13901449
)
1391-
} catch {
1450+
} catch (error) {
1451+
logger.error('Provenance filter matcher could not be built', {
1452+
error: getErrorMessage(error, 'Unknown error'),
1453+
candidateCount: candidatesByScanLiteral.size,
1454+
})
13921455
return { complete: false }
13931456
}
13941457

@@ -1623,15 +1686,27 @@ export class ResolvedSecretTraceRegistry {
16231686
)
16241687
}
16251688

1626-
private markInputPathIncomplete(path: ResolvedSecretInputPath | undefined): void {
1689+
private markInputPathIncomplete(
1690+
path: ResolvedSecretInputPath | undefined,
1691+
reason: ResolvedSecretIncompletenessReason = 'unspecified'
1692+
): void {
16271693
if (!path || path.length === 0) {
1628-
this.markIncomplete()
1694+
this.markIncomplete(reason)
16291695
return
16301696
}
16311697
const key = inputPathKey(path)
16321698
if (this.incompleteInputPaths.has(key)) return
16331699
this.incompleteInputPaths.set(key, [...path])
16341700
this.modelEgressRevision += 1
1701+
const details = {
1702+
reason,
1703+
inputPath: path.join('.'),
1704+
scopeWorkspaceId: this.scope?.workspaceId,
1705+
activeEntryCount: this.activeEntries.size,
1706+
}
1707+
const message = 'Resolved secret input path marked incomplete'
1708+
if (PROPAGATED_INCOMPLETENESS_REASONS.has(reason)) logger.warn(message, details)
1709+
else logger.error(message, details)
16351710
}
16361711

16371712
private copyIncompleteInputPathsTo(
@@ -1672,7 +1747,7 @@ export class ResolvedSecretTraceRegistry {
16721747
entryBytes >
16731748
MAX_SERIALIZED_PROVENANCE_BYTES
16741749
) {
1675-
this.markIncomplete()
1750+
this.markIncomplete('provenance-capacity-exceeded')
16761751
return
16771752
}
16781753
this.activeEntries.set(key, entry)
@@ -1733,7 +1808,7 @@ export async function createResolvedSecretTraceRegistry(
17331808
options.restoreTrusted === true &&
17341809
options.restoredCheckpointVersion !== undefined
17351810
) {
1736-
registry.markIncomplete()
1811+
registry.markIncomplete('restored-checkpoint-unavailable')
17371812
}
17381813

17391814
return registry
@@ -1744,6 +1819,6 @@ export function createIncompleteResolvedSecretTraceRegistry(
17441819
scope?: ResolvedSecretTraceScopeV1
17451820
): ResolvedSecretTraceRegistry {
17461821
const registry = new ResolvedSecretTraceRegistry([], scope)
1747-
registry.markIncomplete()
1822+
registry.markIncomplete('constructed-incomplete')
17481823
return registry
17491824
}

0 commit comments

Comments
 (0)