Part 3 of 3. Part 1 set six requirements and argued that only a per-tenant resource with an ARN meets them. Part 2 showed the design is also the cheaper one. This part is the build: each requirement mapped to a call, a policy, or a log line, with code. Then the parts that stay in application code.
Architecture

One AWS account. One vector bucket per region served; one index per tenant inside it. Everything below is illustrative TypeScript against @aws-sdk/client-s3vectors and @aws-sdk/client-bedrock-runtime; parameter names should be checked against the current SDK.
Ingest: S3 (tenant prefix) ──▶ Lambda: extract + chunk ──▶ SQS ──▶ Lambda: embed (Bedrock Titan V2)
└──▶ PutVectors → index/org-<tenant>
Query: eID provider (OIDC) ──▶ API authorizer (tenant, role) ──▶ STS session policy (one index ARN)
──▶ embed question ──▶ QueryVectors(index/org-<tenant>, filter=classification)
──▶ LLM, grounded on returned chunk_text, citations = document_id + version
Audit: CloudTrail data events, resource type AWS::S3Vectors::Index, queried by resources.ARN
The shared regulatory catalogue (laws, regulations, guidance) is fanned out as a copy into every tenant index; Part 2 has the cost argument for that.
Index Creation
Tenant onboarding is one call. Two decisions in it are irreversible: the dimension and the non-filterable keys.
import { S3VectorsClient, CreateIndexCommand } from "@aws-sdk/client-s3vectors";
const s3v = new S3VectorsClient({ region: "eu-west-1" });
await s3v.send(new CreateIndexCommand({
vectorBucketName: "kb-eu-west-1", // the bucket is regional → residency
indexName: "org-b", // the index is the tenant → isolation
dataType: "float32",
dimension: 1024, // Titan Text Embeddings V2, 1024-d
distanceMetric: "cosine",
metadataConfiguration: {
nonFilterableMetadataKeys: ["chunk_text"], // returned, never scanned, never filterable
},
}));
Resulting ARN: arn:aws:s3vectors:eu-west-1:<account>:bucket/kb-eu-west-1/index/org-b. The region is the second segment. ListIndexes per bucket is the residency report.
Ingestion: Choose the Keys

The single most consequential line in the pipeline is the vector key. If the store generates it, “delete this document” becomes “search for this document’s chunks and delete what you find”, and approximate search is not proof of absence. If you generate it from the document id, erasure is a lookup.
import { PutVectorsCommand } from "@aws-sdk/client-s3vectors";
async function ingestDocument(tenant: string, doc: Document, chunks: string[]) {
const vectors = await Promise.all(chunks.map(async (text, i) => ({
key: `${doc.id}-k${String(i + 1).padStart(3, "0")}`, // chosen by us
data: { float32: await embed(text) },
metadata: {
document_id: doc.id, // filterable
version: doc.version, // filterable → appears on the citation
classification: doc.classification, // filterable → role filter at query time
chunk_text: text, // non-filterable → rides with the vector
},
})));
for (const batch of chunk(vectors, 500)) { // ≤ 500 per call
await s3v.send(new PutVectorsCommand({
vectorBucketName: "kb-eu-west-1", indexName: `org-${tenant}`, vectors: batch,
}));
}
await manifest.put(tenant, doc.id, doc.version, vectors.map(v => v.key)); // written down
}
Notice what is not in the metadata: the tenant. Nothing on the chunk says “org-b”. It is in org-b; that is what makes it B's. Writing a tenant tag would reintroduce the rung-1 failure mode (a wrong tag looks like a right one) for no benefit.
PutVectors is strongly consistent and has a 128 KB minimum per call for billing — batch.
Embedding, for completeness:
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
const bedrock = new BedrockRuntimeClient({ region: "eu-west-1" });
async function embed(text: string): Promise<number[]> {
const res = await bedrock.send(new InvokeModelCommand({
modelId: "amazon.titan-embed-text-v2:0",
contentType: "application/json",
body: JSON.stringify({ inputText: text, dimensions: 1024, normalize: true }),
}));
return JSON.parse(new TextDecoder().decode(res.body)).embedding;
}
Inference runs in the region you call. Bedrock does not retain prompts or use them for training; that is part of the residency answer.
Query: Credentials that Name one Index

The tenant boundary is not a filter in the query. It is the address the query is sent to — and the only address the caller’s credentials can open. The API mints those credentials per request.
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
function indexArn(tenant: string) {
return `arn:aws:s3vectors:eu-west-1:${ACCOUNT}:bucket/kb-eu-west-1/index/org-${tenant}`;
}
async function tenantCredentials(tenant: string) {
const res = await sts.send(new AssumeRoleCommand({
RoleArn: QUERY_ROLE_ARN, // broad role
RoleSessionName: `q-${tenant}`,
DurationSeconds: 900,
Policy: JSON.stringify({ // session policy: intersection, never a widening
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Action: ["s3vectors:QueryVectors", "s3vectors:GetVectors"],
Resource: indexArn(tenant), // exactly one ARN
}],
}),
}));
return res.Credentials!;
}
The session policy can only narrow the role; it cannot grant beyond it. The credentials last minutes and never leave the API. At scale, attribute-based access control does the same with a tag on the index matched to a principal tag, so the policy does not grow with the tenant list.
Then the query, with the intra-tenant control as a filter:
import { QueryVectorsCommand } from "@aws-sdk/client-s3vectors";
async function retrieve(tenant: string, role: Role, question: string) {
const client = new S3VectorsClient({ region: "eu-west-1", credentials: await tenantCredentials(tenant) });
const res = await client.send(new QueryVectorsCommand({
indexArn: indexArn(tenant),
queryVector: { float32: await embed(question) },
topK: 10,
filter: { classification: { $in: allowedClassifications(role) } }, // evaluated during search
returnMetadata: true,
returnDistance: true,
}));
return res.vectors ?? []; // each: key, distance, metadata incl. chunk_text
}
Two boundaries, two mechanisms. Which tenant is IAM on the ARN. Which classification within the tenant is a metadata filter, evaluated inside the search rather than after it. Post-filtering, ask for ten, discard what the user may not see , silently destroys recall for restricted roles and reports nothing; filtering inside the search returns ten results the user may see. And allowedClassifications(role) derives from the authorizer's claims, never from anything in the request body.
Grounding and citations follow from the response shape: the model is given only chunk_text from the returned vectors, and each citation is document_id · version from the same metadata. The cited passage is the passage the model saw, because they are the same bytes.
The Deliberate Bug

The test that separates rung 2 from rung 3. Point the application at the wrong index while holding tenant-scoped credentials:
const client = new S3VectorsClient({ region: "eu-west-1", credentials: await tenantCredentials("b") });
await client.send(new QueryVectorsCommand({ indexArn: indexArn("a"), queryVector: { float32: q }, topK: 10 }));
// → AccessDeniedException: User ... is not authorized to perform: s3vectors:QueryVectors on resource: .../index/org-a
Nothing comes back. On a rung-2 design the same bug returns A’s data with a 200. Keep this as an integration test: it is the cheapest proof you have that the boundary is not in your code.
The refused call is also logged, see below, with errorCode: AccessDenied against index/org-a.
Erasure: Delete by Key, Verify by Key

Deletion uses the manifest written at ingest. No search is involved.
import { DeleteVectorsCommand, GetVectorsCommand } from "@aws-sdk/client-s3vectors";
async function eraseDocument(tenant: string, docId: string, version: number) {
const keys = await manifest.get(tenant, docId, version);
for (const batch of chunk(keys, 500)) {
await s3v.send(new DeleteVectorsCommand({ vectorBucketName: "kb-eu-west-1", indexName: `org-${tenant}`, keys: batch }));
}
// verify — strongly consistent, so this is valid immediately
for (const batch of chunk(keys, 100)) {
const res = await s3v.send(new GetVectorsCommand({ vectorBucketName: "kb-eu-west-1", indexName: `org-${tenant}`, keys: batch }));
if ((res.vectors ?? []).length) throw new Error(`erasure incomplete: ${res.vectors!.length} keys remain`);
}
await audit.record({ tenant, docId, version, keyCount: keys.length, verifiedAt: new Date() });
}
The auditor’s version of the check is the second loop, run with her own read-only credentials scoped to the tenant’s index ARN. Same call, different principal, and the answer comes from AWS rather than from a screenshot of ours. If she calls GetVectors on another tenant's index she receives AccessDenied, which is the isolation test again.
A whole tenant leaving is DeleteIndex. Two operational notes: a deleted vector leaves query results immediately but can count towards index size (and so scan billing) for up to a day; and re-ingesting a new document version should delete the old version's keys through the same path, not through a separate cleanup.
Audit: CloudTrail Data Events by ARN

Management events (CreateIndex, DeleteIndex) are logged by default. Query-level records are data events, opt-in and billed per event; without them the audit claim is false.
// CDK
new cloudtrail.Trail(this, "Trail", { sendToCloudWatchLogs: false })
.addEventSelector(cloudtrail.DataResourceType.S3_VECTORS_INDEX, [
`arn:aws:s3vectors:eu-west-1:${account}:bucket/kb-eu-west-1/index/*`,
]);
// Check the CDK enum/name for the S3 Vectors index resource type; the underlying resource type is AWS::S3Vectors::Index.
A query event, trimmed:
{
"eventSource": "s3vectors.amazonaws.com",
"eventName": "QueryVectors",
"eventTime": "2026-09-09T10:41:07Z",
"userIdentity": { "type": "AssumedRole", "arn": "arn:aws:sts::123456789012:assumed-role/kb-query/q-b" },
"resources": [{ "type": "AWS::S3Vectors::Index",
"ARN": "arn:aws:s3vectors:eu-west-1:123456789012:bucket/kb-eu-west-1/index/org-b" }],
"errorCode": null
}
“Who read tenant A’s data?” is one predicate, in CloudTrail Lake or Athena:
SELECT eventTime, eventName, userIdentity.arn, errorCode
FROM cloudtrail_events
WHERE eventSource = 's3vectors.amazonaws.com'
AND element_at(resources, 1).arn LIKE '%/index/org-a'
ORDER BY eventTime DESC;
The record is written by AWS as the call arrives, before the application handler runs, and the platform operator cannot alter it. Give the auditor read access to the trail (or export her tenant’s events) and she answers the question without us. That is the whole shift for requirement 4: the auditor checks; we do not vouch.
Four Additions that Fit the Model

A KMS key per index.
Vector buckets support SSE-KMS. Encrypt each tenant’s index under its own customer-managed key: the key has an ARN and a region (a second residency artefact), and disabling it renders everything under it unreadable , including any copy , which is a stronger deletion than DeleteIndex alone.
Export.
ListVectors streams an index, including non-filterable text, in up to 16 parallel segments. A tenant can take its entire knowledge base out with credentials scoped to its ARN. GDPR Article 20 portability, and no lock-in, in one call.
Private network path.
Route S3 Vectors calls through a VPC endpoint so query traffic never traverses the public internet. This is the question a tenant’s security officer asks under NIS2 or a sector-specific security code.
Cost per tenant.
Tag each index for cost allocation and the vector layer appears per tenant on the bill an invoice line rather than a model. Tags also enable the ABAC variant of the session policy above.
What the Index Boundary Does Not Solve

Everything above answers which tenant can reach which data, and it is enforced by AWS. The following are not, and they are where most of our time went.
Roles inside a tenant.
Covered above by the classification filter. The two rules that matter: the filter goes inside the query, not after it; and the role rank comes from the authorizer, never from the client.
Existence leakage.
A public-sector failure mode most teams have not met: the model must not reveal that a restricted document exists. “I can’t show you the child-welfare report on this family” is itself a disclosure. So the retrieval layer never returns a chunk the user cannot see (the filter does that), and the prompt never mentions what was withheld; the model answers from what it has or says it has nothing. This is a retrieval rule rather than a prompt rule for the reason Part 1 gave: the folder is testable and the writer is not.
Cross-language similarity.
A corpus mixing a local language and English legal text scores the two noticeably differently for equally relevant passages under a single embedding model. A fixed cosine cutoff tuned on one language quietly drops good results in the other. We moved from an absolute threshold to a window relative to the best match in each result set. If your tenants are multilingual, measure this before choosing a threshold; the number is corpus-specific.
No lexical search.
Exact identifiers — section numbers, case ids — go into filterable metadata and are filtered on, not embedded and hoped for. Where true keyword search is required, OpenSearch can sit in front with S3 Vectors as the storage engine.
Chunk boundaries.
The least glamorous finding: a fortnight on where documents are split produced more answer-quality improvement than months of retrieval architecture. Chunk on document structure (sections, clauses), not on token counts alone.
Model migration.
Dimension is fixed at CreateIndex. A new embedding model means a new index per tenant, re-embed from the source documents (which you still hold in S3), cut over the config, DeleteIndex on the old one. The per-tenant layout makes this a rolling migration, and the delete proves the old copy is gone.
Bedrock Knowledge Bases.
It can use S3 Vectors as its store and is a reasonable default. We run our own ingestion for one reason: Knowledge Bases generates vector keys, and the erasure proof above depends on choosing them.
Requirements, revisited

Five of the six are properties of where the data sits and who is allowed to address it, not of code we wrote. That was the design goal from Part 1, and it is the reason the platform can be handed to somebody who does not trust us.
Code is illustrative and should be checked against the current AWS SDK v3 and CDK; the CloudTrail event is abbreviated. Verify VPC endpoint support and index tagging for S3 Vectors against current documentation. No production volumes or evaluation thresholds appear in this series; tenants, documents, regions and account ids are placeholders.




