Access was revoked. What happens now?
Yesterday’s stamp cannot prove today’s permission.

Yesterday, Arun could read D2, the Engineering incident note at Sachin. The copy room stamped a searchable extract, and the records clerk kept a prepared answer for reuse.
Today, the document owner removes Engineering’s access. The source folder has a new label. The old photocopy and prepared answer still exist.
Which label should decide what Arun receives now?
In Part 3, we found allowed evidence in a ranked collection. This article adds time: you will trace an access change across three places that may update at different moments.
Which stamp decides after the owner changes access?
1The owner changes D2
Engineering loses its source grant. The policy advances from v1 to v2.
New folder label → authoritative policy2Copies lag behind
The search copy and old answer still carry yesterday’s permission.
Delayed copies → stale index and cache3Check before dispatch
Consult the current policy. Discard an answer prepared under an old revision.
Dispatch gate → current authorizationA current denial must beat an old copy’s grant.
Three copies, three clocks
The source policy is the authoritative record of the document’s permissions. The indexed policy is a copy attached to a searchable record. A cached answer is a stored response that may include facts from that record.
In our fictional timeline, D2 starts at policy version 1 with an Engineering group grant. At the first change, the source moves to version 2 and both grant lists are empty. Arun is still an Engineering employee; the document no longer grants that group access.
The index catches up at the next step. The old answer is physically removed from the cache at the final step. These are separate events, not one instant operation.
The numbers 1 and 2 are illustrative revision counters. A version number only helps when the application compares it with a trusted current value. Reading “version 1” from two old copies does not prove either is fresh.
Stop using a stale grant before deleting every copy
Predict: after the source changes, must the protected request wait for the index to update before it can stop using D2?
Follow the three clocks.
Fictional records. Browser simulation. No live model call.
Version 2
Arun: deny
groups: []Version 1
Index label: allow
Stale: source is already v2Still stored
Not usable
Reuse requires current permission and version match.D2 stays out of the context. The owner’s denial is respected even while old copies exist.
Inspect the input, rule, and output
Input / state
{
"stage": 1,
"currentCheck": true,
"reader": {
"id": "arun",
"name": "Arun",
"role": "Engineering",
"tenantId": "sachin",
"groups": [
"engineering"
]
},
"source": {
"id": "D2",
"title": "Engineering incident note",
"team": "Engineering",
"tenantId": "sachin",
"allowedGroups": [],
"allowedUsers": [],
"match": 98,
"fact": "Sign-in requests time out under peak load. The identity team is fixing the session refresh path.",
"aclVersion": 2
},
"indexed": {
"id": "D2",
"title": "Engineering incident note",
"team": "Engineering",
"tenantId": "sachin",
"allowedGroups": [
"engineering"
],
"allowedUsers": [],
"match": 98,
"fact": "Sign-in requests time out under peak load. The identity team is fixing the session refresh path.",
"aclVersion": 1
}
}Decision rule
contextIncludesD2 = indexAllows
&& (!currentCheckEnabled || currentAllows)
cacheUsable = oldEntryExists
&& cacheVersion === sourceVersion
&& currentAllowsOutput
{
"source": {
"id": "D2",
"title": "Engineering incident note",
"team": "Engineering",
"tenantId": "sachin",
"allowedGroups": [],
"allowedUsers": [],
"match": 98,
"fact": "Sign-in requests time out under peak load. The identity team is fixing the session refresh path.",
"aclVersion": 2
},
"indexed": {
"id": "D2",
"title": "Engineering incident note",
"team": "Engineering",
"tenantId": "sachin",
"allowedGroups": [
"engineering"
],
"allowedUsers": [],
"match": 98,
"fact": "Sign-in requests time out under peak load. The identity team is fixing the session refresh path.",
"aclVersion": 1
},
"indexAllows": true,
"currentAllows": false,
"oldCacheExists": true,
"cacheUsable": false,
"contextIncludesD2": false,
"cacheVersion": 1,
"policyVersion": 2
}This view runs the same deterministic functions as the downloadable example. The short rule above summarizes the operation; the download contains the complete implementation.
At step 1, the stale index still proposes D2. The current source check denies it, so D2 does not enter the new context. At step 2, the index itself stops proposing D2 as allowed. At step 3, the old cached answer is removed.
Turn off the current check while the source is at version 2 and the index is at version 1. D2 incorrectly enters the context. Turn the check back on: the current denial wins. The old cache remains unusable because its version and permission no longer match.
Deletion and usability are different. An old cache entry can remain stored while being ineligible for a new response. Physical deletion still matters for the system’s retention requirements; our timeline isolates the decision to use it.
“Current” needs a concrete meaning
A clerk who phones yesterday’s copy room has not checked today’s source. The same problem appears when a permission service responds from a stale cache or replica.
Define where an access change becomes authoritative, what freshness the read requires, and the checkpoint after which a new response must honor it. The illustrated model reads one in-memory source synchronously, so the source change is immediately visible to its next check. That does not model distributed propagation delay.
For a real example of this choice, OpenFGA’s query modes distinguish using a cache when possible from skipping it to query the database. Its documentation notes that a cached check may miss an immediately preceding permission change. Those are OpenFGA’s documented semantics, not a universal promise that every permission service provides instant revocation. OpenFGA query consistency.
A cached answer is a candidate, not a grant
Imagine the clerk finds an envelope already prepared for Arun. Before reusing it, the clerk still needs to know whether Arun can receive its contents now.
Part 6 uses a conservative cache key containing a policy revision, company, user, groups, question, and fixed-generator version. A policy change advances the revision. The old key stops matching, and current source permissions are checked before any cached response is returned. The cached source IDs must also match the newly selected evidence, so a missed revision update cannot restore a source the current check excluded.
This global counter deliberately invalidates more answers than necessary. A larger system might track dependencies more selectively, but it must define how it discovers changes. Content, prompt, or model changes can also invalidate an answer; our fixture text and generator stay fixed during a run.
What about a request already in progress?
Suppose a request prepares evidence under version 1. While it is doing asynchronous work, the owner revokes access and the source reaches version 2.
Our service checks the revision again before returning its buffered answer. The mismatch discards the prepared response and returns HTTP 409 with a retry message. A retry starts from the new policy. It does not patch a possibly mixed old answer.
That final check cannot undo text already sent to a model, streamed to a browser, or downloaded earlier. A streaming service must define its own delivery checkpoints and revocation behavior. Part 6 deliberately buffers the result and demonstrates one final gate.
Run the timeline
The example asserts that D2 is included only before revocation when current checks are enabled. It also demonstrates the failing stale-index case with the check disabled.
Run the same model yourself.
Node.js 20 or newer. No packages or API keys. Save the file, open a terminal in its directory, and run the command below.
Download part 4 · JavaScript ↓node sachin-permissions-4.mjsEXPECTED OUTPUT
Stage 0: index allows; current policy allows; context includes D2
Stage 1: index allows; current policy denies; context excludes D2
Stage 2: index denies; current policy denies; context excludes D2
Stage 3: index denies; current policy denies; context excludes D2
Revocation checks passed.Read the complete source and assertions
// Sachin RAG permissions, part 4. Node.js 20+. Fictional fixtures; fixed answers.
// @ts-check
/** Fictional teaching fixtures. This simulation is not an authorization boundary.
* @typedef {{id: string, name: string, role: string, tenantId: string, groups: readonly string[]}} Principal
* @typedef {{id: string, title: string, team: string, tenantId: string, allowedGroups: readonly string[], allowedUsers: readonly string[], match: number, fact: string}} Evidence
*/
/** @type {readonly Principal[]} */
export const PEOPLE = [
{ id: "maya", name: "Maya", role: "Support", tenantId: "sachin", groups: ["support"] },
{ id: "arun", name: "Arun", role: "Engineering", tenantId: "sachin", groups: ["engineering"] },
];
export const QUESTION = "Why is the Orion launch delayed?";
/** @type {readonly Evidence[]} */
export const DOCUMENTS = [
{
id: "D1", title: "Customer launch update", team: "Shared", tenantId: "sachin",
allowedGroups: ["support", "engineering"], allowedUsers: [], match: 87,
fact: "Orion’s launch has moved to 14 October while the team completes reliability checks.",
},
{
id: "D2", title: "Engineering incident note", team: "Engineering", tenantId: "sachin",
allowedGroups: ["engineering"], allowedUsers: [], match: 98,
fact: "Sign-in requests time out under peak load. The identity team is fixing the session refresh path.",
},
{
id: "D3", title: "Customer support FAQ", team: "Support", tenantId: "sachin",
allowedGroups: ["support"], allowedUsers: [], match: 82,
fact: "Existing workspaces stay available. Customers do not need to take any action.",
},
{
id: "D4", title: "Launch cost review", team: "Finance", tenantId: "sachin",
allowedGroups: ["finance"], allowedUsers: [], match: 91,
fact: "The delay adds $24,000 to the internal launch budget.",
},
];
// @example:policy
/** Identity must already come from a trusted session; this function only checks permission.
* @param {Principal | null} user @param {Evidence} chunk */
export function checkAccess(user, chunk) {
const hasIdentity = user !== null;
const sameCompany = hasIdentity && user.tenantId === chunk.tenantId;
const directGrant = hasIdentity && chunk.allowedUsers.includes(user.id);
const matchingGroups = user?.groups.filter(group => chunk.allowedGroups.includes(group)) ?? [];
const groupGrant = matchingGroups.length > 0;
const allowed = sameCompany && (directGrant || groupGrant);
const reason = !hasIdentity ? "No verified identity"
: !sameCompany ? "Different company"
: directGrant ? "Direct user grant"
: groupGrant ? `${matchingGroups.join(", ")} group grants access`
: "No matching permission";
return { hasIdentity, sameCompany, directGrant, groupGrant, matchingGroups, allowed, reason };
}
// @example:end-policy
/** @param {Principal | null} user @param {readonly Evidence[]} documents */
export function retrieveEvidence(user, documents = DOCUMENTS) {
return documents
.filter(chunk => checkAccess(user, chunk).allowed)
.sort((a, b) => b.match - a.match);
}
/** @param {Principal | null} user @param {readonly Evidence[]} documents */
export function buildAnswer(user, documents = DOCUMENTS) {
const evidence = retrieveEvidence(user, documents);
const context = evidence.map(chunk => `[${chunk.id}] ${chunk.title}\n${chunk.fact}`).join("\n\n");
// These are example request messages. This lesson makes no model call.
const messages = evidence.length ? [
{ role: "system", content: "Use the supplied evidence to answer the question. Cite each source ID. If the evidence is insufficient, say so." },
{ role: "user", content: `Question: ${QUESTION}\n\nPermitted context:\n${context}` },
] : [];
return {
evidence,
context,
messages,
// Assemble fixed facts to make the experiment reproducible, without a model.
sentences: evidence.map(({ id, fact }) => ({ sourceId: id, text: fact })),
emptyMessage: evidence.length ? null : "I don’t have accessible evidence to answer this question.",
};
}
/** Fictional, deterministic teaching models shared by the diagrams and downloads.
* @typedef {import('./rag-permissions.mjs').Principal} Principal
* @typedef {import('./rag-permissions.mjs').Evidence} Evidence
*/
export function hasPolicy(record) {
const ids = value => Array.isArray(value) && value.every(id => typeof id === "string" && id.length > 0);
return typeof record?.tenantId === "string" && record.tenantId.length > 0
&& ids(record.allowedGroups) && ids(record.allowedUsers);
}
export function ingestDocument(document, paragraphs, aclVersion = 1) {
if (!hasPolicy(document)) throw new Error("Missing or invalid permission metadata");
if (!Number.isInteger(aclVersion) || aclVersion < 1) throw new Error("Invalid policy version");
if (!Array.isArray(paragraphs) || paragraphs.some(text => typeof text !== "string" || !text.trim())) {
throw new Error("Chunks must contain nonempty text");
}
return paragraphs.map((fact, index) => ({
...document, id: `${document.id}.${index + 1}`, parentId: document.id,
fact, aclVersion, allowedGroups: [...document.allowedGroups], allowedUsers: [...document.allowedUsers],
}));
}
// Ten fictional passages extend the same four documents. Scores are illustrative, not vector distances.
/** @type {Array<[string, number, number, string]>} */
const rankingRows = [
["D2", 1, 98, "Sign-in requests time out under peak load."],
["D4", 1, 96, "The delay adds $24,000 to the internal launch budget."],
["D1", 1, 94, "Orion’s launch has moved to 14 October."],
["D2", 2, 92, "The identity team is fixing the session refresh path."],
["D3", 1, 90, "Existing workspaces stay available."],
["D1", 2, 88, "The team is completing reliability checks."],
["D3", 2, 86, "Customers do not need to take any action."],
["D1", 3, 84, "The launch update will be revised after the next readiness review."],
["D4", 2, 82, "Finance will reconcile launch costs after release."],
["D3", 3, 80, "Support will share the next approved launch update."],
];
export const RANKED_CHUNKS = rankingRows.map(([parentId, ordinal, match, fact]) => {
const parent = DOCUMENTS.find(document => document.id === parentId);
if (!parent) throw new Error("Unknown parent document");
return { ...parent, id: `${parentId}.${ordinal}`, parentId, match, fact, aclVersion: 1 };
});
/** Exact ranked-list experiment; it does not simulate an ANN graph. */
export function compareRetrieval(user = PEOPLE[0], k = 5, fetchCount = 5) {
const ranked = [...RANKED_CHUNKS].sort((a, b) => b.match - a.match);
const isAllowed = chunk => hasPolicy(chunk) && checkAccess(user, chunk).allowed;
const eligible = ranked.filter(isAllowed);
const ideal = eligible.slice(0, k);
const fetched = ranked.slice(0, fetchCount);
const after = fetched.filter(isAllowed).slice(0, k);
const recovered = after.filter(chunk => ideal.some(target => target.id === chunk.id)).length;
return { ranked, eligible, ideal, fetched, after, missed: ideal.filter(chunk => !after.some(hit => hit.id === chunk.id)),
recall: ideal.length ? recovered / ideal.length : null };
}
/** Each stage is a new snapshot; moving backwards never mutates the fixtures. */
export function revocationSnapshot(stage = 0, enforceCurrent = true) {
const original = DOCUMENTS[1];
const source = { ...original, allowedGroups: stage >= 1 ? [] : [...original.allowedGroups], aclVersion: stage >= 1 ? 2 : 1 };
const indexed = stage >= 2 ? { ...source } : { ...original, aclVersion: 1 };
const indexAllows = checkAccess(PEOPLE[1], indexed).allowed;
const currentAllows = checkAccess(PEOPLE[1], source).allowed;
const oldCacheExists = stage < 3;
const cacheVersion = 1;
const cacheUsable = oldCacheExists && cacheVersion === source.aclVersion && currentAllows;
return { source, indexed, indexAllows, currentAllows, oldCacheExists, cacheUsable,
contextIncludesD2: indexAllows && (!enforceCurrent || currentAllows),
cacheVersion, policyVersion: source.aclVersion };
}
/** Source IDs are looked up and authorized again on every source request. */
export function readSource(user, id, documents = DOCUMENTS) {
const document = documents.find(item => item.id === id && item.tenantId === user?.tenantId);
if (!document || !hasPolicy(document) || !checkAccess(user, document).allowed) {
return { status: 404, body: { message: "Source unavailable" } };
}
return { status: 200, body: { id: document.id, title: document.title, text: document.fact } };
}
export function inspectSurface(personId = "maya", surface = "citation", enforce = true) {
const person = PEOPLE.find(user => user.id === personId) ?? PEOPLE[0];
const permitted = buildAnswer(person);
const restricted = DOCUMENTS[1];
const safe = surface === "citation" ? readSource(person, "D2")
: surface === "preview" ? { status: 200, body: permitted.evidence.map(({ id, title }) => ({ id, title })) }
: surface === "shared-answer" ? { status: 200, body: permitted.sentences }
: { status: 200, body: { requestId: "demo-1", outcome: "allowed", returnedIds: permitted.evidence.map(chunk => chunk.id) } };
const unsafe = surface === "citation" ? { status: 200, body: { id: restricted.id, title: restricted.title, text: restricted.fact } }
: surface === "preview" ? { status: 200, body: DOCUMENTS.map(({ id, title }) => ({ id, title })) }
: surface === "shared-answer" ? { status: 200, body: buildAnswer(PEOPLE[1]).sentences }
: { status: 200, body: { question: QUESTION, candidates: DOCUMENTS, sessionToken: "fictional-demo-token" } };
return { person, response: enforce ? safe : unsafe, protected: enforce };
}
export function createServiceState() {
return {
// Public exercise credentials, not secrets or a real login system.
sessions: { "support-demo": structuredClone(PEOPLE[0]), "engineering-demo": structuredClone(PEOPLE[1]),
"other-tenant-demo": { ...structuredClone(PEOPLE[0]), tenantId: "other" } },
documents: structuredClone(DOCUMENTS), policyVersion: 1, policyAvailable: true,
cache: new Map(), audit: [],
};
}
export function revokeD2(state) {
const document = state.documents.find(item => item.id === "D2");
document.allowedGroups = [];
document.allowedUsers = [];
state.policyVersion += 1;
}
export function prepareServiceRequest(state, token, body = {}) {
const user = Object.hasOwn(state.sessions, token) ? structuredClone(state.sessions[token]) : null;
const base = { user, sessionKey: token, evidence: [], version: state.policyVersion, key: "", query: "", trace: ["Resolve identity from the server’s session table"] };
if (!user) return { ...base, error: 401, message: "Sign in required" };
if (typeof body?.question !== "string" || body.question.trim() !== QUESTION) {
return { ...base, error: 400, message: "This exercise supports the Orion launch question only" };
}
if (!state.policyAvailable) return { ...base, error: 503, message: "Permission check unavailable" };
if (state.documents.some(document => !hasPolicy(document))) {
return { ...base, error: 503, message: "Permission metadata unavailable" };
}
// Body-supplied groups and tenantId never participate in the decision.
const evidence = buildAnswer(user, state.documents).evidence;
const key = JSON.stringify(["fixed-generator-v1", state.policyVersion, user.tenantId, user.id, [...user.groups].sort(), QUESTION]);
return { ...base, error: 0, message: "", evidence, query: QUESTION, key,
trace: [...base.trace, "Ignore client permission claims", "Check tenant and current document grants", "Prepare permitted evidence"] };
}
export function finishServiceRequest(state, plan) {
const reply = (status, body, cacheHit = false) => {
// This audit record stays on the server. No token, prompt, title, or document body.
state.audit.push({ requestId: `request-${state.audit.length + 1}`, status,
policyVersion: state.policyVersion, returnedIds: status === 200 ? (body.sources ?? []).map(source => source.id) : [] });
return { status, body, cacheHit, trace: plan.trace };
};
if (plan.error) return reply(plan.error, { message: plan.message });
if (!state.policyAvailable) return reply(503, { message: "Permission check unavailable" });
if (plan.version !== state.policyVersion) return reply(409, { message: "Permissions changed; retry the request" });
// Recheck current membership too. The version check covers policy changes through revokeD2.
const current = Object.hasOwn(state.sessions, plan.sessionKey) ? state.sessions[plan.sessionKey] : null;
if (!current || current.id !== plan.user.id || current.tenantId !== plan.user.tenantId
|| JSON.stringify([...current.groups].sort()) !== JSON.stringify([...plan.user.groups].sort())
|| plan.evidence.some(chunk => {
const source = state.documents.find(document => document.id === chunk.id);
return !source || !hasPolicy(source) || !checkAccess(current, source).allowed;
})) return reply(409, { message: "Permissions changed; retry the request" });
if (!plan.evidence.length) return reply(200, { answer: "I don’t have accessible evidence to answer this question.", sources: [], modelCalled: false });
const cached = state.cache.get(plan.key);
// Compare dependencies too: a missed revision update must not restore an excluded source.
const selectedIds = plan.evidence.map(chunk => chunk.id);
if (cached && JSON.stringify(cached.sources.map(source => source.id)) === JSON.stringify(selectedIds)) {
return reply(200, structuredClone(cached), true);
}
// Deterministic generation seam: a real model integration needs separate output evaluation.
const response = { answer: plan.evidence.map(chunk => `${chunk.fact} [${chunk.id}]`).join("\n\n"),
sources: plan.evidence.map(({ id, title }) => ({ id, title })), modelCalled: false };
state.cache.set(plan.key, structuredClone(response));
return reply(200, response);
}
export function runServiceScenario(scenario = "maya") {
const state = createServiceState();
let token = scenario === "arun" || scenario === "in-flight" || scenario === "revoked" ? "engineering-demo" : "support-demo";
if (scenario === "missing-session") token = "unknown";
if (scenario === "other-tenant") token = "other-tenant-demo";
if (scenario === "outage") state.policyAvailable = false;
const body = { question: QUESTION, ...(scenario === "forged-groups" ? { groups: ["engineering"], tenantId: "other" } : {}) };
if (scenario === "revoked") {
finishServiceRequest(state, prepareServiceRequest(state, token, body));
revokeD2(state);
}
const plan = prepareServiceRequest(state, token, body);
if (scenario === "in-flight") revokeD2(state);
const response = finishServiceRequest(state, plan);
return { body, plan, response, audit: state.audit, cacheEntries: state.cache.size, policyVersion: state.policyVersion };
}
import assert from "node:assert/strict";
for (let stage = 0; stage < 4; stage++) {
const snapshot = revocationSnapshot(stage);
assert.equal(snapshot.contextIncludesD2, stage === 0);
if (stage > 0) assert.equal(snapshot.cacheUsable, false);
console.log("Stage " + stage + ": index " + (snapshot.indexAllows ? "allows" : "denies") + "; current policy " + (snapshot.currentAllows ? "allows" : "denies") + "; context " + (snapshot.contextIncludesD2 ? "includes" : "excludes") + " D2");
}
assert.equal(revocationSnapshot(1, false).contextIncludesD2, true);
console.log("Revocation checks passed.");
Try clearing the old answer before updating the index. That removes one stale copy; it does not make the index label current. The source check is still necessary in this design.
Also remember our policy’s “OR.” Removing Engineering’s group grant would not revoke Arun if D2 still directly named him in allowedUsers. This fixture removes every grant on D2 so the result is unambiguous.
We have protected the evidence and its freshness. Now follow the answer out of the room: Part 5 covers citations, previews, sharing, and diagnostics.