Skip to content

Commit 9d879b8

Browse files
committed
improvement(knowledge): read stored ACL expiry without a flag, and keep it current on renewal
Scope renewal extends an observation without changing who observes a document, so it now carries that into the document's stored expiry instead of leaving it behind. The read predicate falls back to proving freshness from the evidence when a row carries no expiry, which is what an in-flight deploy or an unfinished backfill leaves, so the change needs no rollout flag: `knowledge-acl-expiry` and its scope plumbing are gone. The backfill re-checks the row is still unset inside its update, so a writer that fills one between the page read and the lock keeps its own value.
1 parent ef5bbe1 commit 9d879b8

13 files changed

Lines changed: 120 additions & 104 deletions

File tree

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,6 @@ export const env = createEnv({
630630
CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally
631631
KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally
632632
KNOWLEDGE_TIN_KEYWORD: z.boolean().optional(), // Rank large-scope keyword retrieval through the Tin text index where it exists
633-
KNOWLEDGE_ACL_EXPIRY: z.boolean().optional(), // Read mirrored-permission freshness from document.acl_valid_until instead of per-candidate evidence
634633

635634
// Organizations - for self-hosted deployments
636635
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)

‎apps/sim/lib/core/config/feature-flags.test.ts‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -177,19 +177,6 @@ describe('isFeatureEnabled', () => {
177177
})
178178
})
179179

180-
describe('knowledge-acl-expiry flag', () => {
181-
it('is a global switch', async () => {
182-
expect(await isFeatureEnabled('knowledge-acl-expiry')).toBe(false)
183-
envRef.KNOWLEDGE_ACL_EXPIRY = true
184-
expect(await isFeatureEnabled('knowledge-acl-expiry')).toBe(true)
185-
})
186-
187-
it('follows an AppConfig global rule', async () => {
188-
withAppConfig({ 'knowledge-acl-expiry': { enabled: true } })
189-
expect(await isFeatureEnabled('knowledge-acl-expiry')).toBe(true)
190-
})
191-
})
192-
193180
describe('knowledge-member-access flag', () => {
194181
it('uses a global fallback switch off AppConfig', async () => {
195182
expect(await isFeatureEnabled('knowledge-member-access')).toBe(false)

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,6 @@ const FEATURE_FLAGS = {
9393
'KNOWLEDGE_MEMBER_ACCESS.',
9494
fallback: 'KNOWLEDGE_MEMBER_ACCESS',
9595
},
96-
'knowledge-acl-expiry': {
97-
description:
98-
'Read mirrored-permission freshness from document.acl_valid_until, written by every ACL ' +
99-
'writer, instead of re-deriving it per candidate from verification and observation ' +
100-
'timestamps. Strictly no wider than the per-candidate proof: a stored expiry is never ' +
101-
'later than the evidence behind it. Requires the 0020 backfill. Off-AppConfig falls back ' +
102-
'to KNOWLEDGE_ACL_EXPIRY.',
103-
fallback: 'KNOWLEDGE_ACL_EXPIRY',
104-
},
10596
'knowledge-tin-keyword': {
10697
description:
10798
'Rank keyword retrieval for members whose permitted set is too large to enumerate through ' +

‎apps/sim/lib/knowledge/access/predicate.postgres.test.ts‎

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: {} }))
2121
const { drizzle } = await import('drizzle-orm/postgres-js')
2222
const schema = await import('@sim/db/schema')
2323
const { persistDocumentAcls } = await import('@/lib/knowledge/connectors/sync-persistence')
24-
const { materializeDocumentAcls } = await import('@/lib/knowledge/connectors/member-observations')
24+
const { materializeDocumentAcls, refreshObservedAclExpiry } = await import(
25+
'@/lib/knowledge/connectors/member-observations'
26+
)
2527
const { mergeMirroredAcls, hideUnlistedDocuments } = await import(
2628
'@/lib/knowledge/connectors/mirrored-acls'
2729
)
@@ -88,8 +90,7 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
8890
join = false,
8991
githubInstallationGrants?: GitHubInstallationReadGrant[],
9092
userId = 'reader',
91-
confluenceSiteGrants?: ConfluenceSiteReadGrant[],
92-
storedAclFreshness = false
93+
confluenceSiteGrants?: ConfluenceSiteReadGrant[]
9394
): Promise<boolean> {
9495
const query = new PgDialect().sqlToQuery(
9596
knowledgeAccessCondition({
@@ -98,7 +99,6 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
9899
tokens,
99100
githubInstallationGrants,
100101
confluenceSiteGrants,
101-
storedAclFreshness,
102102
})
103103
)
104104
const values = query.params.map((value: unknown) => {
@@ -504,10 +504,8 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
504504
* with the earliest observation it names, so one stale observer hides the document from readers
505505
* whose own observation is still current, until the sweep drops it and re-materializes.
506506
*/
507-
it('decides mirrored freshness from the row, never admitting what per-candidate evidence refuses', async () => {
508-
const proof = (tokens: string[], id: string) => readable(tokens, id)
509-
const stored = (tokens: string[], id: string) =>
510-
readable(tokens, id, false, undefined, 'reader', undefined, true)
507+
it('decides mirrored freshness from the row, falling back to evidence only where none is stored', async () => {
508+
const stored = (tokens: string[], id: string) => readable(tokens, id)
511509

512510
await putDocument('admin-fresh', ['u:alice@corp.com'])
513511
await connection.unsafe(
@@ -550,20 +548,32 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
550548
['members-fresh', [alice], true],
551549
['members-stale', [alice], false],
552550
] as const) {
553-
expect(await proof([...tokens], id)).toBe(expected)
554551
expect(await stored([...tokens], id)).toBe(expected)
555552
}
556-
/** Stricter, never wider: the fresh observer waits for the sweep. */
557-
expect(await proof([alice], 'members-mixed')).toBe(true)
553+
/** Stricter than the evidence, never wider: the fresh observer waits for the sweep. */
558554
expect(await stored([alice], 'members-mixed')).toBe(false)
559-
expect(await proof([bob], 'members-mixed')).toBe(false)
560555
expect(await stored([bob], 'members-mixed')).toBe(false)
561-
/** A row the backfill has not reached yet is unreadable rather than assumed current. */
562-
expect(await proof(['u:alice@corp.com'], 'admin-unbackfilled')).toBe(true)
556+
/**
557+
* Scope renewal extends an observation without changing who observes the document; carrying
558+
* that into the stored expiry is what makes the document readable again.
559+
*/
560+
await connection.unsafe(
561+
`UPDATE knowledge_document_observation SET last_seen_at = statement_timestamp()
562+
WHERE document_id = 'members-mixed' AND member_id = 'bob'`
563+
)
564+
expect(await stored([alice], 'members-mixed')).toBe(false)
565+
await refreshObservedAclExpiry('members', ['members-mixed'], drizzle(connection, { schema }))
566+
expect(await stored([alice], 'members-mixed')).toBe(true)
567+
expect(await stored([bob], 'members-mixed')).toBe(true)
568+
569+
/** A row no current writer has touched still proves freshness from its evidence. */
570+
expect(await stored(['u:alice@corp.com'], 'admin-unbackfilled')).toBe(true)
571+
await connection.unsafe(
572+
"UPDATE document SET acl_verified_at = statement_timestamp() - interval '25 hours' WHERE id = 'admin-unbackfilled'"
573+
)
563574
expect(await stored(['u:alice@corp.com'], 'admin-unbackfilled')).toBe(false)
564575
/** Uploads and workspace-mode documents carry no expiry and are unaffected. */
565576
await connection.unsafe("INSERT INTO document(id) VALUES ('upload')")
566-
expect(await proof(['ws'], 'upload')).toBe(true)
567577
expect(await stored(['ws'], 'upload')).toBe(true)
568578
})
569579

@@ -608,7 +618,19 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
608618
expect(await readable([page], 'persisted')).toBe(false)
609619
expect(await readable([space, page], 'persisted')).toBe(true)
610620
await connection.unsafe(
611-
"UPDATE document SET acl_verified_at = statement_timestamp() - interval '25 hours'"
621+
`UPDATE document SET acl_verified_at = statement_timestamp() - interval '25 hours',
622+
acl_valid_until = statement_timestamp() - interval '1 hour'`
623+
)
624+
expect(await readable([space, page], 'persisted')).toBe(false)
625+
await persistDocumentAcls('admin', input, executor)
626+
expect(await readable([space, page], 'persisted')).toBe(true)
627+
/**
628+
* A rolling-deploy writer that knows only the evidence column leaves the stored expiry behind,
629+
* and the document waits for a current writer instead of being read from stale state.
630+
*/
631+
await connection.unsafe(
632+
`UPDATE document SET acl_verified_at = statement_timestamp(),
633+
acl_valid_until = statement_timestamp() - interval '1 hour'`
612634
)
613635
expect(await readable([space, page], 'persisted')).toBe(false)
614636
await persistDocumentAcls('admin', input, executor)

‎apps/sim/lib/knowledge/access/predicate.ts‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,18 +223,21 @@ function storedKnowledgeAccessCondition(
223223
const cutoff = aclFreshnessCutoff()
224224
/**
225225
* Mirrored permissions are current while the row says so. Every writer of an ACL records when
226-
* its evidence expires, so a candidate is checked with one comparison instead of a lookup per
227-
* document into its connector's members and observations. The stored expiry is never later than
228-
* the evidence it was written from: a members-mode document expires with the earliest
229-
* observation its ACL names, so this can hide a document the per-candidate proof would still
230-
* admit, and can never admit one it would refuse.
226+
* its evidence expires, so a candidate costs one comparison instead of a lookup per document
227+
* into its connector's members and observations. The stored expiry is never later than the
228+
* evidence it was written from: a members-mode document expires with the earliest observation
229+
* its ACL names, so this can hide a document the evidence would still admit, and can never
230+
* admit one it would refuse.
231+
*
232+
* A row without an expiry is one no current writer has touched — the rollout's backfill has not
233+
* reached it, or an older app version wrote its ACL during a deploy — so it falls back to
234+
* proving freshness from the evidence itself. That branch is dead once every row carries an
235+
* expiry.
231236
*/
232-
const mirroredIsCurrent = (mode: 'admin' | 'members'): SQL =>
233-
scope.storedAclFreshness
234-
? sql`${knowledgeConnector.accessMode} = ${mode} AND ${document.aclValidUntil} > statement_timestamp()`
235-
: mode === 'admin'
236-
? sql`${knowledgeConnector.accessMode} = 'admin' AND ${document.aclVerifiedAt} > ${cutoff}`
237-
: sql`${knowledgeConnector.accessMode} = 'members' AND ${memberObservationCondition(tokens, cutoff)}`
237+
const isCurrent = (evidence: SQL): SQL => sql`(
238+
${document.aclValidUntil} > statement_timestamp()
239+
OR (${document.aclValidUntil} IS NULL AND ${evidence})
240+
)`
238241
return sql`(
239242
${aclOverlap(tokens)}
240243
AND NOT EXISTS (
@@ -254,8 +257,8 @@ function storedKnowledgeAccessCondition(
254257
AND (
255258
(${knowledgeConnector.accessMode} = 'workspace' AND ${document.acl} = ARRAY['ws']::text[])
256259
OR (${document.acl} <> ARRAY['ws']::text[] AND (
257-
(${mirroredIsCurrent('admin')})
258-
OR (${mirroredIsCurrent('members')})
260+
(${knowledgeConnector.accessMode} = 'admin' AND ${isCurrent(sql`${document.aclVerifiedAt} > ${cutoff}`)})
261+
OR (${knowledgeConnector.accessMode} = 'members' AND ${isCurrent(memberObservationCondition(tokens, cutoff))})
259262
))
260263
)
261264
)

‎apps/sim/lib/knowledge/access/scope.ts‎

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
import { createLogger } from '@sim/logger'
1717
import { getErrorMessage } from '@sim/utils/errors'
1818
import { and, eq, gte, inArray, isNull, or, sql } from 'drizzle-orm'
19-
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
2019
import { OrchestrationError } from '@/lib/core/orchestration/types'
2120
import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope'
2221
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
@@ -356,46 +355,25 @@ async function resolveKnowledgeIdentity(
356355
if (subject?.kind !== 'sim_user') {
357356
if (context.organizationId)
358357
throw new OrchestrationError('forbidden', 'Organization search requires a user subject')
359-
const storedAclFreshness = await storedAclFreshnessEnabled()
360-
return {
361-
/** The shared constant while the rollout has not reached this request. */
362-
access: storedAclFreshness.storedAclFreshness
363-
? { ...WORKSPACE_ACCESS_SCOPE, ...storedAclFreshness }
364-
: WORKSPACE_ACCESS_SCOPE,
365-
githubReaders: [],
366-
confluenceReaders: [],
367-
}
358+
return { access: WORKSPACE_ACCESS_SCOPE, githubReaders: [], confluenceReaders: [] }
368359
}
369360
return resolveUserKnowledgeIdentity(subject.userId, context)
370361
}
371362

372363
async function resolveUserKnowledgeIdentity(userId: string, context: KnowledgeAccessScopeContext) {
373364
resourceScopeFromOwner(context)
374-
const [{ tokens, githubReaders = [], confluenceReaders = [] }, storedAclFreshness] =
375-
await Promise.all([loadUserAccess(userId, context), storedAclFreshnessEnabled()])
365+
const {
366+
tokens,
367+
githubReaders = [],
368+
confluenceReaders = [],
369+
} = await loadUserAccess(userId, context)
376370
return {
377-
access: { kind: 'user' as const, userId, tokens, ...storedAclFreshness },
371+
access: { kind: 'user' as const, userId, tokens },
378372
githubReaders,
379373
confluenceReaders,
380374
}
381375
}
382376

383-
/**
384-
* Whether this request reads mirrored-permission freshness from the document row. Resolved once
385-
* per scope so the predicate stays synchronous; a configuration read that fails keeps the
386-
* per-candidate proof, which is correct and only slower.
387-
*/
388-
async function storedAclFreshnessEnabled(): Promise<{ storedAclFreshness?: true }> {
389-
try {
390-
return (await isFeatureEnabled('knowledge-acl-expiry')) ? { storedAclFreshness: true } : {}
391-
} catch (error) {
392-
logger.warn('Feature flag read failed; deriving ACL freshness per candidate', {
393-
error: getErrorMessage(error),
394-
})
395-
return {}
396-
}
397-
}
398-
399377
/**
400378
* The scope of a person identified only by user id — the shape session-backed
401379
* routes outside the application layer have in hand. Never call this with a
@@ -406,11 +384,7 @@ export async function resolveUserKnowledgeAccessScope(
406384
userId: string,
407385
workspaceId: string | undefined
408386
): Promise<KnowledgeAccessScope> {
409-
const [access, storedAclFreshness] = await Promise.all([
410-
loadUserAccess(userId, { workspaceId }),
411-
storedAclFreshnessEnabled(),
412-
])
413-
return { kind: 'user', userId, tokens: access.tokens, ...storedAclFreshness }
387+
return { kind: 'user', userId, tokens: (await loadUserAccess(userId, { workspaceId })).tokens }
414388
}
415389

416390
/** Memoises {@link resolveKnowledgeAccessScope} for one operation; a failed lookup is retried on the next call. */

‎apps/sim/lib/knowledge/access/types.ts‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ export const WORKSPACE_ACCESS_TOKENS = [PUBLIC_ACCESS_TOKEN, WORKSPACE_ACCESS_TO
3030
export interface WorkspaceAccessScope {
3131
kind: 'workspace'
3232
tokens: typeof WORKSPACE_ACCESS_TOKENS
33-
/** See {@link UserAccessScope.storedAclFreshness}. */
34-
storedAclFreshness?: true
3533
}
3634

3735
export interface UserAccessScope {
@@ -47,14 +45,6 @@ export interface UserAccessScope {
4745
githubInstallationGrants?: readonly GitHubInstallationReadGrant[]
4846
/** Current reader access to the immutable Confluence site behind a central crawl. */
4947
confluenceSiteGrants?: readonly ConfluenceSiteReadGrant[]
50-
/**
51-
* Whether mirrored-permission freshness is read from `document.acl_valid_until` instead of
52-
* being re-derived per candidate from observation and verification timestamps. Resolved once
53-
* per scope from the `knowledge-acl-expiry` rollout flag, so a predicate stays synchronous and
54-
* every surface that carries a scope agrees on one answer for the whole request. Absent while
55-
* the flag is off, so a scope is unchanged until the rollout reaches it.
56-
*/
57-
storedAclFreshness?: true
5848
}
5949

6050
export interface ConfluenceSiteReadGrant {

‎apps/sim/lib/knowledge/connectors/member-observations.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ describe('renewMemberObservationsInScopes', () => {
9191
renewed: 2,
9292
finished: true,
9393
})
94-
expect(renewedIds()).toEqual([['d-kept', 'd-dm']])
94+
/** The renewed documents are re-materialized too, so their stored expiry follows. */
95+
expect(renewedIds()).toEqual([
96+
['d-kept', 'd-dm'],
97+
['d-kept', 'd-dm'],
98+
])
9599
})
96100

97101
it('matches every id under a broader scope that covers a narrower one', async () => {
@@ -101,7 +105,11 @@ describe('renewMemberObservationsInScopes', () => {
101105
])
102106
dbChainMockFns.returning.mockResolvedValueOnce([{ documentId: 'd-1' }, { documentId: 'd-2' }])
103107
await renew(['slack:v4:T1:C2:', 'slack:v4:T1:'])
104-
expect(renewedIds()).toEqual([['d-1', 'd-2']])
108+
/** The renewed documents are re-materialized too, so their stored expiry follows. */
109+
expect(renewedIds()).toEqual([
110+
['d-1', 'd-2'],
111+
['d-1', 'd-2'],
112+
])
105113
})
106114

107115
it('writes nothing when no scope is granted and stops at its deadline', async () => {

‎apps/sim/lib/knowledge/connectors/member-observations.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@ export async function renewMemberObservationsInScopes(input: {
215215
.map((row) => row.documentId)
216216
if (renewable.length > 0) {
217217
const now = new Date()
218-
const rows = await input.withLease((tx) =>
219-
tx
218+
const rows = await input.withLease(async (tx) => {
219+
const updated = await tx
220220
.update(knowledgeDocumentObservation)
221221
.set({ lastSeenAt: now })
222222
.where(
@@ -227,7 +227,20 @@ export async function renewMemberObservationsInScopes(input: {
227227
)
228228
)
229229
.returning({ documentId: knowledgeDocumentObservation.documentId })
230-
)
230+
/**
231+
* Renewed evidence has to reach the document too: the read path takes a document's
232+
* freshness from its stored expiry, which is only as current as its last write. Renewal
233+
* cannot change who observes a document, so the ACL itself stays as it is.
234+
*/
235+
if (updated.length > 0) {
236+
await refreshObservedAclExpiry(
237+
input.connectorId,
238+
updated.map((row) => row.documentId),
239+
tx
240+
)
241+
}
242+
return updated
243+
})
231244
renewed += rows.length
232245
}
233246
if (page.length < RENEWAL_PAGE_SIZE) return { renewed, finished: true }
@@ -349,6 +362,31 @@ export async function rewriteConnectorAcls(
349362
}
350363
}
351364

365+
/**
366+
* Carries renewed observation freshness into the documents it covers, leaving their ACLs alone.
367+
* Renewal only extends how long existing evidence stands, so the expiry is the one thing that
368+
* moves.
369+
*/
370+
export async function refreshObservedAclExpiry(
371+
connectorId: string,
372+
documentIds: readonly string[],
373+
executor: DbOrTx = db
374+
): Promise<void> {
375+
for (let offset = 0; offset < documentIds.length; offset += MATERIALIZE_BATCH_SIZE) {
376+
const batch = documentIds.slice(offset, offset + MATERIALIZE_BATCH_SIZE)
377+
await executor
378+
.update(document)
379+
.set({ aclValidUntil: observedAclValidUntil() })
380+
.where(
381+
and(
382+
inArray(document.id, batch),
383+
eq(document.connectorId, connectorId),
384+
sql`${document.aclValidUntil} IS DISTINCT FROM ${observedAclValidUntil()}`
385+
)
386+
)
387+
}
388+
}
389+
352390
export async function materializeDocumentAcls(
353391
connectorId: string,
354392
documentIds: Iterable<string>,

‎packages/db/script-migrations/0016_backfill_search_vectors.postgres.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ describe.runIf(Boolean(databaseUrl))('search projection upgrade in PostgreSQL',
391391
{ name: '0017_index_search_documents' },
392392
{ name: '0018_repair_workspace_file_content_revision' },
393393
{ name: '0019_tin_keyword_projection' },
394+
{ name: '0020_document_acl_valid_until' },
394395
])
395396
const [{ complete }] = await sql`SELECT count(*)::int AS complete FROM embedding e
396397
JOIN embedding_search s ON s.id = e.id JOIN embedding_keyword_search k ON k.id = e.id

0 commit comments

Comments
 (0)