diff --git a/api/controllers/console/knowledge_fs/resources.py b/api/controllers/console/knowledge_fs/resources.py index 3960ee07512..bff7e220786 100644 --- a/api/controllers/console/knowledge_fs/resources.py +++ b/api/controllers/console/knowledge_fs/resources.py @@ -99,6 +99,7 @@ from services.knowledge_fs.product_dto import ( KnowledgeFSGoldenQuestionResponse, KnowledgeFSIdempotencyHeader, KnowledgeFSJWKSResponse, + KnowledgeFSLogicalDocumentDeletePayload, KnowledgeFSLogicalDocumentListResponse, KnowledgeFSLogicalDocumentResponse, KnowledgeFSMembersReplacePayload, @@ -207,6 +208,7 @@ register_schema_models( KnowledgeFSBulkDocumentDeletePayload, KnowledgeFSDocumentChunkListQuery, KnowledgeFSDocumentDeletePayload, + KnowledgeFSLogicalDocumentDeletePayload, KnowledgeFSDocumentMetadataPayload, KnowledgeFSDocumentReindexPayload, KnowledgeFSGoldenQuestionBulkImportPayload, @@ -1126,7 +1128,7 @@ class KnowledgeFSSpaceLogicalDocumentApi(Resource): ) return dump_response(KnowledgeFSLogicalDocumentResponse, result) - @console_ns.expect(console_ns.models[KnowledgeFSDocumentDeletePayload.__name__]) + @console_ns.expect(console_ns.models[KnowledgeFSLogicalDocumentDeletePayload.__name__]) @console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS) @console_ns.response( HTTPStatus.ACCEPTED, @@ -1144,7 +1146,7 @@ class KnowledgeFSSpaceLogicalDocumentApi(Resource): account_id=actor_id, control_space_id=control_space_id, document_id=document_id, - payload=_payload(KnowledgeFSDocumentDeletePayload), + payload=_payload(KnowledgeFSLogicalDocumentDeletePayload), idempotency_key=_idempotency_key(), ) return dump_response(KnowledgeFSDurableDeletionAcceptedResponse, result), HTTPStatus.ACCEPTED diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index d3ac6e9fad2..548d96cfc48 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,7 +1,7 @@ { "schemaVersion": 5, - "subtreeTree": "03fdb59383b64f9cd299b89a6e946f000502ade0", - "openapiSha256": "18e3611208c255895cce4a5101bb0ab4466a57389e981a0e70bccacdef96974a", + "subtreeTree": "fed7e05b3624199d02909e2e032fbf82a92c435a", + "openapiSha256": "189c98cd2535829d75b090a70c22dac720a9e9349b8b2cde1602abc31d14f8b8", "capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", "productOperationManifestSha256": "a2d5e2b72b87652205d505ff85ce2ca689dbd96a466495883bbf1c4b8a54ff32", diff --git a/api/services/knowledge_fs/data_facade.py b/api/services/knowledge_fs/data_facade.py index 1ae32e1b585..5c763d92f50 100644 --- a/api/services/knowledge_fs/data_facade.py +++ b/api/services/knowledge_fs/data_facade.py @@ -58,6 +58,7 @@ from services.knowledge_fs.product_dto import ( KnowledgeFSGrepResponse, KnowledgeFSListQuery, KnowledgeFSListResponse, + KnowledgeFSLogicalDocumentDeletePayload, KnowledgeFSLogicalDocumentListResponse, KnowledgeFSLogicalDocumentResponse, KnowledgeFSOverviewActivityListResponse, @@ -627,7 +628,7 @@ class KnowledgeFSDataFacade: account_id: str, control_space_id: str, document_id: str, - payload: KnowledgeFSDocumentDeletePayload, + payload: KnowledgeFSLogicalDocumentDeletePayload, idempotency_key: str, ) -> KnowledgeFSDurableDeletionAcceptedResponse: raw = self._interactive_child( diff --git a/api/services/knowledge_fs/product_dto.py b/api/services/knowledge_fs/product_dto.py index a520b60843f..46ad13f9f1a 100644 --- a/api/services/knowledge_fs/product_dto.py +++ b/api/services/knowledge_fs/product_dto.py @@ -1150,6 +1150,12 @@ class KnowledgeFSDocumentDeletePayload(BaseModel): model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True) +class KnowledgeFSLogicalDocumentDeletePayload(BaseModel): + expected_revision: int = Field(ge=0, alias="expectedRevision") + + model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True) + + class KnowledgeFSBulkDocumentDeleteItemPayload(KnowledgeFSDocumentDeletePayload): document_id: str = Field(min_length=1, alias="documentId") @@ -2450,6 +2456,7 @@ __all__ = [ "KnowledgeFSIdempotencyHeader", "KnowledgeFSJWKResponse", "KnowledgeFSJWKSResponse", + "KnowledgeFSLogicalDocumentDeletePayload", "KnowledgeFSLogicalDocumentListResponse", "KnowledgeFSLogicalDocumentResponse", "KnowledgeFSMemberBindingPayload", diff --git a/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py b/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py index 9b0446c7cc6..363ab714966 100644 --- a/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py +++ b/api/tests/unit_tests/controllers/test_knowledge_fs_product_controllers.py @@ -19,6 +19,7 @@ from controllers.service_api.knowledge_fs import resources as service_resources from services.knowledge_fs.credential_service import KnowledgeFSServiceCredentialProfile from services.knowledge_fs.product_dto import ( KnowledgeFSDocumentUploadAcceptedResponse, + KnowledgeFSDurableDeletionAcceptedResponse, KnowledgeFSSmallFileUploadResponse, KnowledgeFSSpaceCreatePayload, ) @@ -313,6 +314,59 @@ def test_document_upload_console_bff_reads_only_through_facade_and_returns_accep ] +def test_logical_document_delete_accepts_initial_row_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + class Facade: + def delete_logical_document(self, **kwargs): + calls.append(kwargs) + assert kwargs["payload"].expected_revision == 0 + return KnowledgeFSDurableDeletionAcceptedResponse.model_validate( + { + "job": { + "checkpoint": "requested", + "createdAt": "2030-01-01T00:00:00Z", + "id": "00000000-0000-4000-8000-000000000001", + "knowledgeSpaceId": "space-1", + "runState": "queued", + "targetId": "00000000-0000-4000-8000-000000000002", + "targetType": "logical_document", + "updatedAt": "2030-01-01T00:00:00Z", + }, + "statusUrl": "/deletion-jobs/job-1", + } + ) + + monkeypatch.setattr(console_resources, "_actor", lambda: ("account-1", "tenant-1")) + monkeypatch.setattr(console_resources, "_console_services", lambda: SimpleNamespace(facade=Facade())) + app = Flask(__name__) + + with app.test_request_context( + method="DELETE", + json={"expectedRevision": 0}, + headers={"Idempotency-Key": "delete-logical-document-once"}, + ): + delete = inspect.unwrap(console_resources.KnowledgeFSSpaceLogicalDocumentApi.delete) + response, status = delete( + console_resources.KnowledgeFSSpaceLogicalDocumentApi(), + "control-1", + "document-1", + ) + + assert status == 202 + assert response["job"]["target_type"] == "logical_document" + assert len(calls) == 1 + assert {name: value for name, value in calls[0].items() if name != "payload"} == { + "tenant_id": "tenant-1", + "account_id": "account-1", + "control_space_id": "control-1", + "document_id": "document-1", + "idempotency_key": "delete-logical-document-once", + } + + def test_small_file_console_bff_maps_oversize_to_413() -> None: app = Flask(__name__) with app.test_request_context( diff --git a/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py b/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py index 982433c2ffa..6d58cb82196 100644 --- a/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py +++ b/api/tests/unit_tests/services/test_knowledge_fs_data_facade.py @@ -20,6 +20,7 @@ from services.knowledge_fs.product_dto import ( KnowledgeFSGoldenQuestionPayload, KnowledgeFSGrepQuery, KnowledgeFSListQuery, + KnowledgeFSLogicalDocumentDeletePayload, KnowledgeFSProductRerankProfile, KnowledgeFSProductRetrievalProfile, KnowledgeFSProductScoreThreshold, @@ -1423,6 +1424,27 @@ def test_advanced_facade_binds_child_resources_parent_space_and_idempotency() -> ] +def test_logical_document_delete_preserves_initial_row_version() -> None: + remote = RecordingRemote() + facade = KnowledgeFSDataFacade(broker=RecordingBroker(), remote=remote) # type: ignore[arg-type] + + deletion = facade.delete_logical_document( + tenant_id="tenant-1", + account_id="account-1", + control_space_id="control-1", + document_id="document-1", + payload=KnowledgeFSLogicalDocumentDeletePayload(expected_revision=0), + idempotency_key="delete-logical-once", + ) + + request = remote.requests[-1] + assert deletion.job.target_type == "logical_document" + assert request.operation_id == "deleteLogicalDocument" + assert request.path == "/knowledge-spaces/space-1/logical-documents/document-1" + assert request.payload == {"expectedRevision": 0} + assert request.headers == (("Idempotency-Key", "delete-logical-once"),) + + @pytest.mark.parametrize( ("method_name", "response_name", "operation_id", "specific_kwargs", "child_resource_id"), [ diff --git a/knowledge-fs/.harness/changes/2026-08-02-golden-question-generation-evidence-resolution.md b/knowledge-fs/.harness/changes/2026-08-02-golden-question-generation-evidence-resolution.md new file mode 100644 index 00000000000..83f12f55f5d --- /dev/null +++ b/knowledge-fs/.harness/changes/2026-08-02-golden-question-generation-evidence-resolution.md @@ -0,0 +1,60 @@ +# Golden-question published-generation evidence resolution + +Date: 2026-08-02 + +## What changed + +- Golden-question create and update validation now resolves evidence node ids across retained + publication generations. +- Production bad-case promotion and failed-query promotion use the same durable evidence lookup. +- Direct document-asset evidence ids, candidate permission checks, backing-asset checks, and + required-permission snapshots remain unchanged. +- Added a regression proving that a node from a published generation can be selected from evidence + search and saved as an expected evidence id. + +## Why + +Evidence matching reads the immutable published projection snapshot, so the returned node ids +belong to a publication generation. Golden-question validation previously called the ordinary +generation-scoped `getMany` repository method without a generation id. That method intentionally +defaults to legacy rows whose `publication_generation_id` is null. A valid published candidate was +therefore misclassified as a direct document-asset id and the save failed with `404 Expected +evidence not found`. + +The repository already owns `getManyByIdsAcrossGenerations` specifically for durable evidence +references whose globally unique ids survive publication changes. Using that lookup makes the +search and save boundaries agree without weakening authorization. + +## Performance and reliability + +- Node lookup remains one bounded batch for all selected evidence ids; there is no per-node query. +- The existing maximum of 50 expected evidence ids still bounds the cross-generation lookup. +- Backing document assets are still checked before persistence and malformed or unauthorized + permission scopes continue to fail closed. +- Published and retained generations are addressed only by globally unique node ids; no + unbounded generation scan is introduced. + +## Verification + +- TDD red phase reproduced the failure: a published-generation node was ignored by the legacy + `getMany` path and the expected permission scope was empty. +- Focused golden-question and failed-query handler and gateway tests passed: 4 files, 38 tests. +- KnowledgeFS API typecheck passed. +- `pnpm check` passed, including workspace tests, coverage gates, evaluations, contract checks, + migration checks, Compose validation, and smoke-test definitions. +- `pnpm build` passed for all 12 packages. +- The four changed TypeScript source/test files pass focused Biome checks, and `git diff --check` + passed. +- The Dify KnowledgeFS contract lock was intentionally refreshed and its `--check` command passed. + This fix does not add a Golden Question OpenAPI or Capability v2 contract change; the staged + OpenAPI digest update belongs to the separate logical-document deletion fix. + +## Known baseline + +- Full `pnpm lint` remains blocked by 10 pre-existing findings in unchanged Admin, fixture, + OpenAPI, and generated capability files. No finding is in a file changed by this fix. + +## Rollout + +- The KnowledgeFS API must be rebuilt and redeployed before retrying the Console PATCH request. +- The production request supplied for diagnosis was not replayed because it mutates user data. diff --git a/knowledge-fs/.harness/changes/2026-08-02-logical-document-initial-revision-delete.md b/knowledge-fs/.harness/changes/2026-08-02-logical-document-initial-revision-delete.md new file mode 100644 index 00000000000..33fc44e756d --- /dev/null +++ b/knowledge-fs/.harness/changes/2026-08-02-logical-document-initial-revision-delete.md @@ -0,0 +1,66 @@ +# Logical document initial-revision deletion + +Date: 2026-08-02 + +## What changed + +- Split logical-document deletion from document-asset deletion at both the Dify Console BFF and + KnowledgeFS request-contract boundaries. +- Logical-document deletion now accepts a non-negative row version, including the initial + `expectedRevision: 0` value returned by the logical-document API. +- Document-asset deletion remains strictly positive; the shared asset payload was not relaxed. +- Updated the durable deletion repository guard so compare-and-swap deletion can target an + initial logical-document row version of zero. +- Regenerated the Dify Console TypeScript contract and added controller, facade, handler, + repository, and generated-contract regression coverage. + +## Why + +Logical documents are created with `row_version = 0`, and their response contract already exposes +zero as a valid row version. The delete route incorrectly reused the document-asset payload, whose +version starts at one. As a result, a correct delete request for a newly created logical document +was rejected first by Dify's Pydantic validation and, if it reached KnowledgeFS, by the shared Zod +and repository positive-integer guards. + +This change gives the two independently versioned resources separate payload contracts and keeps +optimistic concurrency intact. It does not bypass or remove the expected-revision check. + +## Performance and reliability + +- The deletion path, transaction count, SQL compare-and-swap predicate, and durable job behavior + are unchanged. +- Validation remains constant time and no additional remote calls or database queries were added. +- A stale zero revision still conflicts after the logical document advances to a later row version. +- The idempotency-key requirement remains unchanged. + +## Verification + +- TDD red phase reproduced rejection at all affected boundaries: + - KnowledgeFS route validation rejected logical `expectedRevision: 0`. + - PostgreSQL and TiDB repository tests rejected a zero logical row version. + - Dify Console payload validation rejected a zero logical row version. + - The generated Console client contract rejected a zero logical row version. +- Focused KnowledgeFS handler and repository tests passed: 2 files, 85 tests. +- KnowledgeFS API typecheck passed. +- `pnpm check` and `pnpm build` passed. +- Dify controller and data-facade tests passed: 104 tests. +- Targeted Dify Ruff, Pyrefly, and Mypy checks passed. +- Generated Console logical-document deletion contract smoke test passed. +- Generated Console contract typecheck passed. +- Contract generation and lock checks passed. +- Targeted Python Ruff and KnowledgeFS Biome checks passed. +- `git diff --check` passed. + +## Known risks / follow-up + +- The Dify API and KnowledgeFS API must both be deployed before retrying the production request; + deploying only one side leaves another rejecting boundary in place. +- Full `pnpm lint` remains blocked by 10 pre-existing findings in unchanged Admin, fixture, + OpenAPI, and generated capability files. All changed KnowledgeFS TypeScript files pass focused + Biome checks. +- The standalone KnowledgeFS API coverage command executes all tests successfully but the existing + repository-wide branch result is 89.94%, below its 90% threshold. This change adds no runtime + branches, and the changed request-schema file reports 100% coverage. +- The production request supplied for diagnosis was not replayed because it was destructive. +- Temporary progress documents were not recreated; this change record is the traceability source + for this fix. diff --git a/knowledge-fs/packages/api/src/durable-deletion-handlers.test.ts b/knowledge-fs/packages/api/src/durable-deletion-handlers.test.ts index d0ac83bcd51..87b12655239 100644 --- a/knowledge-fs/packages/api/src/durable-deletion-handlers.test.ts +++ b/knowledge-fs/packages/api/src/durable-deletion-handlers.test.ts @@ -85,7 +85,7 @@ describe("durable deletion handlers", () => { const response = await app.request( `/knowledge-spaces/${SPACE_ID}/logical-documents/${DOCUMENT_ID}`, { - body: JSON.stringify({ expectedRevision: 4 }), + body: JSON.stringify({ expectedRevision: 0 }), headers: requestHeaders(), method: "DELETE", }, @@ -101,7 +101,7 @@ describe("durable deletion handlers", () => { expect.objectContaining({ callerKind: "interactive", documentId: DOCUMENT_ID, - expectedRevision: 4, + expectedRevision: 0, idempotencyKey: "delete-space-0001", knowledgeSpaceId: SPACE_ID, }), @@ -109,6 +109,19 @@ describe("durable deletion handlers", () => { expect(service.requestDocumentDeletion).not.toHaveBeenCalled(); }); + it("keeps document-asset deletion versions strictly positive", async () => { + const service = serviceStub(); + const app = testApp(service); + const response = await app.request(`/knowledge-spaces/${SPACE_ID}/documents/${DOCUMENT_ID}`, { + body: JSON.stringify({ expectedRevision: 0 }), + headers: requestHeaders(), + method: "DELETE", + }); + + expect(response.status).toBe(400); + expect(service.requestDocumentDeletion).not.toHaveBeenCalled(); + }); + it("fails closed with 503 when the durable service is not configured", async () => { const app = testApp(undefined); const response = await app.request( diff --git a/knowledge-fs/packages/api/src/durable-deletion-handlers.ts b/knowledge-fs/packages/api/src/durable-deletion-handlers.ts index 2329e13da76..4bef80eb630 100644 --- a/knowledge-fs/packages/api/src/durable-deletion-handlers.ts +++ b/knowledge-fs/packages/api/src/durable-deletion-handlers.ts @@ -6,6 +6,7 @@ import type { DeleteDocumentParams, DeleteKnowledgeSpaceBody, DeleteKnowledgeSpaceParams, + DeleteLogicalDocumentBody, DeleteSourceBody, DeleteSourceParams, DeleteSourceQuery, @@ -176,7 +177,7 @@ export function registerDurableDeletionHandlers({ return unavailable(context); } const params = context.req.valid("param") as DeleteDocumentParams; - const body = context.req.valid("json") as DeleteDocumentBody; + const body = context.req.valid("json") as DeleteLogicalDocumentBody; const headers = context.req.valid("header") as DurableDeletionIdempotencyHeaders; try { const accepted = await service.requestLogicalDocumentDeletion({ diff --git a/knowledge-fs/packages/api/src/durable-deletion-repository.test.ts b/knowledge-fs/packages/api/src/durable-deletion-repository.test.ts index f8afc209cc2..16341e69d2e 100644 --- a/knowledge-fs/packages/api/src/durable-deletion-repository.test.ts +++ b/knowledge-fs/packages/api/src/durable-deletion-repository.test.ts @@ -556,7 +556,7 @@ describe.each(["postgres", "tidb"] as const)( ? [ jobRow({ idempotency_key: "delete-logical-a", - target_revision: 4, + target_revision: 0, target_type: "logical_document", }), ] @@ -582,7 +582,7 @@ describe.each(["postgres", "tidb"] as const)( rows: [ { active_revision: null, - row_version: 4, + row_version: 0, source_id: null, status: "failed", }, @@ -638,7 +638,7 @@ describe.each(["postgres", "tidb"] as const)( accessChannel: "interactive", createdAt, documentId: targetId, - expectedDocumentRowVersion: 4, + expectedDocumentRowVersion: 0, idempotencyKey: "delete-logical-a", knowledgeSpaceId, permissionSnapshotId, @@ -648,7 +648,7 @@ describe.each(["postgres", "tidb"] as const)( }), ).resolves.toMatchObject({ created: true, - job: { targetRevision: 4, targetType: "logical_document" }, + job: { targetRevision: 0, targetType: "logical_document" }, }); const logicalUpdate = calls.find( @@ -665,7 +665,7 @@ describe.each(["postgres", "tidb"] as const)( tenantId, knowledgeSpaceId, targetId, - 4, + 0, ]); expect(assetUpdate?.inTransaction).toBe(true); expect(assetUpdate?.sql).toContain("owned_revision"); diff --git a/knowledge-fs/packages/api/src/durable-deletion-repository.ts b/knowledge-fs/packages/api/src/durable-deletion-repository.ts index bd0e7341eb7..8c0aa93b687 100644 --- a/knowledge-fs/packages/api/src/durable-deletion-repository.ts +++ b/knowledge-fs/packages/api/src/durable-deletion-repository.ts @@ -609,7 +609,7 @@ export function createDatabaseDurableDeletionRepository({ : target.type === "logical_document" ? { deleteMode: "cascade" as const, - expectedRevision: positiveInteger( + expectedRevision: nonnegativeInteger( (input as RequestLogicalDocumentDeletionInput).expectedDocumentRowVersion, "expectedDocumentRowVersion", ), diff --git a/knowledge-fs/packages/api/src/durable-deletion-request-schemas.ts b/knowledge-fs/packages/api/src/durable-deletion-request-schemas.ts index b650da1ac6d..76898156840 100644 --- a/knowledge-fs/packages/api/src/durable-deletion-request-schemas.ts +++ b/knowledge-fs/packages/api/src/durable-deletion-request-schemas.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi"; const ExpectedRevisionSchema = z.number().int().positive(); +const LogicalDocumentExpectedRevisionSchema = z.number().int().nonnegative(); export const DurableDeletionIdempotencyHeadersSchema = z .object({ @@ -51,6 +52,12 @@ export const DeleteDocumentBodySchema = z }) .strict(); +export const DeleteLogicalDocumentBodySchema = z + .object({ + expectedRevision: LogicalDocumentExpectedRevisionSchema, + }) + .strict(); + export const BulkDeleteDocumentsBodySchema = z .object({ documents: z @@ -72,6 +79,7 @@ export type DeleteSourceBody = z.infer; export type DeleteSourceParams = z.infer; export type DeleteSourceQuery = z.infer; export type DeleteDocumentBody = z.infer; +export type DeleteLogicalDocumentBody = z.infer; export type DeleteDocumentParams = z.infer; export type BulkDeleteDocumentsBody = z.infer; export type DurableDeletionIdempotencyHeaders = z.infer< diff --git a/knowledge-fs/packages/api/src/durable-deletion-routes.ts b/knowledge-fs/packages/api/src/durable-deletion-routes.ts index aafe9a3bcc3..e496716bff3 100644 --- a/knowledge-fs/packages/api/src/durable-deletion-routes.ts +++ b/knowledge-fs/packages/api/src/durable-deletion-routes.ts @@ -6,6 +6,7 @@ import { DeleteDocumentParamsSchema, DeleteKnowledgeSpaceBodySchema, DeleteKnowledgeSpaceParamsSchema, + DeleteLogicalDocumentBodySchema, DeleteSourceBodySchema, DeleteSourceParamsSchema, DurableDeleteSourceQuerySchema, @@ -129,7 +130,7 @@ export const requestLogicalDocumentDeletionRoute = createRoute({ path: "/knowledge-spaces/{id}/logical-documents/{documentId}", request: { body: { - content: { "application/json": { schema: DeleteDocumentBodySchema } }, + content: { "application/json": { schema: DeleteLogicalDocumentBodySchema } }, required: true, }, headers: DurableDeletionIdempotencyHeadersSchema, diff --git a/knowledge-fs/packages/api/src/failed-query-handlers-branch-coverage.test.ts b/knowledge-fs/packages/api/src/failed-query-handlers-branch-coverage.test.ts index 08e26a7cc6a..ba81b602dff 100644 --- a/knowledge-fs/packages/api/src/failed-query-handlers-branch-coverage.test.ts +++ b/knowledge-fs/packages/api/src/failed-query-handlers-branch-coverage.test.ts @@ -284,7 +284,7 @@ function failedQueryFixture(options: FailedQueryFixtureOptions = {}) { ...(options.triageRunner === null ? {} : { failedQueryTriageRunner: { run: triageRun } as never }), - nodes: { getMany: vi.fn(async () => []) } as never, + nodes: { getManyByIdsAcrossGenerations: vi.fn(async () => []) } as never, now: () => "2026-07-14T12:00:00.000Z", spaces: { get: vi.fn(async () => (options.space === undefined ? { id: SPACE_ID } : options.space)), diff --git a/knowledge-fs/packages/api/src/failed-query-handlers.ts b/knowledge-fs/packages/api/src/failed-query-handlers.ts index 229952e3753..cfb7d5cc114 100644 --- a/knowledge-fs/packages/api/src/failed-query-handlers.ts +++ b/knowledge-fs/packages/api/src/failed-query-handlers.ts @@ -34,7 +34,7 @@ export interface RegisterFailedQueryHandlersOptions { readonly failedQueries: FailedQueryRepository; readonly failedQueryTriageRunner?: FailedQueryTriageRunner | undefined; readonly now?: () => string; - readonly nodes: Pick; + readonly nodes: Pick; readonly spaces: KnowledgeSpaceRepository; } diff --git a/knowledge-fs/packages/api/src/golden-question-handlers-branch-coverage.test.ts b/knowledge-fs/packages/api/src/golden-question-handlers-branch-coverage.test.ts index e230bd67ada..d73b6845203 100644 --- a/knowledge-fs/packages/api/src/golden-question-handlers-branch-coverage.test.ts +++ b/knowledge-fs/packages/api/src/golden-question-handlers-branch-coverage.test.ts @@ -231,6 +231,33 @@ describe("golden-question handler branch coverage", () => { }); describe("golden-question evidence permission scope branches", () => { + it("resolves published-generation evidence nodes by their durable ids", async () => { + const getMany = vi.fn(async () => []); + const getManyByIdsAcrossGenerations = vi.fn(async () => [ + { + documentAssetId: ASSET_ID, + id: NODE_ID, + permissionScope: ["team:a"], + publicationGenerationId: "018f0d60-7a49-7cc2-9c1b-5b36f18f2c46", + }, + ]); + + await expect( + goldenQuestionEvidencePermissionScope({ + assets: { get: vi.fn(async () => ({ metadata: {} })) } as never, + candidateGrants: ["team:a"], + expectedEvidenceIds: [NODE_ID], + knowledgeSpaceId: SPACE_ID, + nodes: { getMany, getManyByIdsAcrossGenerations } as never, + }), + ).resolves.toEqual(["team:a"]); + expect(getManyByIdsAcrossGenerations).toHaveBeenCalledWith({ + ids: [NODE_ID], + knowledgeSpaceId: SPACE_ID, + }); + expect(getMany).not.toHaveBeenCalled(); + }); + it("handles empty, duplicate, direct-asset, and node-backed evidence", async () => { await expect(evidenceScope({ expectedEvidenceIds: [] })).resolves.toEqual([]); await expect(evidenceScope({ expectedEvidenceIds: [ASSET_ID, ASSET_ID] })).resolves.toBeNull(); @@ -353,7 +380,7 @@ function goldenFixture(options: GoldenFixtureOptions = {}) { get: vi.fn(async () => (options.asset === undefined ? { metadata: {} } : options.asset)), } as never, authorization: { authorize } as never, - nodes: { getMany: vi.fn(async () => []) } as never, + nodes: { getManyByIdsAcrossGenerations: vi.fn(async () => []) } as never, now: () => "2026-07-14T12:00:00.000Z", questions: questions as never, spaces: { @@ -414,7 +441,7 @@ function evidenceScope(options: { candidateGrants: options.candidateGrants ?? [], expectedEvidenceIds: options.expectedEvidenceIds, knowledgeSpaceId: SPACE_ID, - nodes: { getMany: vi.fn(async () => options.nodes ?? []) } as never, + nodes: { getManyByIdsAcrossGenerations: vi.fn(async () => options.nodes ?? []) } as never, }); } diff --git a/knowledge-fs/packages/api/src/golden-question-handlers.ts b/knowledge-fs/packages/api/src/golden-question-handlers.ts index 8bcf441fa5e..a7f7f83978c 100644 --- a/knowledge-fs/packages/api/src/golden-question-handlers.ts +++ b/knowledge-fs/packages/api/src/golden-question-handlers.ts @@ -62,7 +62,7 @@ export interface RegisterGoldenQuestionHandlersOptions { readonly assets: Pick; readonly authorization: KnowledgeSpaceAuthorizationGuard; readonly evidenceMatcher?: GoldenQuestionEvidenceMatcher | undefined; - readonly nodes: Pick; + readonly nodes: Pick; readonly now: () => string; readonly questions: GoldenQuestionRepository; readonly spaces: KnowledgeSpaceRepository; @@ -726,7 +726,7 @@ async function productionBadCaseEvidencePermissionScope(input: { readonly assets: Pick; readonly bundle: EvidenceBundle | null; readonly knowledgeSpaceId: string; - readonly nodes: Pick; + readonly nodes: Pick; readonly permissionScopes: readonly string[]; }): Promise { if (!input.bundle) { @@ -746,7 +746,7 @@ async function productionBadCaseEvidencePermissionScope(input: { .map((missing) => missing.expectedEvidenceId) .filter((id): id is string => id !== undefined), ); - const referencedNodes = await input.nodes.getMany({ + const referencedNodes = await input.nodes.getManyByIdsAcrossGenerations({ ids: uniqueStrings([...requiredNodeIds, ...optionalMissingNodeIds]), knowledgeSpaceId: input.knowledgeSpaceId, }); @@ -804,12 +804,12 @@ export async function goldenQuestionEvidencePermissionScope(input: { readonly candidateGrants: readonly string[]; readonly expectedEvidenceIds: readonly string[]; readonly knowledgeSpaceId: string; - readonly nodes: Pick; + readonly nodes: Pick; }): Promise { const evidenceIds = uniqueStrings(input.expectedEvidenceIds); if (evidenceIds.length !== input.expectedEvidenceIds.length) return null; if (evidenceIds.length === 0) return []; - const nodes = await input.nodes.getMany({ + const nodes = await input.nodes.getManyByIdsAcrossGenerations({ ids: evidenceIds, knowledgeSpaceId: input.knowledgeSpaceId, }); diff --git a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts index 2c6fc2cf6ee..1fea6734786 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/types.gen.ts @@ -378,6 +378,10 @@ export type KnowledgeFsLogicalDocumentListResponse = { next_cursor?: string | null } +export type KnowledgeFsLogicalDocumentDeletePayload = { + expectedRevision: number +} + export type KnowledgeFsMembersReplacePayload = { members: Array } @@ -2255,7 +2259,7 @@ export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponse = GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses] export type DeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdData = { - body: KnowledgeFsDocumentDeletePayload + body: KnowledgeFsLogicalDocumentDeletePayload headers: { 'Idempotency-Key': string } diff --git a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts index a56269c5f92..305f1ab65f0 100644 --- a/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts +++ b/packages/contracts/generated/api/console/knowledge-fs/zod.gen.ts @@ -269,6 +269,13 @@ export const zKnowledgeFsDocumentCompilationJobResponse = z.object({ version: z.int().gte(1), }) +/** + * KnowledgeFSLogicalDocumentDeletePayload + */ +export const zKnowledgeFsLogicalDocumentDeletePayload = z.object({ + expectedRevision: z.int().gte(0), +}) + /** * KnowledgeFSBadCaseCreatePayload */ @@ -2577,7 +2584,7 @@ export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponse = zKnowledgeFsLogicalDocumentListResponse export const zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdBody = - zKnowledgeFsDocumentDeletePayload + zKnowledgeFsLogicalDocumentDeletePayload export const zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdHeaders = z.object( { diff --git a/packages/contracts/knowledge-fs-logical-document-delete-contract.smoke.test.ts b/packages/contracts/knowledge-fs-logical-document-delete-contract.smoke.test.ts index 27ad7d3d166..1567e51bd8b 100644 --- a/packages/contracts/knowledge-fs-logical-document-delete-contract.smoke.test.ts +++ b/packages/contracts/knowledge-fs-logical-document-delete-contract.smoke.test.ts @@ -3,6 +3,7 @@ import { logicalDocuments } from './generated/api/console/knowledge-fs/orpc.gen' import { zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdBody, zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdHeaders, + zKnowledgeFsDocumentDeletePayload, } from './generated/api/console/knowledge-fs/zod.gen' describe('generated KnowledgeFS logical document deletion contract', () => { @@ -17,6 +18,11 @@ describe('generated KnowledgeFS logical document deletion contract', () => { zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdBody.safeParse({ expectedRevision: 0, }).success, + ).toBe(true) + expect( + zKnowledgeFsDocumentDeletePayload.safeParse({ + expectedRevision: 0, + }).success, ).toBe(false) expect( zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdHeaders.safeParse({