Skip to content

fix(kb): record the knowledge base id before anything else can fail - #898

Merged
DerrickF merged 3 commits into
developfrom
fix/kb-persist-and-adopt-knowledge-base-id
Aug 31, 2026
Merged

fix(kb): record the knowledge base id before anything else can fail#898
DerrickF merged 3 commits into
developfrom
fix/kb-persist-and-adopt-knowledge-base-id

Conversation

@DerrickF

@DerrickF DerrickF commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The record was unrecoverable

The retry after the ACTIVE fix never reached the data source. It could not:

ConflictException: KnowledgeBase with name
dev-boisestateai-v2-kb-ast-1a90784a7f18 already exists.

awsKbId was only ever written by attach_aws_ids, which runs after both AWS creates succeed. A failure between them left a record with no identifier, so every later attempt re-entered the create path and was refused — permanently, because the name was taken. No number of retries could ever have worked.

Correcting a claim I made

The clientToken does not cover this, and this PR fixes that claim wherever it appears — including in a failure message I added two commits ago. AWS idempotency tokens expire within minutes; a retry an hour later is a genuinely new request that collides on the unique name.

Two changes

records.attach_knowledge_base_id persists awsKbId the moment the create returns, guarded on attribute_not_exists(awsKbId) so a straggler from an abandoned attempt cannot overwrite a newer identifier. Everything after that point is resumable.

Adopt-by-name on a name-collision ConflictException. This is what recovers records already in that state, including the one in dev — without it the only recourse is deleting the knowledge base by hand. Adoption is safe because the name derives from app_kb_id, so a collision can only be this knowledge base's own earlier attempt. The status conflict and the name conflict share the ConflictException code, so they are distinguished by message.

Why two tests certified the bug

This is the part worth reading.

  • test_the_record_survives_as_a_discoverable_retry_anchor asserted "awsKbId" not in anchor — the missing write, encoded as a requirement.
  • test_the_retry_does_not_create_a_second_knowledge_base asserted the retry re-issues the create, and relied on the fake to deduplicate it.

FakeBedrockAgent modelled clientToken dedup as permanent and did not model name uniqueness at all. Both tests passed against a design that could not recover, because the test double encoded an incorrect model of the external service.

The fake now enforces name uniqueness, treats tokens as expired by default (the realistic case for any retry that is not within minutes), and answers list_knowledge_bases. Both tests are rewritten to assert what actually makes the window survivable, and a third covers the stuck-record state directly.

Verification

2,465 tests pass across shared, lambdas, property, architecture and supply_chain. ruff clean.

Mutations verified caught: dropping the immediate persist, and dropping adopt-by-name.

After merge

Backend-only — backend.yml rebuilds the image, no platform deploy. Then retrying adopts the existing 2IRZCDKPNN, records its id, waits for ACTIVE (already there), and proceeds to the data source.


Added: the IAM action that authorizes direct ingestion

Found by adding a document to the promoted knowledge base in dev. It went to failed:

AccessDeniedException ... IngestKnowledgeBaseDocuments ... not authorized
to perform: bedrock:StartIngestionJob on resource: knowledge-base/M8WQZVQJ8X

AWS authorizes IngestKnowledgeBaseDocuments under the adjacent action name bedrock:StartIngestionJob — both appear in one statement in AWS's direct-ingestion prerequisites. grantManagedKbDirectIngestion carried only the name matching the API call, so it reviewed as complete, deployed clean, and failed the first real upload. Same shape as the missing bedrock:TagResource.

The worker had the identical gap — same grant, same API — so the first migration driven by the deployed dispatcher would have failed the same way. It stayed hidden because every migration so far ran through scripts/local-dev/run-kb-migration.py under an SSO identity broader than either Lambda role. Precisely the blind spot HANDOFF.md §2 names.

The action invites deletion: Requirement 9.2 forbids calling StartIngestionJob (0.1 RPS account-wide) and nothing does. Holding it is authorization, not invocation. A docblock and a separately-named test carry that reason.

bedrock:ListKnowledgeBaseDocuments is in AWS's example policy and omitted deliberately — no code path calls it.

Guards: three tests, one asserting both the worker and ingestion-consumer roles carry the action. Mutation-tested — removing it fails exactly four tests, each named for the reason. Full infra suite 634 passing.

This touches infrastructure/lib/constructs/**, so merging to develop triggers the PlatformStack deploy that applies it.

The retry after the ACTIVE fix did not reach the data source. It could not:

  ConflictException: KnowledgeBase with name
  dev-boisestateai-v2-kb-ast-1a90784a7f18 already exists.

`awsKbId` was only ever written by `attach_aws_ids`, which runs after BOTH AWS
creates succeed. So a failure between them left a record with no identifier, and
every later attempt re-entered the create path and was refused, permanently,
because the name was taken. The record was unrecoverable by any number of
retries.

The `clientToken` does not cover this, and this commit corrects that claim
wherever it appears — including in a message I added two commits ago. AWS
idempotency tokens expire within minutes; a retry an hour later is a genuinely new
request that collides on the unique name.

Two changes:

* `records.attach_knowledge_base_id` persists `awsKbId` the moment the create
  returns, guarded on `attribute_not_exists(awsKbId)` so a straggler cannot
  overwrite a newer attempt's identifier. Everything after that point is
  resumable.
* Provisioning adopts by name on a name-collision `ConflictException`. This is
  what recovers records already stuck in that state, including the one in dev —
  without it the only recourse is deleting the knowledge base by hand. Adoption is
  safe because the name is derived from `app_kb_id`, so a collision can only be
  this knowledge base's own earlier attempt. The status conflict and the name
  conflict share the `ConflictException` code, so they are told apart by message.

WHY TWO TESTS CERTIFIED THE BUG

`test_the_record_survives_as_a_discoverable_retry_anchor` asserted
`"awsKbId" not in anchor` — the missing write, encoded as a requirement — and
`test_the_retry_does_not_create_a_second_knowledge_base` asserted the retry
re-issues the create and relied on the fake deduplicating it. `FakeBedrockAgent`
modelled `clientToken` dedup as permanent and did not model name uniqueness at
all, so both passed against a design that could not recover.

The fake now enforces name uniqueness, treats tokens as expired by default, and
answers `list_knowledge_bases`. Both tests are rewritten to assert what actually
makes the window survivable, and a third covers the stuck-record state directly.

Mutations verified caught: dropping the immediate persist, and dropping
adopt-by-name.

Tests: 2,465 passed across shared, lambdas, property, architecture and
supply_chain.
…on in dev

A migration now runs shadow -> verify -> promote -> retain and serves from the
managed backend. Three further defects, all found by driving the state machine
locally against dev instead of through a deploy cycle.

THE EMBEDDING PIN AND MANAGED RERANKING ARE MUTUALLY EXCLUSIVE (Req 8.5 amended)

Req 8.5 pinned titan-embed-text-v2:0 via `embeddingModelType: CUSTOM`; Req 11.2
requires `rerankingModelType: MANAGED`. AWS rejects the combination, and §13 had
measured the two separately, never together. Measured all four:

  CUSTOM  + MANAGED -> ValidationException
  CUSTOM  + NONE    -> ok, scores 1.00/0.982/0.952 (flat)
  default + MANAGED -> ok, scores 0.413/0.199 (separated)
  default + NONE    -> ok

The pin loses, for a better reason than "it buys little": it protected a failure
mode that cannot occur here. On S3 Vectors *we* embed the question, so the query
model must match the index. Managed retrieval sends text and managed ingestion
sends text — we never produce a vector, so Bedrock embeds both sides and
consistency is its invariant. §13 measured the pin as worth nothing (9/9 identical)
against reranking being "what makes a small context cap defensible".
`embeddingModelId`/`embeddingDimensions` are no longer recorded either: with no
pin, nothing here knows what Bedrock chose, and a field naming Titan on a knowledge
base embedded with something else is worse than an absent one.

VERIFY FAILED A GOOD MIGRATION FOR BEING ASKED TOO EARLY

The canary returned nothing because the freshly-ingested document was not yet
queryable, and that was terminal. Measured ~45 s from ingest to retrievable on a
fresh knowledge base, against the docstring's 0.75-1.03 s (a warm figure). Now
defers via `records.defer_verify`, bounded at MAX_VERIFY_ATTEMPTS, so latency reads
as latency and only a corpus that never answers fails.

ADOPTION TOOK A KNOWLEDGE BASE THAT WAS BEING DELETED

Found while recreating one locally: the delete had not finished, adopt-by-name
took the DELETING knowledge base, and the ACTIVE wait then refused it. Adoption now
skips terminal statuses — the name is about to free up, so a fresh create is right.

ALSO

A test that only passed while MANAGED_KB_SERVICE_ROLE_ARN was absent now deletes it
explicitly; the variable is needed in backend/src/.env for the local driver.

`scripts/local-dev/run-kb-migration.py` drives the state machine in-process against
dev. Three of the last five defects would have been minutes each with it. HANDOFF.md
is rewritten: it previously said "Nothing deployed", which has been untrue since
1.16.0 shipped the feature to production behind flags.

Tests: 6,817 backend passed (5 pre-existing Strands failures), 626 infra.
…estion

A document uploaded to a promoted knowledge base in dev went to `failed`
with:

  AccessDeniedException ... IngestKnowledgeBaseDocuments ... not
  authorized to perform: bedrock:StartIngestionJob on resource:
  knowledge-base/M8WQZVQJ8X

AWS authorizes `IngestKnowledgeBaseDocuments` under the adjacent action
name `bedrock:StartIngestionJob`; both appear in one statement in AWS's
direct-ingestion prerequisites. The grant carried only the name matching
the API call, so it reviewed as complete, deployed clean, and failed on
the first real upload — the same shape as the missing `bedrock:TagResource`.

The worker had the identical gap. It receives the same grant and calls
the same API, so the first migration driven by the deployed dispatcher
would have failed identically. It stayed invisible because every
migration so far was driven by scripts/local-dev/run-kb-migration.py
under an SSO identity broader than either Lambda role.

The action is easy to mistake for a mistake: Requirement 9.2 forbids
*calling* StartIngestionJob (0.1 RPS account-wide, one document per ten
seconds) and nothing does. Holding it is authorization, not invocation.
A docblock and a separately-named test carry that reason so the obvious
cleanup fails a test that explains itself.

`bedrock:ListKnowledgeBaseDocuments` is in AWS's example policy and left
out on purpose: no code path calls it.

Guards: three tests, including one asserting both the worker and the
ingestion-consumer roles carry the action. Mutation-tested — removing
the action fails exactly four tests, each named for the reason.
@DerrickF
DerrickF merged commit ef2f4c9 into develop Aug 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant