diff --git a/docs/README.md b/docs/README.md index e53a9501..1a273f30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,7 @@ Resource contracts and acceptance: - [DataProduct acceptance criteria](reference/data-product-acceptance.md) - [Responsibility contracts](reference/responsibility-contracts.md) - [Responsibility acceptance criteria](reference/responsibility-acceptance.md) -- [Decision and Document contracts (proposed)](reference/decision-and-document-contracts.md) +- [Decision and Document contracts](reference/decision-and-document-contracts.md) - [Decision and Document acceptance requirements](reference/decision-and-document-acceptance.md) ## Explanation diff --git a/docs/explanation/architecture-resource-roadmap.md b/docs/explanation/architecture-resource-roadmap.md index 37397f28..aad38091 100644 --- a/docs/explanation/architecture-resource-roadmap.md +++ b/docs/explanation/architecture-resource-roadmap.md @@ -92,12 +92,14 @@ coverage and records its deployment and acceptance limits. ## Decision and Document contracts -The proposed [Decision and Document contract](../reference/decision-and-document-contracts.md) +The [Decision and Document contract](../reference/decision-and-document-contracts.md) defines how rationale and supporting material select exact model subjects. Its [acceptance requirements](../reference/decision-and-document-acceptance.md) extend the connected fixture with artifact, navigation, permission and lifecycle -cases. These resources are not implemented; the contract precedes schema and -runtime work. +cases. Native schema, exact subject admission, retained content and anchor +selection, typed queries, CLI/MCP authoring and Studio review are implemented. +The acceptance reference distinguishes executable coverage from remaining +exhaustive and deployed-browser evidence. ## Source integration and format support diff --git a/docs/reference/data-product-contracts.md b/docs/reference/data-product-contracts.md index 159e4cb7..e91149af 100644 --- a/docs/reference/data-product-contracts.md +++ b/docs/reference/data-product-contracts.md @@ -335,8 +335,9 @@ Admission ceilings are 1,024 total ports, 4,096 total associations across all nested collections, and 1 MiB for the complete protobuf-encoded DataProduct. Each LineageInput counts as an association. Existing lower entity, RPC and store limits still apply. Exceeding a ceiling rejects atomically, without -truncating ports or references. DataProduct uses entity kind `33`, entity union -field `34`, and wire contract revision `12` with matching mutation revision floors. +truncating ports or references. DataProduct uses entity kind `33` and entity union +field `34`. It was introduced with wire contract revision `12`; connected clients +and servers must satisfy the current advertised mutation revision floors. The required visible resource scan budget is 5,000 rows combined across DataProducts, Channels, DataStores and Specifications in visible namespaces. diff --git a/docs/reference/decision-and-document-acceptance.md b/docs/reference/decision-and-document-acceptance.md index 3b3e599c..59113f21 100644 --- a/docs/reference/decision-and-document-acceptance.md +++ b/docs/reference/decision-and-document-acceptance.md @@ -1,9 +1,10 @@ # Decision and Document acceptance -Status: proposed acceptance requirements for the -[contract](decision-and-document-contracts.md). Decision/Document runtime and -client support are unimplemented. Existing source fixtures below establish -reusable native anchors, not passing acceptance for these resources. +Status: native Decision/Document schema, backend and client surfaces are +implemented under the [contract](decision-and-document-contracts.md). The +coverage section links executable cases and separates their evidence from +remaining acceptance requirements. Source fixtures below establish reusable +native anchors; their existence alone does not prove the new resource behavior. ## Connected fixture anchors @@ -14,9 +15,10 @@ reusable native anchors, not passing acceptance for these resources. | [DataProduct fixture](../../rsworkspace/crates/trogon-atlas-server/tests/data_product_fixture.rs) | Namespace `product-fixture`, version 1: `orders`, `api`, `warehouse`, `delivery`, `placed`, `place`, `handle`, `project` and `observe` connect product access and lineage to native behavior. `Submitted` and `Placed` are input port display names, not IDs. | | [Ecommerce fixture](../../rsworkspace/crates/trogon-atlas-server/tests/phase10_ecommerce_fixture.rs) | `order-to-fulfillment` Storyboard includes `place-order-slice`, `orders-list-slice`, `auto-capture-payment`, `capture-payment-slice` and subsequent fulfillment steps in authored order. Namespace `ecommerce`, version 1; use returned identities, not display positions. | -The proposed connected extension creates Decision `receipt-delivery-choice@1` +The [connected extension](../../rsworkspace/crates/trogon-atlas-server/tests/decision_document_fixture.rs) +creates Decision `receipt-delivery-choice@1` and Document `receipt-delivery-note@1` in `responsibility-fixture`. These are new -fixture requirements, not existing resources. The Decision accepts a retained +fixture resources. The Decision accepts a retained option with rationale and a RISK consequence. The Document retains Markdown and a declared `delivery` anchor. Both select `receipt`, each distinct observation ConnectionRef, the Receipt MESSAGE member and Receipts OUTPUT port. Capture @@ -53,7 +55,7 @@ UIDs or describe an assembled query as a historical transaction snapshot. | Stale connection UID, duplicate/malformed persisted IDs, removed occurrence or wrong native context | Reject selection after complete owner identity validation; no index/endpoint fallback. | | Selected owner has an unrelated invalid declaration | Resolve the selected identity/context without recursively validating unrelated outgoing edges; report independent diagnostics separately. | | New Decision/Document entity or association as CatalogSubject | Reject under the closed inventory. Support/navigation/RELATED do not create recursive subject kinds. | -| Team/Person assignment to proposed entity kinds | Preserve direct kinds and provenance; no inherited responsibility, authorization or authenticated approval. Assignment to anchors/options/new associations rejects. | +| Team/Person assignment to Decision/Document entity kinds | Preserve direct kinds and provenance; no inherited responsibility, authorization or authenticated approval. Assignment to anchors/options/new associations rejects. | ## Retained content and navigation @@ -99,8 +101,9 @@ UIDs or describe an assembled query as a historical transaction snapshot. | Hidden target direct read or query | NOT_FOUND without target titles, content, anchors, digest, path or existence disclosure; Reader mutation PERMISSION_DENIED. | | Readable owner with hidden target | Preserve already authored selectors; suppress new hydration details. Visible totals/pages reveal no hidden referrers. | | Exact target outside projection cap | Hydrate authorized owner independently of display caps; absence from projection is not nonexistence. | -| Discovery resource ceiling | Share 5,000 rows across existing resource families and proposed resources; count each exact selected-view key once. At the boundary permit completed work, beyond it return RESOURCE_EXHAUSTED. | -| Work/output ceilings | Share 10,000 examined association/anchor/replacement items and 4,096 output records request-wide. Repeated endpoints cost work; pages, batches and repeated queries within the request cannot reset budgets. | +| Discovery resource ceiling | Share 5,000 rows across existing resource families and Decision/Document; count each exact selected-view key once. At the boundary permit completed work, beyond it return RESOURCE_EXHAUSTED. | +| Work/output ceilings | Typed catalog queries and replacement admission share 10,000 examined association/anchor/replacement items request-wide; queries return at most 4,096 records. Repeated endpoints cost work; pages, batches and repeated queries within the request cannot reset budgets. | +| Complete retarget enumeration | Unrelated discovery occurrences do not block migration. Preserve complete resource-row bounds, staged-write admission and lower Store batch limits; reject output beyond 4,096 results before preview/apply can publish writes. | | Replacement admission cannot finish within budget | Reject atomically, never infer cycle absence or ACCEPTED termination from partial discovery. | | Bounded impact/closure/export truncation | Explicit incomplete metadata and visited identities; no verified negative claim or complete reapply artifact. | | Safe-delete reporting exceeds visible row budget | Existing capped reporting may be incomplete, but independent complete cross-namespace guard must still protect every target. | @@ -117,5 +120,25 @@ authored storyboard order and repeated occurrences. Browser/deployed evidence must be recorded separately from source and local test evidence. No existing fixture, successful documentation check or contract approval marks -these implementation requirements complete. This slice defines no runtime, -linter execution architecture or deployment acceptance. +every acceptance requirement complete. Linter execution and deployment +acceptance remain separate work. + +## Executable coverage and remaining evidence + +| Source | Exercised behavior | +| --- | --- | +| [Connected fixture](../../rsworkspace/crates/trogon-atlas-server/tests/decision_document_fixture.rs) | Uses exact version 1 in `responsibility-fixture`, a ReadModel, repeated observation occurrences, a Specification MESSAGE and a DataProduct OUTPUT port. Checks adopted choice, retained bytes and digest-pinned anchor, whole versus anchor incoming declarations, direct responsibility assignments, generic reference/impact provenance, reorder, stale byte pins and guarded/forced deletion with retained selectors. Exact hydration runs with display snapshot cap zero. | +| [Core identity regressions](../../rsworkspace/crates/trogon-atlas-core/tests/decision_document.rs) | Values, complete local identities, exact anchor/digest resolution, id-less reapply, removal and fresh identity preparation, graph references and selector-preserving retarget rules. | +| [Authenticated backend](../../rsworkspace/crates/trogon-atlas-server/tests/decision_document_backend.rs), [handler regressions](../../rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs) | Admission, replacement chains, typed detail/reverse lookup, hidden targets/referrers, repeated selectors, branches, final-batch delete protection, local-selector retarget refusal, operational failure and shared discovery/work/output ceilings. Delete-report tests distinguish reporting incompleteness from the independent complete safety guard. | +| [Store routing and guards](../../rsworkspace/crates/trogon-atlas-store/tests/decision_document_routing.rs) | New resource keys, nested reference protection and branch-state incoming discovery. The existing complete Store guard remains independent of visible query caps. | +| [Manifest/provenance checks](../../rsworkspace/crates/trogon-atlas-client/tests/decision_document_manifest.rs), [typed transport](../../rsworkspace/crates/trogon-atlas-client/tests/decision_document_transport.rs), [CLI](../../rsworkspace/crates/trogon-atlas-cli/tests/decision_document_cli_integration.rs), [MCP](../../rsworkspace/crates/trogon-atlas-mcp/tests/decision_document_mcp_integration.rs) | Native declarations, exact selectors, byte/pin and generated-ID preservation, unchanged reapply classification, typed operations, malformed input and incomplete-report transport/presentation. | +| [Studio inspector](../../tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.test.tsx), [API context](../../tsworkspace/webapps/studio/src/lib/decision-document-api.test.ts), [serializer boundary](../../tsworkspace/webapps/studio/server/decision-document-contract.test.mjs) | Read-only subject/detail review, exact navigation selections, historical no-current-hydration behavior and strict typed JSON validation before protobuf normalization can change request intent. | + +These tests establish the behavior they exercise, not exhaustive coverage of +every row above. Full subject/association inventory permutations, all +clone/merge and cross-namespace combinations, production-scale resource limits, +retention operations and deployed-browser acceptance still require separate +case-to-evidence records. Generic impact keeps an existing representative path +per owner; the connected fixture does not prove enumeration of every parallel +path. Native manifest round trips do not prove external import/synchronization +or source export fidelity. diff --git a/docs/reference/decision-and-document-contracts.md b/docs/reference/decision-and-document-contracts.md index 8a0874f9..81c1491b 100644 --- a/docs/reference/decision-and-document-contracts.md +++ b/docs/reference/decision-and-document-contracts.md @@ -1,10 +1,15 @@ # Decision and Document contracts -Status: proposed native resource contracts. Decision and Document have no -implemented schema, admission, storage, queries or client surfaces. Names below -are conceptual fields and operations, not allocated protobuf tags, entity-kind -numbers or RPCs. The [acceptance reference](decision-and-document-acceptance.md) -specifies required evidence; existing fixtures do not prove this proposal works. +Status: native schema, backend admission, typed queries, manifest/CLI/MCP +authoring and Studio review surfaces are implemented. The +[protobuf contract](../../proto/trogonatlas/eventmodel/v1alpha1/decision_document.proto) +defines the wire representation. The [acceptance reference](decision-and-document-acceptance.md) +links executable coverage and distinguishes it from exhaustive or deployed +acceptance evidence. + +Decision and Document use entity kinds 36 and 37 and union fields 37 and 38. +Contract revision and minimum client/server mutation revisions are 14; upgrade +connected clients and servers together. ## Scope @@ -22,7 +27,7 @@ format compiler, executable content or filesystem access in this proposal. ## Shared fields and values -| Field | Proposed contract | +| Field | Contract | | --- | --- | | `id` | Required existing Id: namespace, slug and exact positive breaking-change version. | | `title` | Required `CatalogTitle`. | @@ -49,7 +54,7 @@ still apply. Exceeding any limit rejects the whole write, without truncation. ## Decision values and state -| Field | Proposed contract | +| Field | Contract | | --- | --- | | `status` | Required PROPOSED, ACCEPTED, REJECTED, WITHDRAWN or SUPERSEDED. | | `context` | Required CatalogText describing the question and constraints. | @@ -106,7 +111,7 @@ and value, reject within a Decision. Queries do not recursively expand it by def ## Document body, identity and navigation -| Field | Proposed contract | +| Field | Contract | | --- | --- | | `body` | Required oneof `retained: RetainedDocumentText` or `link: ExternalDocumentLink`. | | `anchors` | At most 256 declared `DocumentAnchor` values; allowed only with retained body. | @@ -187,8 +192,8 @@ identity or mismatched kind/direction. No selection freezes mappings or behavior A historical conclusion requires checked bytes, branch, revision/etag and authorization context; current hydration is not historical evidence. -Team/Person responsibility entity subjects must explicitly admit the proposed -Decision and Document kinds at implementation. Existing assignment kinds, +Team/Person responsibility entity subjects admit the Decision and Document +kinds. Existing assignment kinds, assignee authority, direct/reverse queries and no-inheritance rules remain. No new assignments live on Decision or Document, and no Person becomes an authenticated approver. Responsibility subjects exclude these new associations, @@ -203,7 +208,7 @@ including pins; documentation, display labels and list order are excluded. Changing role, target or pin requires fresh association identity. All association and anchor inventories are validated before local selection. -| Operation | Proposed requirement | +| Operation | Requirement | | --- | --- | | Create/add | Omitted local/association IDs request server UUIDv7. Valid supplied IDs only on new-owner creation; existing-owner additions omit IDs. Persisted returned UID/IDs are authoritative. Preview reserves none. | | Edit/reorder | Preserve unchanged IDs; reject unknown IDs, changed fingerprints retaining IDs and stripping retained identities. | @@ -256,19 +261,31 @@ timeouts, decoding, backend and authorization-service errors retain operational failure semantics. Neither hidden, failed nor incomplete lookup is a verified empty result. Validation must not convert operational failures into model errors. -## Proposed operations, queries and bounds +## Native operations, queries and bounds -Implementation must integrate ordinary native entity authoring, manifests, +Ordinary native entity authoring, manifests, validation, branch operations, diff, preview, safe/force deletion, snapshot/export -and exact reads. No executable command/RPC spelling is allocated here. +and exact reads include Decision and Document. The +[query protobuf contract](../../proto/trogonatlas/api/eventmodel/v1alpha1/decision_documents.proto) +defines typed request and response fields. -| Conceptual query | Input and required result | +| RPC | Input and result | | --- | --- | -| Decisions/documents for subject | Exact CatalogSubject and optional typed Decision status filter. Return authorized owners, owner UID, association ID and unchanged full selector; no owner/endpoint/family expansion. | -| Decision detail | Exact DecisionRef. Return authored state, selected option and resolved/unresolved supporting/replacement/subject records; following replacement is explicit, bounded and cycle-aware. | -| Document detail | Exact DocumentRef or DocumentAnchorRef. Return authorized retained bytes/media/digest or unverified inert link and declared anchor metadata. | -| Incoming related/navigation/supporting declarations | Exact DecisionRef or DocumentSelection. Match full selector, including pin presence/value, without promoting anchor lookup to whole-document lookup. | -| Generic references/impact | Preserve containing from EntityRef/from_uid, field, ConnectionId, full typed declaration and selected owner as to. Owner-level impact finds nested selectors without replacing their provenance. | +| `ListSubjectDecisions` | Exact CatalogSubject and optional typed Decision status filter. Return authorized owners, owner UID, association ID and unchanged full selector; no owner/endpoint/family expansion. | +| `ListSubjectDocuments` | Exact CatalogSubject. Return authorized owners, owner UID, association ID and unchanged full selector; no owner/endpoint/family expansion. | +| `GetDecisionDetail` | Exact DecisionRef. Return authored state, selected option and resolved/unresolved supporting/replacement/subject records; following replacement is explicit, bounded and cycle-aware. | +| `GetDocumentDetail` | Exact DocumentSelection. Return authorized retained bytes/media/digest or unverified inert link and declared anchor metadata. | +| `ListIncomingDecisionDeclarations`, `ListIncomingDocumentDeclarations` | Exact DecisionRef or DocumentSelection. Match full selector, including pin presence/value, without promoting anchor lookup to whole-document lookup. | + +Generic reference results preserve containing `from` EntityRef, `from_uid`, +field, ConnectionId, full typed CatalogSubject or DocumentSelection and selected +owner as `to`. Owner-level impact preserves the full selector on its returned +path; it retains the existing one-path-per-owner traversal rather than enumerating +every parallel association path. Reference lists retain distinct occurrences. + +Resolution is `RESOLVED` for a checked selection, `UNRESOLVED` for an invalid +selection against a readable owner, and `UNAVAILABLE` when the selected owner +cannot be hydrated. Missing and hidden owners share the unavailable state. Queries default to direct declarations, not recursive relationship expansion. Explicit traversal uses visited typed identities and preserves every distinct @@ -281,12 +298,20 @@ unverified even when lookup itself completes successfully. Visible resource discovery extends the existing shared 5,000-row ceiling across Channels, DataStores, Specifications, DataProducts, Teams, Persons, Decisions and Documents. Count each exact kind/key/version once per selected view/request. -Examine at most 10,000 identified occurrences, anchors or replacement hops -combined and emit at most 4,096 records per request. Repeated equal endpoints -consume occurrence work; pagination, batch members and traversal loops never -reset budgets. Exact owner hydration bypasses display projection caps while -retaining entity/RPC limits. Replacement admission shares these work ceilings; -it fails atomically if chain termination cannot be established within them. +Typed catalog queries examine at most 10,000 identified occurrences, anchors or +replacement hops combined and emit at most 4,096 records per request. Repeated +equal endpoints consume occurrence work; pagination, batch members and traversal +loops never reset budgets. Exact owner hydration bypasses display projection +caps while retaining entity/RPC limits. Replacement admission shares these work +ceilings; it fails atomically if chain termination cannot be established within +them. + +Generic retargeting enumerates complete referrers independently of the discovery +occurrence ceiling, so unrelated assignments do not block a migration. It retains +the complete visible resource-row ceiling, staged-write admission budgets and a +4,096-result output ceiling. Exceeding the output ceiling refuses preview/apply +before writes; existing lower Store batch limits still apply. It never uses a +partial reporting fallback to choose mutation targets. Typed discovery exceeding a ceiling returns RESOURCE_EXHAUSTED. Existing bounded impact/closure/export surfaces may explicitly report incomplete/truncated @@ -301,3 +326,10 @@ a total timeout. Its safety decision does not use visible reporting caps. Any incomplete guard or operational failure refuses deletion. Only visible resource budget exhaustion permits the existing explicitly incomplete delete-report fallback; it never authorizes deletion. No persistent reverse index is promised. + +`DeleteEntityResponse.referrers_incomplete` marks incomplete visible reporting, +including a clipped reporting snapshot or a replay whose current report was not +reconstructed. It does not weaken the independent guard or assert anything about +hidden referrers. Reports share a 4,096-record request ceiling. Overflow refuses +the request before entity writes; only the visible resource-budget fallback may +clip its report at that ceiling while retaining the incomplete flag. diff --git a/docs/reference/failure-reasons.md b/docs/reference/failure-reasons.md index 69c2e28e..1f23556e 100644 --- a/docs/reference/failure-reasons.md +++ b/docs/reference/failure-reasons.md @@ -106,6 +106,15 @@ it carries its own code rather than an `ErrorInfo` reason: | `INVALID_OP` | The op itself was structurally invalid in a way request-time validation did not already reject (for example, violating a store-level invariant). | | `BATCH_OP_FAILED` | Fallback for any other store failure (a backend error, a lost change event, corrupt stored data). Not actionable by the caller beyond retrying or reporting it; the detail stays in the server log, not the response. | +## Catalog validation diagnostics + +`CATALOG_REFERENCE_UNRESOLVED` identifies invalid Decision/Document values or +unresolved selected subjects, document anchors and decision relationships in +validation results. `ListValidationRules` publishes its rule metadata; +`ValidateProject` issues retain that metadata and the visible field selection. +This issue code is distinct from a transport `ErrorInfo` reason. Operational +lookup failures remain errors rather than model diagnostics. + ## CLI exit codes `trogon-atlas`'s exit code is a direct function of the failure category (see diff --git a/docs/reference/responsibility-contracts.md b/docs/reference/responsibility-contracts.md index 090cdb39..6096609b 100644 --- a/docs/reference/responsibility-contracts.md +++ b/docs/reference/responsibility-contracts.md @@ -6,8 +6,9 @@ native client surfaces are implemented. The coverage from remaining acceptance requirements and deployed-browser evidence. Team and Person use entity kinds 34 and 35 and union fields 35 and 36. -Contract revision and minimum client/server mutation revisions are 13; -upgrade connected clients and servers together. +These resources were introduced with contract revision 13. Connected clients +and servers must satisfy the current advertised mutation revision floors; +upgrade them together. ## Scope and authority diff --git a/proto/trogonatlas/api/eventmodel/v1alpha1/decision_documents.proto b/proto/trogonatlas/api/eventmodel/v1alpha1/decision_documents.proto new file mode 100644 index 00000000..94312704 --- /dev/null +++ b/proto/trogonatlas/api/eventmodel/v1alpha1/decision_documents.proto @@ -0,0 +1,111 @@ +syntax = "proto3"; + +package trogonatlas.api.eventmodel.v1alpha1; + +import "trogonatlas/type/v1alpha1/connection_id.proto"; +import "trogonatlas/eventmodel/v1alpha1/identity.proto"; +import "trogonatlas/eventmodel/v1alpha1/event_model.proto"; +import "trogonatlas/eventmodel/v1alpha1/decision_document.proto"; + +enum CatalogResolutionState { + CATALOG_RESOLUTION_STATE_UNSPECIFIED = 0; + CATALOG_RESOLUTION_STATE_RESOLVED = 1; + CATALOG_RESOLUTION_STATE_UNAVAILABLE = 2; + CATALOG_RESOLUTION_STATE_UNRESOLVED = 3; +} +message CatalogQueryFailure { + string field = 1; + trogonatlas.type.v1alpha1.ConnectionId connection_id = 2; + int32 code = 3; + string message = 4; +} +message CatalogCheckedOwner { + trogonatlas.eventmodel.v1alpha1.EntityRef owner = 1; + string uid = 2; + string etag = 3; +} +message CatalogCheckedScope { + optional string branch = 1; + repeated string authorized_namespaces = 2; + string revision = 3; + repeated CatalogCheckedOwner visited = 4; + repeated trogonatlas.eventmodel.v1alpha1.CatalogSubject visited_subjects = 5; + repeated trogonatlas.eventmodel.v1alpha1.DocumentSelection visited_documents = 6; +} +message CatalogDeclaration { + CatalogCheckedOwner owner = 1; + string field = 2; + CatalogResolutionState state = 3; + oneof declaration { + trogonatlas.eventmodel.v1alpha1.SubjectAssociation subject = 4; + trogonatlas.eventmodel.v1alpha1.DecisionReplacement replacement = 5; + trogonatlas.eventmodel.v1alpha1.RelatedDecision related = 6; + trogonatlas.eventmodel.v1alpha1.SupportingDocument supporting = 7; + trogonatlas.eventmodel.v1alpha1.DocumentNavigation navigation = 8; + } +} +message ListSubjectDecisionsRequest { + trogonatlas.eventmodel.v1alpha1.CatalogSubject subject = 1; + optional trogonatlas.eventmodel.v1alpha1.DecisionStatus status = 2; +} +message ListSubjectDecisionsResponse { + repeated CatalogDeclaration declarations = 1; + CatalogCheckedScope scope = 2; + bool incomplete = 3; + repeated CatalogQueryFailure failures = 4; + CatalogResolutionState subject_state = 5; +} +message ListSubjectDocumentsRequest { + trogonatlas.eventmodel.v1alpha1.CatalogSubject subject = 1; +} +message ListSubjectDocumentsResponse { + repeated CatalogDeclaration declarations = 1; + CatalogCheckedScope scope = 2; + bool incomplete = 3; + repeated CatalogQueryFailure failures = 4; + CatalogResolutionState subject_state = 5; +} +message GetDecisionDetailRequest { + trogonatlas.eventmodel.v1alpha1.DecisionRef decision = 1; + bool follow_replacement = 2; +} +message GetDecisionDetailResponse { + trogonatlas.eventmodel.v1alpha1.Entity entity = 1; + string etag = 2; + repeated CatalogDeclaration declarations = 3; + CatalogCheckedScope scope = 4; + bool incomplete = 5; + repeated CatalogQueryFailure failures = 6; + repeated CatalogCheckedOwner replacement_chain = 7; +} +message GetDocumentDetailRequest { + trogonatlas.eventmodel.v1alpha1.DocumentSelection document = 1; +} +message GetDocumentDetailResponse { + trogonatlas.eventmodel.v1alpha1.Entity entity = 1; + string etag = 2; + trogonatlas.eventmodel.v1alpha1.DocumentAnchor anchor = 3; + repeated CatalogDeclaration declarations = 4; + CatalogCheckedScope scope = 5; + bool incomplete = 6; + repeated CatalogQueryFailure failures = 7; + CatalogResolutionState selection_state = 8; +} +message ListIncomingDecisionDeclarationsRequest { + trogonatlas.eventmodel.v1alpha1.DecisionRef decision = 1; +} +message ListIncomingDecisionDeclarationsResponse { + repeated CatalogDeclaration declarations = 1; + CatalogCheckedScope scope = 2; + bool incomplete = 3; + repeated CatalogQueryFailure failures = 4; +} +message ListIncomingDocumentDeclarationsRequest { + trogonatlas.eventmodel.v1alpha1.DocumentSelection document = 1; +} +message ListIncomingDocumentDeclarationsResponse { + repeated CatalogDeclaration declarations = 1; + CatalogCheckedScope scope = 2; + bool incomplete = 3; + repeated CatalogQueryFailure failures = 4; +} diff --git a/proto/trogonatlas/api/eventmodel/v1alpha1/graph.proto b/proto/trogonatlas/api/eventmodel/v1alpha1/graph.proto index 9078f058..cf4e3659 100644 --- a/proto/trogonatlas/api/eventmodel/v1alpha1/graph.proto +++ b/proto/trogonatlas/api/eventmodel/v1alpha1/graph.proto @@ -4,6 +4,8 @@ package trogonatlas.api.eventmodel.v1alpha1; import "trogonatlas/eventmodel/v1alpha1/identity.proto"; import "trogonatlas/eventmodel/v1alpha1/responsibility.proto"; +import "trogonatlas/eventmodel/v1alpha1/decision_document.proto"; +import "trogonatlas/type/v1alpha1/connection_id.proto"; // A typed pointer hit. `from` is the entity that holds the pointer (e.g. a // slice), `from_field` is the structural path inside that entity to the @@ -15,6 +17,9 @@ message Reference { trogonatlas.eventmodel.v1alpha1.Responsibility responsibility = 4; trogonatlas.eventmodel.v1alpha1.TeamMember membership = 5; string from_uid = 6; + trogonatlas.eventmodel.v1alpha1.CatalogSubject catalog_subject = 7; + trogonatlas.eventmodel.v1alpha1.DocumentSelection document_selection = 8; + trogonatlas.type.v1alpha1.ConnectionId connection_id = 9; } message ImpactNode { diff --git a/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto b/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto index 31f4648d..1b9ed3b9 100644 --- a/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto +++ b/proto/trogonatlas/api/eventmodel/v1alpha1/service.proto @@ -19,6 +19,7 @@ import "trogonatlas/api/eventmodel/v1alpha1/entity_kinds.proto"; import "trogonatlas/api/eventmodel/v1alpha1/type_libraries.proto"; import "trogonatlas/api/eventmodel/v1alpha1/data_products.proto"; import "trogonatlas/api/eventmodel/v1alpha1/responsibility.proto"; +import "trogonatlas/api/eventmodel/v1alpha1/decision_documents.proto"; // ============================================================================= @@ -310,6 +311,7 @@ message DeleteEntityResponse { // never block the delete. Empty on an operation_id replay, which has no // current view to judge. repeated ValidationIssue validation = 3; + bool referrers_incomplete = 4; } // ---------- Batch mutation (atomic multi-entity write) ----------------------- @@ -1154,6 +1156,13 @@ message ResolveBranchEntryResponse { // DeleteBranch : branch lifecycle (Phase 1: Isolation) service EventModelService { + rpc ListSubjectDecisions(ListSubjectDecisionsRequest) returns (ListSubjectDecisionsResponse); + rpc ListSubjectDocuments(ListSubjectDocumentsRequest) returns (ListSubjectDocumentsResponse); + rpc GetDecisionDetail(GetDecisionDetailRequest) returns (GetDecisionDetailResponse); + rpc GetDocumentDetail(GetDocumentDetailRequest) returns (GetDocumentDetailResponse); + rpc ListIncomingDecisionDeclarations(ListIncomingDecisionDeclarationsRequest) returns (ListIncomingDecisionDeclarationsResponse); + rpc ListIncomingDocumentDeclarations(ListIncomingDocumentDeclarationsRequest) returns (ListIncomingDocumentDeclarationsResponse); + rpc ListSubjectAssignments(ListSubjectAssignmentsRequest) returns (ListSubjectAssignmentsResponse); rpc ListAssigneeResponsibilities(ListAssigneeResponsibilitiesRequest) returns (ListAssigneeResponsibilitiesResponse); rpc ListTeamMembers(ListTeamMembersRequest) returns (ListTeamMembersResponse); diff --git a/proto/trogonatlas/eventmodel/v1alpha1/decision_document.proto b/proto/trogonatlas/eventmodel/v1alpha1/decision_document.proto new file mode 100644 index 00000000..35fdd02d --- /dev/null +++ b/proto/trogonatlas/eventmodel/v1alpha1/decision_document.proto @@ -0,0 +1,125 @@ +syntax = "proto3"; + +package trogonatlas.eventmodel.v1alpha1; + +import "google/protobuf/any.proto"; +import "trogonatlas/type/v1alpha1/id.proto"; +import "trogonatlas/type/v1alpha1/connection_id.proto"; +import "trogonatlas/eventmodel/v1alpha1/identity.proto"; +import "trogonatlas/eventmodel/v1alpha1/connections.proto"; +import "trogonatlas/eventmodel/v1alpha1/specification.proto"; +import "trogonatlas/eventmodel/v1alpha1/data_product.proto"; + +message CatalogTitle { string value = 1; } +message CatalogText { string value = 1; } +message LocalValueId { string value = 1; } +message ExternalDocumentLink { string value = 1; } +message AnchorFragment { string value = 1; } +message CatalogSubject { + oneof selection { + EntityRef entity = 1; + ConnectionRef connection = 2; + ContractMemberRef member = 3; + DataPortRef port = 4; + } +} +message SubjectAssociation { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + CatalogSubject subject = 2; + CatalogText doc = 3; +} +enum DecisionStatus { + DECISION_STATUS_UNSPECIFIED = 0; + DECISION_STATUS_PROPOSED = 1; + DECISION_STATUS_ACCEPTED = 2; + DECISION_STATUS_REJECTED = 3; + DECISION_STATUS_WITHDRAWN = 4; + DECISION_STATUS_SUPERSEDED = 5; +} +message DecisionOption { + LocalValueId option_id = 1; + CatalogTitle title = 2; + CatalogText description = 3; +} +message DecisionConsequence { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_BENEFIT = 1; + KIND_COST = 2; + KIND_RISK = 3; + } + Kind kind = 1; + CatalogText text = 2; +} +message DecisionReplacement { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + DecisionRef decision = 2; +} +message RelatedDecision { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + DecisionRef decision = 2; + CatalogText doc = 3; +} +message DocumentAnchorRef { + DocumentRef document = 1; + string owner_uid = 2; + LocalValueId anchor_id = 3; + ContentDigest expected_digest = 4; +} +message DocumentSelection { + oneof selection { + DocumentRef document = 1; + DocumentAnchorRef anchor = 2; + } +} +message SupportingDocument { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + DocumentSelection document = 2; + CatalogText doc = 3; +} +message Decision { + trogonatlas.type.v1alpha1.Id id = 1; + CatalogTitle title = 2; + optional string doc = 3; + repeated google.protobuf.Any metadata = 4; + trogonatlas.type.v1alpha1.Id supersedes = 5; + repeated SubjectAssociation subjects = 6; + DecisionStatus status = 7; + CatalogText context = 8; + CatalogText rationale = 9; + repeated DecisionOption options = 10; + LocalValueId selected_option = 11; + repeated DecisionConsequence consequences = 12; + DecisionReplacement replaced_by = 13; + repeated RelatedDecision related_decisions = 14; + repeated SupportingDocument documents = 15; +} +message RetainedDocumentText { + MediaType media_type = 1; + string content = 2; + ContentDigest digest = 3; +} +message DocumentAnchor { + LocalValueId anchor_id = 1; + AnchorFragment fragment = 2; + CatalogTitle label = 3; +} +message DocumentNavigation { + trogonatlas.type.v1alpha1.ConnectionId connection_id = 1; + DocumentSelection document = 2; + CatalogTitle label = 3; +} +message Document { + trogonatlas.type.v1alpha1.Id id = 1; + CatalogTitle title = 2; + optional string doc = 3; + repeated google.protobuf.Any metadata = 4; + trogonatlas.type.v1alpha1.Id supersedes = 5; + repeated SubjectAssociation subjects = 6; + oneof body { + RetainedDocumentText retained = 7; + ExternalDocumentLink link = 8; + } + repeated DocumentAnchor anchors = 9; + repeated DocumentNavigation navigation = 10; +} diff --git a/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto b/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto index dc7c8a7e..8d81fabd 100644 --- a/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto +++ b/proto/trogonatlas/eventmodel/v1alpha1/event_model.proto @@ -82,6 +82,7 @@ import "trogonatlas/eventmodel/v1alpha1/architecture.proto"; import "trogonatlas/eventmodel/v1alpha1/specification.proto"; import "trogonatlas/eventmodel/v1alpha1/data_product.proto"; import "trogonatlas/eventmodel/v1alpha1/responsibility.proto"; +import "trogonatlas/eventmodel/v1alpha1/decision_document.proto"; // ============================================================================= // Event Modeling schema. @@ -604,6 +605,8 @@ message Entity { DataProduct data_product = 34; Team team = 35; Person person = 36; + Decision decision = 37; + Document document = 38; } trogonatlas.type.v1alpha1.SystemMeta system = 29; } diff --git a/proto/trogonatlas/eventmodel/v1alpha1/identity.proto b/proto/trogonatlas/eventmodel/v1alpha1/identity.proto index 3a981058..92f140ef 100644 --- a/proto/trogonatlas/eventmodel/v1alpha1/identity.proto +++ b/proto/trogonatlas/eventmodel/v1alpha1/identity.proto @@ -96,7 +96,12 @@ enum EntityKind { ENTITY_KIND_DATA_PRODUCT = 33; ENTITY_KIND_TEAM = 34; ENTITY_KIND_PERSON = 35; + ENTITY_KIND_DECISION = 36; + ENTITY_KIND_DOCUMENT = 37; } message TeamRef { trogonatlas.type.v1alpha1.Id id = 1; } message PersonRef { trogonatlas.type.v1alpha1.Id id = 1; } + +message DecisionRef { trogonatlas.type.v1alpha1.Id id = 1; } +message DocumentRef { trogonatlas.type.v1alpha1.Id id = 1; } diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index a88cb750..c535733c 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -4479,6 +4479,7 @@ dependencies = [ "proptest", "prost", "prost-types", + "serde_json", "thiserror", "tokio", "tracing", diff --git a/rsworkspace/crates/trogon-atlas-cli/src/main.rs b/rsworkspace/crates/trogon-atlas-cli/src/main.rs index 1a3ddf0c..c2b7e331 100644 --- a/rsworkspace/crates/trogon-atlas-cli/src/main.rs +++ b/rsworkspace/crates/trogon-atlas-cli/src/main.rs @@ -127,6 +127,42 @@ enum Cmd { /// or an id that `describe` prints. entity: String, }, + /// List Decisions for an exact subject using proto3 JSON `ListSubjectDecisionsRequest`. + ListSubjectDecisions { + /// Proto3 JSON `ListSubjectDecisionsRequest`. + #[arg(long)] + request: String, + }, + /// List Documents for an exact subject using proto3 JSON `ListSubjectDocumentsRequest`. + ListSubjectDocuments { + /// Proto3 JSON `ListSubjectDocumentsRequest`. + #[arg(long)] + request: String, + }, + /// Inspect a Decision using proto3 JSON `GetDecisionDetailRequest`. + GetDecisionDetail { + /// Proto3 JSON `GetDecisionDetailRequest`. + #[arg(long)] + request: String, + }, + /// Inspect a whole Document or anchor using proto3 JSON `GetDocumentDetailRequest`. + GetDocumentDetail { + /// Proto3 JSON `GetDocumentDetailRequest`. + #[arg(long)] + request: String, + }, + /// Find declarations selecting a Decision using proto3 JSON `ListIncomingDecisionDeclarationsRequest`. + ListIncomingDecisionDeclarations { + /// Proto3 JSON `ListIncomingDecisionDeclarationsRequest`. + #[arg(long)] + request: String, + }, + /// Find declarations selecting a Document using proto3 JSON `ListIncomingDocumentDeclarationsRequest`. + ListIncomingDocumentDeclarations { + /// Proto3 JSON `ListIncomingDocumentDeclarationsRequest`. + #[arg(long)] + request: String, + }, /// Inspect authoritative DataProduct selections and checked model evidence. InspectDataProduct { /// Exact `data_product//@` address. @@ -847,6 +883,136 @@ async fn run( Some(data), )) } + Cmd::ListSubjectDecisions { request } => { + let json = serde_json::from_str(&request).map_err(|error| { + trogon_atlas_client::failure::invalid_input(format!( + "invalid request JSON: {error}" + )) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListSubjectDecisionsRequest", + &json, + )?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let result = trogon_atlas_client::decision_document::list_subject_decisions( + &mut client, + request, + ) + .await?; + if text { + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok((ExitCode::SUCCESS, Some(result))) + } + Cmd::ListSubjectDocuments { request } => { + let json = serde_json::from_str(&request).map_err(|error| { + trogon_atlas_client::failure::invalid_input(format!( + "invalid request JSON: {error}" + )) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListSubjectDocumentsRequest", + &json, + )?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let result = trogon_atlas_client::decision_document::list_subject_documents( + &mut client, + request, + ) + .await?; + if text { + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok((ExitCode::SUCCESS, Some(result))) + } + Cmd::GetDecisionDetail { request } => { + let json = serde_json::from_str(&request).map_err(|error| { + trogon_atlas_client::failure::invalid_input(format!( + "invalid request JSON: {error}" + )) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "GetDecisionDetailRequest", + &json, + )?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let result = + trogon_atlas_client::decision_document::get_decision_detail(&mut client, request) + .await?; + if text { + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok((ExitCode::SUCCESS, Some(result))) + } + Cmd::GetDocumentDetail { request } => { + let json = serde_json::from_str(&request).map_err(|error| { + trogon_atlas_client::failure::invalid_input(format!( + "invalid request JSON: {error}" + )) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "GetDocumentDetailRequest", + &json, + )?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let result = + trogon_atlas_client::decision_document::get_document_detail(&mut client, request) + .await?; + if text { + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok((ExitCode::SUCCESS, Some(result))) + } + Cmd::ListIncomingDecisionDeclarations { request } => { + let json = serde_json::from_str(&request).map_err(|error| { + trogon_atlas_client::failure::invalid_input(format!( + "invalid request JSON: {error}" + )) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListIncomingDecisionDeclarationsRequest", + &json, + )?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let result = + trogon_atlas_client::decision_document::list_incoming_decision_declarations( + &mut client, + request, + ) + .await?; + if text { + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok((ExitCode::SUCCESS, Some(result))) + } + Cmd::ListIncomingDocumentDeclarations { request } => { + let json = serde_json::from_str(&request).map_err(|error| { + trogon_atlas_client::failure::invalid_input(format!( + "invalid request JSON: {error}" + )) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListIncomingDocumentDeclarationsRequest", + &json, + )?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let result = + trogon_atlas_client::decision_document::list_incoming_document_declarations( + &mut client, + request, + ) + .await?; + if text { + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok((ExitCode::SUCCESS, Some(result))) + } Cmd::InspectDataProduct { entity } => { let address = view::parse_address(&entity)?; if address.kind != pb::EntityKind::DataProduct { diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/decision_document_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/decision_document_cli_integration.rs new file mode 100644 index 00000000..3a0f799c --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-cli/tests/decision_document_cli_integration.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::{process::Command, sync::Arc}; + +use tokio::net::TcpListener; +use trogon_atlas_proto::event_model_service_server::EventModelServiceServer; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +async fn start_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let store = trogon_atlas_testsupport::shared().await.store().await; + let service = + EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(EventModelServiceServer::new(service)) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + format!("http://{addr}") +} +fn cli(endpoint: &str, args: &[&str]) -> String { + let result = Command::new(env!("CARGO_BIN_EXE_trogon-atlas")) + .arg("--endpoint") + .arg(endpoint) + .args(args) + .output() + .unwrap(); + assert!( + result.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() +} + +const MANIFEST: &str = r#"apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Event +metadata: {namespace: catalog-surfaces, name: receipt} +spec: {title: Receipt} +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Document +metadata: {namespace: catalog-surfaces, name: note} +spec: + title: {value: Retained note} + subjects: [{subject: {entity: {kind: event, id: receipt}}}] + retained: + mediaType: {value: text/markdown} + content: "Retained notes\n" + digest: {sha256: 125a1e44e8f47bf4e244de8f09f31442749f57260dec8319d252e2992a3b16f1} + anchors: [{fragment: {value: Delivery}, label: {value: Delivery}}] +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Decision +metadata: {namespace: catalog-surfaces, name: choice} +spec: + title: {value: Delivery choice} + subjects: [{subject: {entity: {kind: event, id: receipt}}}] + status: DECISION_STATUS_PROPOSED + context: {value: Delivery question} + rationale: {value: Pending adoption} + options: [{title: {value: Retain}, description: {value: Retain note}}] + documents: [{document: {document: note}}] +"#; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_catalog_cli_apply_get_query_and_unchanged_reapply() { + let endpoint = start_server().await; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("catalog.yaml"); + std::fs::write(&path, MANIFEST).unwrap(); + cli(&endpoint, &["apply", "-f", path.to_str().unwrap()]); + let before = cli(&endpoint, &["get", "document/catalog-surfaces/note@1"]); + let document = trogon_atlas_client::manifest::parse_str(&before, "export.yaml").unwrap(); + let Some(trogon_atlas_proto::entity::Kind::Document(note)) = &document[0].entity.kind else { + panic!("document missing"); + }; + assert_ne!(note.anchors[0].anchor_id.as_ref().unwrap().value, ""); + cli(&endpoint, &["apply", "-f", path.to_str().unwrap()]); + assert_eq!( + before, + cli(&endpoint, &["get", "document/catalog-surfaces/note@1"]) + ); + let id = |slug| serde_json::json!({"namespace":"catalog-surfaces","slug":slug,"version":"1"}); + let subject = serde_json::json!({"entity":{"kind":"ENTITY_KIND_EVENT","id":id("receipt")}}); + for (command, request) in [ + ( + "list-subject-decisions", + serde_json::json!({"subject":subject}), + ), + ( + "list-subject-documents", + serde_json::json!({"subject":subject}), + ), + ( + "get-decision-detail", + serde_json::json!({"decision":{"id":id("choice")},"followReplacement":false}), + ), + ( + "get-document-detail", + serde_json::json!({"document":{"document":{"id":id("note")}}}), + ), + ( + "list-incoming-decision-declarations", + serde_json::json!({"decision":{"id":id("choice")}}), + ), + ( + "list-incoming-document-declarations", + serde_json::json!({"document":{"document":{"id":id("note")}}}), + ), + ] { + let response = cli(&endpoint, &[command, "--request", &request.to_string()]); + assert!(response.contains("scope"), "{command}: {response}"); + } +} +#[test] +fn malformed_native_catalog_query_fails_before_connecting() { + for (command, request) in [ + ("get-document-detail", "{not json"), + ("get-decision-detail", r#"{"followReplacement":"false"}"#), + ("list-subject-decisions", r#"{"status":"TYPO"}"#), + ( + "get-document-detail", + r#"{"document":{"document":{},"anchor":{}}}"#, + ), + ] { + let result = Command::new(env!("CARGO_BIN_EXE_trogon-atlas")) + .args([ + "--endpoint", + "http://127.0.0.1:1", + command, + "--request", + request, + ]) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(5)); + let error = String::from_utf8(result.stderr).unwrap(); + assert!(!error.contains("transport error"), "{error}"); + } +} diff --git a/rsworkspace/crates/trogon-atlas-client/manifest.schema.json b/rsworkspace/crates/trogon-atlas-client/manifest.schema.json index 698ba57d..01f892b8 100644 --- a/rsworkspace/crates/trogon-atlas-client/manifest.schema.json +++ b/rsworkspace/crates/trogon-atlas-client/manifest.schema.json @@ -259,6 +259,15 @@ ], "type": "string" }, + "trogonatlas.eventmodel.v1alpha1.AnchorFragment": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.ApiUsage": { "additionalProperties": false, "properties": { @@ -514,6 +523,92 @@ ], "type": "string" }, + "trogonatlas.eventmodel.v1alpha1.CatalogSubject": { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "entity", + "connection" + ] + } + }, + { + "not": { + "required": [ + "entity", + "member" + ] + } + }, + { + "not": { + "required": [ + "entity", + "port" + ] + } + }, + { + "not": { + "required": [ + "connection", + "member" + ] + } + }, + { + "not": { + "required": [ + "connection", + "port" + ] + } + }, + { + "not": { + "required": [ + "member", + "port" + ] + } + } + ], + "properties": { + "connection": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ConnectionRef" + }, + "entity": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.EntityRef" + }, + "member": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContractMemberRef" + }, + "port": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DataPortRef" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.CatalogText": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.CatalogTitle": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.Channel": { "additionalProperties": false, "allOf": [ @@ -1908,6 +2003,162 @@ } ] }, + "trogonatlas.eventmodel.v1alpha1.Decision": { + "additionalProperties": false, + "properties": { + "consequences": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionConsequence" + }, + "type": "array" + }, + "context": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + }, + "doc": { + "type": "string" + }, + "documents": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SupportingDocument" + }, + "type": "array" + }, + "metadata": { + "items": { + "properties": { + "@type": { + "type": "string" + } + }, + "required": [ + "@type" + ], + "type": "object" + }, + "type": "array" + }, + "options": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionOption" + }, + "type": "array" + }, + "rationale": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + }, + "relatedDecisions": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.RelatedDecision" + }, + "type": "array" + }, + "replacedBy": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionReplacement" + }, + "selectedOption": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.LocalValueId" + }, + "status": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionStatus" + }, + "subjects": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SubjectAssociation" + }, + "type": "array" + }, + "supersedes": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + }, + "title": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogTitle" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.DecisionConsequence": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionConsequence.Kind" + }, + "text": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.DecisionConsequence.Kind": { + "enum": [ + "KIND_BENEFIT", + "KIND_COST", + "KIND_RISK", + "KIND_UNSPECIFIED" + ], + "type": "string" + }, + "trogonatlas.eventmodel.v1alpha1.DecisionOption": { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + }, + "optionId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.LocalValueId" + }, + "title": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogTitle" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.DecisionRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.DecisionReplacement": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "decision": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionRef" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.DecisionStatus": { + "enum": [ + "DECISION_STATUS_ACCEPTED", + "DECISION_STATUS_PROPOSED", + "DECISION_STATUS_REJECTED", + "DECISION_STATUS_SUPERSEDED", + "DECISION_STATUS_UNSPECIFIED", + "DECISION_STATUS_WITHDRAWN" + ], + "type": "string" + }, "trogonatlas.eventmodel.v1alpha1.DeclaredLineage": { "additionalProperties": false, "properties": { @@ -1950,6 +2201,162 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.Document": { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "retained", + "link" + ] + } + } + ], + "properties": { + "anchors": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentAnchor" + }, + "type": "array" + }, + "doc": { + "type": "string" + }, + "link": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ExternalDocumentLink" + }, + "metadata": { + "items": { + "properties": { + "@type": { + "type": "string" + } + }, + "required": [ + "@type" + ], + "type": "object" + }, + "type": "array" + }, + "navigation": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentNavigation" + }, + "type": "array" + }, + "retained": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.RetainedDocumentText" + }, + "subjects": { + "items": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.SubjectAssociation" + }, + "type": "array" + }, + "supersedes": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + }, + "title": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogTitle" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.DocumentAnchor": { + "additionalProperties": false, + "properties": { + "anchorId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.LocalValueId" + }, + "fragment": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.AnchorFragment" + }, + "label": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogTitle" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.DocumentAnchorRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "anchorId": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.LocalValueId" + }, + "document": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentRef" + }, + "expectedDigest": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContentDigest" + }, + "ownerUid": { + "type": "string" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.DocumentNavigation": { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "document": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentSelection" + }, + "label": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogTitle" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.DocumentRef": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.Id" + } + }, + "type": "object" + } + ] + }, + "trogonatlas.eventmodel.v1alpha1.DocumentSelection": { + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "document", + "anchor" + ] + } + } + ], + "properties": { + "anchor": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentAnchorRef" + }, + "document": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentRef" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.Domain": { "additionalProperties": false, "properties": { @@ -2032,6 +2439,8 @@ "Component", "DataProduct", "DataStore", + "Decision", + "Document", "Domain", "ENTITY_KIND_ALERT_NOTIFICATION_TARGET", "ENTITY_KIND_ALERT_POLICY", @@ -2044,6 +2453,8 @@ "ENTITY_KIND_COMPONENT", "ENTITY_KIND_DATA_PRODUCT", "ENTITY_KIND_DATA_STORE", + "ENTITY_KIND_DECISION", + "ENTITY_KIND_DOCUMENT", "ENTITY_KIND_DOMAIN", "ENTITY_KIND_EVENT", "ENTITY_KIND_EVENT_MODEL", @@ -2109,6 +2520,8 @@ "data-store", "data_product", "data_store", + "decision", + "document", "domain", "event", "event-model", @@ -2348,6 +2761,15 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.ExternalDocumentLink": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.ExternalSystem": { "additionalProperties": false, "properties": { @@ -3023,6 +3445,15 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.LocalValueId": { + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.MediaType": { "additionalProperties": false, "properties": { @@ -4072,6 +4503,28 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.RelatedDecision": { + "oneOf": [ + { + "$ref": "#/$defs/_ShorthandRef" + }, + { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "decision": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DecisionRef" + }, + "doc": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + } + }, + "type": "object" + } + ] + }, "trogonatlas.eventmodel.v1alpha1.ResidencyPolicy": { "additionalProperties": false, "properties": { @@ -4180,6 +4633,21 @@ }, "type": "object" }, + "trogonatlas.eventmodel.v1alpha1.RetainedDocumentText": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "digest": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.ContentDigest" + }, + "mediaType": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.MediaType" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.RetentionPolicy": { "additionalProperties": false, "properties": { @@ -5102,6 +5570,36 @@ } ] }, + "trogonatlas.eventmodel.v1alpha1.SubjectAssociation": { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + }, + "subject": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogSubject" + } + }, + "type": "object" + }, + "trogonatlas.eventmodel.v1alpha1.SupportingDocument": { + "additionalProperties": false, + "properties": { + "connectionId": { + "$ref": "#/$defs/trogonatlas.type.v1alpha1.ConnectionId" + }, + "doc": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.CatalogText" + }, + "document": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.DocumentSelection" + } + }, + "type": "object" + }, "trogonatlas.eventmodel.v1alpha1.Swimlane": { "additionalProperties": false, "properties": { @@ -7014,6 +7512,52 @@ } } } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "Decision", + "ENTITY_KIND_DECISION", + "decision" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "spec": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.Decision" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "Document", + "ENTITY_KIND_DOCUMENT", + "document" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "spec": { + "$ref": "#/$defs/trogonatlas.eventmodel.v1alpha1.Document" + } + } + } } ], "description": "CRD-style YAML document accepted by `trogon-atlas apply` / trogon-atlas-client manifest loading.", diff --git a/rsworkspace/crates/trogon-atlas-client/src/apply.rs b/rsworkspace/crates/trogon-atlas-client/src/apply.rs index c24fc2a2..e742fe47 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/apply.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/apply.rs @@ -68,8 +68,10 @@ pub fn classify(desired: &pb::Entity, current: Option<&pb::Entity>) -> Action { if cur.encode_to_vec() == want.encode_to_vec() { return Action::Unchanged; } - if trogon_atlas_core::connections::equivalent_with_unassigned_ids(&cur, &want) + if trogon_atlas_core::decision_document::equivalent_manifest(&cur, &want) .unwrap_or(false) + || trogon_atlas_core::connections::equivalent_with_unassigned_ids(&cur, &want) + .unwrap_or(false) { return Action::Unchanged; } diff --git a/rsworkspace/crates/trogon-atlas-client/src/decision_document.rs b/rsworkspace/crates/trogon-atlas-client/src/decision_document.rs new file mode 100644 index 00000000..ce0e92bf --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-client/src/decision_document.rs @@ -0,0 +1,109 @@ +use anyhow::{Context, Result}; +use serde_json::Value; +use trogon_atlas_core::transcode::TranscodePool; +use trogon_atlas_proto as pb; + +use crate::client::Client; +pub use crate::responsibility::parse_request; + +pub async fn list_subject_decisions( + client: &mut Client, + request: pb::ListSubjectDecisionsRequest, +) -> Result { + let response = client + .list_subject_decisions(request) + .await + .context("ListSubjectDecisions failed")? + .into_inner(); + TranscodePool::builtin()? + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.ListSubjectDecisionsResponse", + &response, + ) + .context("encode ListSubjectDecisions response") +} + +pub async fn list_subject_documents( + client: &mut Client, + request: pb::ListSubjectDocumentsRequest, +) -> Result { + let response = client + .list_subject_documents(request) + .await + .context("ListSubjectDocuments failed")? + .into_inner(); + TranscodePool::builtin()? + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.ListSubjectDocumentsResponse", + &response, + ) + .context("encode ListSubjectDocuments response") +} + +pub async fn get_decision_detail( + client: &mut Client, + request: pb::GetDecisionDetailRequest, +) -> Result { + let response = client + .get_decision_detail(request) + .await + .context("GetDecisionDetail failed")? + .into_inner(); + TranscodePool::builtin()? + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.GetDecisionDetailResponse", + &response, + ) + .context("encode GetDecisionDetail response") +} + +pub async fn get_document_detail( + client: &mut Client, + request: pb::GetDocumentDetailRequest, +) -> Result { + let response = client + .get_document_detail(request) + .await + .context("GetDocumentDetail failed")? + .into_inner(); + TranscodePool::builtin()? + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.GetDocumentDetailResponse", + &response, + ) + .context("encode GetDocumentDetail response") +} + +pub async fn list_incoming_decision_declarations( + client: &mut Client, + request: pb::ListIncomingDecisionDeclarationsRequest, +) -> Result { + let response = client + .list_incoming_decision_declarations(request) + .await + .context("ListIncomingDecisionDeclarations failed")? + .into_inner(); + TranscodePool::builtin()? + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.ListIncomingDecisionDeclarationsResponse", + &response, + ) + .context("encode ListIncomingDecisionDeclarations response") +} + +pub async fn list_incoming_document_declarations( + client: &mut Client, + request: pb::ListIncomingDocumentDeclarationsRequest, +) -> Result { + let response = client + .list_incoming_document_declarations(request) + .await + .context("ListIncomingDocumentDeclarations failed")? + .into_inner(); + TranscodePool::builtin()? + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.ListIncomingDocumentDeclarationsResponse", + &response, + ) + .context("encode ListIncomingDocumentDeclarations response") +} diff --git a/rsworkspace/crates/trogon-atlas-client/src/export.rs b/rsworkspace/crates/trogon-atlas-client/src/export.rs index 4f3b5a70..5fe9f590 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/export.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/export.rs @@ -243,6 +243,8 @@ pub(crate) fn entity_label(entity: &pb::Entity) -> String { K::Specification(x) => x.id.as_ref(), K::Team(x) => x.id.as_ref(), K::Person(x) => x.id.as_ref(), + K::Decision(x) => x.id.as_ref(), + K::Document(x) => x.id.as_ref(), }; match id { Some(id) => pb::canonical::id_string(entity_kind_of(kind), id), @@ -291,6 +293,8 @@ fn entity_kind_of(kind: &pb::entity::Kind) -> pb::EntityKind { K::Specification(_) => pb::EntityKind::Specification, K::Team(_) => pb::EntityKind::Team, K::Person(_) => pb::EntityKind::Person, + K::Decision(_) => pb::EntityKind::Decision, + K::Document(_) => pb::EntityKind::Document, } } diff --git a/rsworkspace/crates/trogon-atlas-client/src/lib.rs b/rsworkspace/crates/trogon-atlas-client/src/lib.rs index 19292ae6..cd3b6660 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/lib.rs @@ -13,6 +13,7 @@ pub mod branch; pub mod client; pub mod compat; pub mod data_product; +pub mod decision_document; pub mod export; pub mod failure; pub mod fmt; diff --git a/rsworkspace/crates/trogon-atlas-client/src/manifest.rs b/rsworkspace/crates/trogon-atlas-client/src/manifest.rs index 80cda356..91b79ae2 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/manifest.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/manifest.rs @@ -441,6 +441,8 @@ pub(crate) fn shorthand_ref_field( "trogonatlas.eventmodel.v1alpha1.ChannelAccess" | "trogonatlas.eventmodel.v1alpha1.StoreAccess" | "trogonatlas.eventmodel.v1alpha1.ResponsibilitySubject" + | "trogonatlas.eventmodel.v1alpha1.CatalogSubject" + | "trogonatlas.eventmodel.v1alpha1.DocumentSelection" ) { return None; } diff --git a/rsworkspace/crates/trogon-atlas-client/src/view.rs b/rsworkspace/crates/trogon-atlas-client/src/view.rs index 2b36fc48..a9b4cd32 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/view.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/view.rs @@ -213,7 +213,10 @@ pub fn describe( let line = |r: &pb::Reference, other: Option<&pb::EntityRef>| -> Result { let provenance = (!r.from_uid.is_empty() || r.responsibility.is_some() - || r.membership.is_some()) + || r.membership.is_some() + || r.catalog_subject.is_some() + || r.document_selection.is_some() + || r.connection_id.is_some()) .then(|| types.message_json_value("trogonatlas.api.eventmodel.v1alpha1.Reference", r)) .transpose() .context("encode exact reference provenance")?; diff --git a/rsworkspace/crates/trogon-atlas-client/tests/decision_document_manifest.rs b/rsworkspace/crates/trogon-atlas-client/tests/decision_document_manifest.rs new file mode 100644 index 00000000..24eb5cc8 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-client/tests/decision_document_manifest.rs @@ -0,0 +1,181 @@ +#![allow(clippy::unwrap_used)] + +use serde_json::json; +use trogon_atlas_client::{decision_document, export, manifest, manifest_schema}; +use trogon_atlas_core::transcode::TranscodePool; +use trogon_atlas_proto as pb; + +fn subjects() -> serde_json::Value { + json!([ + {"connectionId":{"value":"association-a"},"subject":{"entity":{"kind":"read_model","id":"receipt@2"}}}, + {"connectionId":{"value":"association-b"},"subject":{"connection":{"owner":{"kind":"automation_slice","id":"observe@3"},"ownerUid":"owner-uid","id":{"value":"occurrence-b"}}}}, + {"connectionId":{"value":"association-c"},"subject":{"member":{"specification":"contract@4","ownerUid":"spec-uid","kind":"CONTRACT_MEMBER_KIND_ARTIFACT","memberId":{"value":"artifact-id"},"expectedDigest":{"sha256":"a".repeat(64)}}}}, + {"connectionId":{"value":"association-d"},"subject":{"port":{"product":"product@5","ownerUid":"product-uid","direction":"DATA_PORT_DIRECTION_OUTPUT","portId":{"value":"port-id"}}}} + ]) +} +fn manifest(kind: &str, spec: &serde_json::Value) -> serde_json::Value { + json!({"apiVersion":manifest::API_VERSION,"kind":kind,"metadata":{"namespace":"catalog","name":"note","version":2},"spec":spec}) +} +#[test] +fn native_catalog_manifests_preserve_bytes_pins_local_ids_and_authored_order() { + let anchor = json!({"anchor":{"document":"notes/delivery@7","ownerUid":"document-uid","anchorId":{"value":"anchor-id"},"expectedDigest":{"sha256":"b".repeat(64)}}}); + let decision = json!({"title":{"value":"Choice"},"supersedes":"note@1","subjects":subjects(),"status":"DECISION_STATUS_ACCEPTED","context":{"value":"Question"},"rationale":{"value":"Rationale"},"options":[{"optionId":{"value":"option-b"},"title":{"value":"B"},"description":{"value":"Alternative"}},{"optionId":{"value":"option-a"},"title":{"value":"A"},"description":{"value":"Alternative A"}}],"selectedOption":{"value":"option-b"},"consequences":[{"kind":"KIND_RISK","text":{"value":"Delivery risk"}}],"documents":[{"connectionId":{"value":"support-id"},"document":anchor}]}); + let text = "# Delivery\r\n\nCafe\u{301} \n"; + let document = json!({"title":{"value":"Notes"},"subjects":subjects(),"retained":{"mediaType":{"value":"text/markdown"},"content":text,"digest":{"sha256":"c".repeat(64)}},"anchors":[{"anchorId":{"value":"anchor-b"},"fragment":{"value":"Delivery"},"label":{"value":"Delivery"}}],"navigation":[{"connectionId":{"value":"navigation-id"},"document":anchor}]}); + let schema = manifest_schema::generate().unwrap(); + let validator = jsonschema::validator_for(&schema).unwrap(); + for raw in [ + manifest("Decision", &decision), + manifest("Document", &document), + manifest( + "Document", + &json!({"title":{"value":"External"},"subjects":subjects(),"link":{"value":"https://example.test/notes"}}), + ), + ] { + assert!(validator.is_valid(&raw)); + let loaded = manifest::parse_str(&raw.to_string(), "catalog.yaml").unwrap(); + let output = export::render_board( + std::slice::from_ref(&loaded[0].entity), + &[], + &TranscodePool::builtin().unwrap(), + ) + .unwrap(); + assert_eq!(output.skipped, []); + let reparsed = manifest::parse_str(&output.manifests, "export.yaml").unwrap(); + assert_eq!(reparsed[0].entity, loaded[0].entity); + if let Some(pb::entity::Kind::Document(doc)) = &loaded[0].entity.kind { + if let Some(pb::document::Body::Retained(body)) = &doc.body { + assert_eq!(body.content.as_bytes(), text.as_bytes()); + } + } + } +} +#[test] +fn catalog_selectors_require_explicit_alternatives_in_manifest_and_schema() { + for spec in [ + json!({"subjects":[{"subject":"receipt@2"}]}), + json!({"navigation":[{"document":"note@2"}]}), + ] { + let raw = manifest("Document", &spec); + assert!(manifest::parse_str(&raw.to_string(), "invalid.yaml").is_err()); + assert!( + !jsonschema::validator_for(&manifest_schema::generate().unwrap()) + .unwrap() + .is_valid(&raw) + ); + } +} +#[test] +fn native_query_json_retains_full_selection_and_optional_filter_presence() { + let raw = json!({"document":{"anchor":{"document":{"id":{"namespace":"notes","slug":"delivery","version":"9007199254740993"}},"ownerUid":"incarnation","anchorId":{"value":"anchor-b"},"expectedDigest":{"sha256":"d".repeat(64)}}}}); + let request: pb::GetDocumentDetailRequest = + decision_document::parse_request("GetDocumentDetailRequest", &raw).unwrap(); + let value = TranscodePool::builtin() + .unwrap() + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.GetDocumentDetailRequest", + &request, + ) + .unwrap(); + assert_eq!(value, raw); + for invalid in [ + json!({"unknown":true}), + json!({"document":{"document":{},"anchor":{}}}), + json!({"document":{"anchor":{"ownerUid":false}}}), + ] { + assert!( + decision_document::parse_request::( + "GetDocumentDetailRequest", + &invalid + ) + .is_err() + ); + } + let absent: pb::ListSubjectDecisionsRequest = + decision_document::parse_request("ListSubjectDecisionsRequest", &json!({})).unwrap(); + let present: pb::ListSubjectDecisionsRequest = decision_document::parse_request( + "ListSubjectDecisionsRequest", + &json!({"status":"DECISION_STATUS_PROPOSED"}), + ) + .unwrap(); + assert_eq!(absent.status, None); + assert_eq!(present.status, Some(pb::DecisionStatus::Proposed as i32)); +} + +#[test] +fn generic_description_preserves_catalog_association_provenance_and_nested_pins() { + let types = TranscodePool::builtin().unwrap(); + let reference_json = json!({"from":{"kind":"ENTITY_KIND_DECISION","id":{"namespace":"catalog","slug":"choice","version":"1"}},"fromUid":"owner-incarnation","fromField":"documents","to":{"kind":"ENTITY_KIND_DOCUMENT","id":{"namespace":"catalog","slug":"note","version":"2"}},"connectionId":{"value":"support-occurrence"},"documentSelection":{"anchor":{"document":{"id":{"namespace":"catalog","slug":"note","version":"2"}},"ownerUid":"document-incarnation","anchorId":{"value":"anchor-b"},"expectedDigest":{"sha256":"e".repeat(64)}}}}); + let reference: pb::Reference = types + .message_from_json_value( + "trogonatlas.api.eventmodel.v1alpha1.Reference", + &reference_json, + ) + .unwrap(); + let decision: pb::Entity = types.message_from_json_value("trogonatlas.eventmodel.v1alpha1.Entity",&json!({"decision":{"id":{"namespace":"catalog","slug":"choice","version":"1"},"title":{"value":"Choice"}}})).unwrap(); + let description = + trogon_atlas_client::view::describe(&decision, &types, &[reference], &[]).unwrap(); + let rendered = serde_json::to_value(description).unwrap(); + assert_eq!(rendered["outgoing"][0]["provenance"], reference_json); + assert_eq!(rendered["title"], "Choice"); +} + +#[test] +fn unchanged_native_catalog_reapply_classifies_as_unchanged_without_stripping_edits() { + let subject = json!({"subject":{"entity":{"kind":"ENTITY_KIND_EVENT","id":"receipt"}}}); + for (kind, spec) in [ + ( + "Decision", + json!({"title":{"value":"Choice"},"subjects":[subject],"status":"DECISION_STATUS_PROPOSED","context":{"value":"Question"},"rationale":{"value":"Reason"},"options":[{"title":{"value":"Retain"},"description":{"value":"Retain notes"}}]}), + ), + ( + "Document", + json!({"title":{"value":"Note"},"subjects":[subject],"retained":{"mediaType":{"value":"text/plain"},"content":"note","digest":{"sha256":"edb465624291e4053c6c5ea4b7eb320dec773e10a57d26b95dcf0564f8e310f8"}},"anchors":[{"fragment":{"value":"Delivery"},"label":{"value":"Delivery"}}]}), + ), + ] { + let desired = manifest::parse_str(&manifest(kind, &spec).to_string(), "catalog.yaml") + .unwrap() + .remove(0) + .entity; + let stored = + trogon_atlas_core::connection_persistence::prepare_entity(None, &desired).unwrap(); + assert_eq!( + trogon_atlas_client::apply::classify(&desired, Some(&stored)), + trogon_atlas_client::apply::Action::Unchanged, + "{kind}" + ); + let mut changed_spec = spec; + changed_spec["title"] = json!({"value":"Changed wording"}); + let changed = + manifest::parse_str(&manifest(kind, &changed_spec).to_string(), "changed.yaml") + .unwrap() + .remove(0) + .entity; + assert_eq!( + trogon_atlas_client::apply::classify(&changed, Some(&stored)), + trogon_atlas_client::apply::Action::Configure, + "changed {kind}" + ); + } +} + +#[test] +fn typed_delete_report_distinguishes_incomplete_empty_reporting_from_complete_absence() { + use prost::Message as _; + let response = pb::DeleteEntityResponse { + operation_receipt: None, + referrers: vec![], + referrers_incomplete: true, + validation: vec![], + }; + let decoded = pb::DeleteEntityResponse::decode(response.encode_to_vec().as_slice()).unwrap(); + assert!(decoded.referrers_incomplete); + let json = TranscodePool::builtin() + .unwrap() + .message_json_value( + "trogonatlas.api.eventmodel.v1alpha1.DeleteEntityResponse", + &decoded, + ) + .unwrap(); + assert_eq!(json["referrersIncomplete"], true); +} diff --git a/rsworkspace/crates/trogon-atlas-client/tests/decision_document_transport.rs b/rsworkspace/crates/trogon-atlas-client/tests/decision_document_transport.rs new file mode 100644 index 00000000..24e1a974 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-client/tests/decision_document_transport.rs @@ -0,0 +1,97 @@ +#![allow(clippy::unwrap_used)] + +use std::convert::Infallible; + +use tonic::{body::Body, Code, Status}; +use trogon_atlas_client::{ + client::{client_for_transport, ConnectOptions, Transport}, + decision_document, + failure::{Failure, FailureCategory}, +}; +use trogon_atlas_proto as pb; + +#[tokio::test] +async fn native_catalog_queries_preserve_branch_auth_and_operational_failure_codes() { + for (rpc, code) in [ + ("ListSubjectDecisions", Code::NotFound), + ("ListSubjectDocuments", Code::PermissionDenied), + ("GetDecisionDetail", Code::Unavailable), + ("GetDocumentDetail", Code::DeadlineExceeded), + ("ListIncomingDecisionDeclarations", Code::ResourceExhausted), + ("ListIncomingDocumentDeclarations", Code::InvalidArgument), + ] { + let transport = Transport::new(tower::service_fn( + move |request: http::Request| async move { + assert!(request.uri().path().ends_with(rpc)); + assert_eq!(request.headers()["x-trogon-atlas-branch"], "catalog-review"); + assert_eq!(request.headers()["authorization"], "Bearer test-token"); + assert!(request.headers().contains_key("x-request-id")); + Ok::<_, Infallible>(Status::new(code, "query refused").into_http::()) + }, + )); + let mut client = client_for_transport( + transport, + Some("test-token"), + &ConnectOptions { + branch: Some("catalog-review".into()), + ..Default::default() + }, + ) + .unwrap(); + let result = match rpc { + "ListSubjectDecisions" => { + decision_document::list_subject_decisions( + &mut client, + pb::ListSubjectDecisionsRequest::default(), + ) + .await + } + "ListSubjectDocuments" => { + decision_document::list_subject_documents( + &mut client, + pb::ListSubjectDocumentsRequest::default(), + ) + .await + } + "GetDecisionDetail" => { + decision_document::get_decision_detail( + &mut client, + pb::GetDecisionDetailRequest::default(), + ) + .await + } + "GetDocumentDetail" => { + decision_document::get_document_detail( + &mut client, + pb::GetDocumentDetailRequest::default(), + ) + .await + } + "ListIncomingDecisionDeclarations" => { + decision_document::list_incoming_decision_declarations( + &mut client, + pb::ListIncomingDecisionDeclarationsRequest::default(), + ) + .await + } + "ListIncomingDocumentDeclarations" => { + decision_document::list_incoming_document_declarations( + &mut client, + pb::ListIncomingDocumentDeclarationsRequest::default(), + ) + .await + } + + _ => unreachable!(), + }; + let error = result.unwrap_err(); + assert_eq!(error.downcast_ref::().unwrap().code(), code); + let category = match code { + Code::NotFound => FailureCategory::NotFound, + Code::PermissionDenied => FailureCategory::Unauthorized, + Code::Unavailable | Code::DeadlineExceeded => FailureCategory::Unavailable, + _ => FailureCategory::Validation, + }; + assert_eq!(Failure::classify(&error).category, category); + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs b/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs index 37af8450..77d8efb6 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/connection_persistence.rs @@ -10,7 +10,7 @@ use crate::{ reconcile_branch_connection_ids, reconcile_connection_ids, renew_connection_ids, validate_stored_connection_ids, ConnectionError, ConnectionId, }, - data_product, specification, stamp_system, + data_product, decision_document, specification, stamp_system, }; /// Prepare an untrusted write against the value guarded by the store's CAS. @@ -20,12 +20,14 @@ pub fn prepare_entity( ) -> Result { let mut prepared = proposed.clone(); if let Some(previous) = previous { - if specification::equivalent_manifest(previous, proposed)? + if decision_document::equivalent_manifest(previous, proposed)? + || specification::equivalent_manifest(previous, proposed)? || data_product::equivalent_manifest(previous, proposed)? { return Ok(previous.clone()); } } + decision_document::prepare_values(previous, &mut prepared, false)?; specification::prepare_members(previous, &mut prepared, false)?; data_product::prepare_ports(previous, &mut prepared, false)?; match previous { @@ -49,6 +51,8 @@ pub fn prepare_landing( previous: Option<&Entity>, branch: &Entity, ) -> Result { + let removed_values = decision_document::removed_values(base, previous, branch)?; + decision_document::reject_removed(branch, &removed_values)?; let removed_ports = data_product::removed_ports(base, previous, branch)?; data_product::reject_removed(branch, &removed_ports)?; let removed_members = specification::removed_members(base, previous, branch)?; @@ -63,6 +67,7 @@ pub fn prepare_landing( } let previous = previous.filter(|entity| same_incarnation(entity, branch)); let mut prepared = branch.clone(); + decision_document::prepare_values(previous, &mut prepared, true)?; specification::prepare_members(previous, &mut prepared, true)?; data_product::prepare_ports(previous, &mut prepared, true)?; if let Some(previous) = previous { @@ -90,6 +95,8 @@ pub fn prepare_resolution( let removed = removed_base_ids(base, discarded, selected)?; let mut resolved = selected.clone(); renew_connection_ids(&mut resolved, &removed)?; + let removed_values = decision_document::removed_values(base, discarded, selected)?; + decision_document::restore_values(&mut resolved, &removed_values)?; let removed_members = specification::removed_members(base, discarded, selected)?; specification::restore_members(&mut resolved, &removed_members)?; let removed_ports = data_product::removed_ports(base, discarded, selected)?; diff --git a/rsworkspace/crates/trogon-atlas-core/src/connections.rs b/rsworkspace/crates/trogon-atlas-core/src/connections.rs index e02d8b55..e3c055ae 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/connections.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/connections.rs @@ -97,6 +97,8 @@ pub enum ConnectionError { RemovedFromBranchBase(ConnectionId), #[error("specification identity error: {0}")] Member(String), + #[error("catalog identity error: {0}")] + Catalog(String), #[error("data port identity error: {0}")] Port(String), #[error("connection encoding error: {0}")] @@ -208,7 +210,7 @@ fn visit( && wrapper_target(descriptor.full_name()).is_none() { let mut endpoint = message.clone(); - for name in ["connection_id", "doc"] { + for name in ["connection_id", "doc", "label"] { if let Some(field) = descriptor.get_field_by_name(name) { endpoint.clear_field(&field); } diff --git a/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs b/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs new file mode 100644 index 00000000..03723715 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/src/decision_document.rs @@ -0,0 +1,715 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt, + str::FromStr, +}; + +use prost::Message; +use trogon_atlas_proto::{self as pb, entity, Entity, EntityKind}; + +use crate::{ + connections::{self, ConnectionError}, + entity_id, entity_kind, responsibility, +}; + +pub const MAX_SUBJECTS: usize = 1_024; +pub const MAX_ASSOCIATIONS: usize = 2_048; +pub const MAX_ENCODED_BYTES: usize = 1_048_576; +pub const MAX_DISCOVERY_ROWS: usize = 5_000; +pub const MAX_EXAMINED_OCCURRENCES: usize = 10_000; +pub const MAX_OUTPUT_RECORDS: usize = 4_096; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[error("{0}")] +pub struct CatalogError(pub String); + +type Result = std::result::Result; + +fn require(valid: bool, message: &str) -> Result<()> { + if valid { + Ok(()) + } else { + Err(CatalogError(message.into())) + } +} +fn failure(error: impl fmt::Display) -> CatalogError { + CatalogError(error.to_string()) +} +fn connection_error(error: CatalogError) -> ConnectionError { + ConnectionError::Catalog(error.0) +} +fn exact(id: Option<&pb::Id>) -> Result<()> { + let id = id.ok_or_else(|| CatalogError("exact entity ID required".into()))?; + require( + id.version > 0 + && crate::is_safe_id_component(&id.namespace) + && crate::is_safe_id_component(&id.slug), + "invalid exact entity ID", + ) +} +fn text(value: &str, limit: usize) -> Result<()> { + require( + !value.trim().is_empty() && value.len() <= limit, + "catalog text blank or byte limit exceeded", + ) +} +fn title(value: Option<&pb::CatalogTitle>) -> Result<()> { + text( + &value + .ok_or_else(|| CatalogError("catalog title required".into()))? + .value, + 256, + ) +} +fn prose(value: Option<&pb::CatalogText>) -> Result<()> { + text( + &value + .ok_or_else(|| CatalogError("catalog text required".into()))? + .value, + 16_384, + ) +} +fn digest(value: &pb::ContentDigest) -> Result<()> { + require( + value.sha256.len() == 64 + && value + .sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "invalid SHA-256 digest", + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LocalValueId(uuid::Uuid); +impl LocalValueId { + #[must_use] + pub fn new() -> Self { + Self(uuid::Uuid::now_v7()) + } +} +impl Default for LocalValueId { + fn default() -> Self { + Self::new() + } +} +impl fmt::Display for LocalValueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +impl FromStr for LocalValueId { + type Err = CatalogError; + fn from_str(value: &str) -> Result { + let parsed = uuid::Uuid::parse_str(value).map_err(failure)?; + require( + !parsed.is_nil() && parsed.to_string() == value, + "local ID must be non-nil lowercase hyphenated UUID", + )?; + Ok(Self(parsed)) + } +} +impl From for pb::LocalValueId { + fn from(value: LocalValueId) -> Self { + Self { + value: value.to_string(), + } + } +} + +#[must_use] +pub fn as_responsibility_subject(subject: &pb::CatalogSubject) -> pb::ResponsibilitySubject { + use pb::{catalog_subject::Selection as C, responsibility_subject::Selection as R}; + pb::ResponsibilitySubject { + selection: subject.selection.as_ref().map(|value| match value { + C::Entity(v) => R::Entity(v.clone()), + C::Connection(v) => R::Connection(v.clone()), + C::Member(v) => R::Member(v.clone()), + C::Port(v) => R::Port(v.clone()), + }), + } +} +pub fn subject_owner(subject: &pb::CatalogSubject) -> Result { + let owner = + responsibility::subject_owner(&as_responsibility_subject(subject)).map_err(failure)?; + require( + !matches!( + EntityKind::try_from(owner.kind), + Ok(EntityKind::Decision | EntityKind::Document) + ), + "Decision and Document are excluded from catalog subject inventory", + )?; + Ok(owner) +} +pub fn validate_subject(subject: &pb::CatalogSubject) -> Result<()> { + subject_owner(subject)?; + responsibility::validate_subject(&as_responsibility_subject(subject)).map_err(failure) +} +pub fn resolve_subject(subject: &pb::CatalogSubject, owner: &Entity) -> Result<()> { + validate_subject(subject)?; + responsibility::resolve_subject(&as_responsibility_subject(subject), owner).map_err(failure) +} +pub fn document_owner(selection: &pb::DocumentSelection) -> Result { + let reference = match &selection.selection { + Some(pb::document_selection::Selection::Document(v)) => Some(v), + Some(pb::document_selection::Selection::Anchor(v)) => v.document.as_ref(), + None => None, + } + .ok_or_else(|| CatalogError("document selection required".into()))?; + exact(reference.id.as_ref())?; + Ok(pb::EntityRef { + kind: EntityKind::Document as i32, + id: reference.id.clone(), + }) +} +pub fn validate_document_selection(selection: &pb::DocumentSelection) -> Result<()> { + document_owner(selection)?; + if let Some(pb::document_selection::Selection::Anchor(v)) = &selection.selection { + v.owner_uid.parse::()?; + v.anchor_id + .as_ref() + .ok_or_else(|| CatalogError("anchor ID required".into()))? + .value + .parse::()?; + if let Some(pin) = &v.expected_digest { + digest(pin)?; + } + } + Ok(()) +} +pub fn resolve_document_selection<'a>( + selection: &pb::DocumentSelection, + owner: &'a Entity, +) -> Result> { + validate_document_selection(selection)?; + let selected = document_owner(selection)?; + require( + entity_kind(owner) == Some(EntityKind::Document) + && entity_id(owner) == selected.id.as_ref(), + "document selection does not identify exact owner", + )?; + let Some(entity::Kind::Document(document)) = &owner.kind else { + return Err(CatalogError("document required".into())); + }; + let Some(pb::document_selection::Selection::Anchor(reference)) = &selection.selection else { + return Ok(None); + }; + require( + owner + .system + .as_ref() + .is_some_and(|s| s.uid == reference.owner_uid), + "document incarnation mismatch", + )?; + validate_stored_values(owner)?; + connections::validate_stored_connection_ids(owner).map_err(failure)?; + let Some(pb::document::Body::Retained(body)) = &document.body else { + return Err(CatalogError("anchor requires retained body".into())); + }; + if let Some(pin) = &reference.expected_digest { + digest(pin)?; + require( + pin.sha256 == crate::specification::content_digest(&body.content).sha256, + "document byte pin mismatch", + )?; + } + document + .anchors + .iter() + .find(|a| a.anchor_id == reference.anchor_id) + .map(Some) + .ok_or_else(|| CatalogError("document anchor missing".into())) +} + +#[must_use] +pub fn subjects(entity: &Entity) -> &[pb::SubjectAssociation] { + match &entity.kind { + Some(entity::Kind::Decision(v)) => &v.subjects, + Some(entity::Kind::Document(v)) => &v.subjects, + _ => &[], + } +} +fn validate_subjects(entity: &Entity) -> Result<()> { + let values = subjects(entity); + require( + !values.is_empty() && values.len() <= MAX_SUBJECTS, + "subject collection outside bounds", + )?; + let mut seen = HashSet::new(); + for value in values { + let subject = value + .subject + .as_ref() + .ok_or_else(|| CatalogError("subject required".into()))?; + validate_subject(subject)?; + require( + seen.insert(subject.encode_to_vec()), + "duplicate full catalog subject", + )?; + if value.doc.is_some() { + prose(value.doc.as_ref())?; + } + } + Ok(()) +} +fn validate_selection_list<'a>( + values: impl Iterator< + Item = ( + &'a Option, + Option<&'a pb::CatalogText>, + ), + >, + owner: Option<&pb::Id>, + reject_self: bool, +) -> Result<()> { + let mut seen = HashSet::new(); + for (selection, doc) in values { + let selection = selection + .as_ref() + .ok_or_else(|| CatalogError("document selection required".into()))?; + validate_document_selection(selection)?; + if reject_self { + require( + document_owner(selection)?.id.as_ref() != owner, + "self navigation forbidden", + )?; + } + require( + seen.insert(selection.encode_to_vec()), + "duplicate full document selection", + )?; + if doc.is_some() { + prose(doc)?; + } + } + Ok(()) +} +fn validate_decision(value: &pb::Decision) -> Result<()> { + title(value.title.as_ref())?; + prose(value.context.as_ref())?; + prose(value.rationale.as_ref())?; + let status = pb::DecisionStatus::try_from(value.status).map_err(failure)?; + require( + status != pb::DecisionStatus::Unspecified, + "decision status required", + )?; + let adopted = matches!( + status, + pb::DecisionStatus::Accepted | pb::DecisionStatus::Superseded + ); + require( + value.selected_option.is_some() == adopted, + "selected option required exactly for adopted statuses", + )?; + require( + value.replaced_by.is_some() == (status == pb::DecisionStatus::Superseded), + "replacement required exactly for superseded status", + )?; + require( + !value.options.is_empty() && value.options.len() <= 64, + "option collection outside bounds", + )?; + for option in &value.options { + title(option.title.as_ref())?; + prose(option.description.as_ref())?; + } + if let Some(selected) = &value.selected_option { + selected.value.parse::()?; + require( + value + .options + .iter() + .any(|o| o.option_id.as_ref() == Some(selected)), + "selected option missing", + )?; + } + require( + value.consequences.len() <= 64 && (!adopted || !value.consequences.is_empty()), + "consequence collection outside bounds", + )?; + let mut seen = HashSet::new(); + for consequence in &value.consequences { + require( + matches!( + pb::decision_consequence::Kind::try_from(consequence.kind), + Ok(pb::decision_consequence::Kind::Benefit + | pb::decision_consequence::Kind::Cost + | pb::decision_consequence::Kind::Risk) + ), + "unknown consequence kind", + )?; + prose(consequence.text.as_ref())?; + require( + seen.insert(consequence.encode_to_vec()), + "duplicate consequence", + )?; + } + if let Some(replacement) = &value.replaced_by { + let target = replacement + .decision + .as_ref() + .ok_or_else(|| CatalogError("replacement decision required".into()))?; + exact(target.id.as_ref())?; + require(target.id != value.id, "self replacement forbidden")?; + } + require( + value.related_decisions.len() <= 128 && value.documents.len() <= 128, + "decision relationship limit exceeded", + )?; + seen.clear(); + for related in &value.related_decisions { + let target = related + .decision + .as_ref() + .ok_or_else(|| CatalogError("related decision required".into()))?; + exact(target.id.as_ref())?; + require( + target.id != value.id && seen.insert(target.encode_to_vec()), + "self or duplicate related decision", + )?; + if related.doc.is_some() { + prose(related.doc.as_ref())?; + } + } + validate_selection_list( + value + .documents + .iter() + .map(|v| (&v.document, v.doc.as_ref())), + None, + false, + )?; + require( + value.subjects.len() + + value.related_decisions.len() + + value.documents.len() + + usize::from(value.replaced_by.is_some()) + <= MAX_ASSOCIATIONS, + "association limit exceeded", + ) +} +fn validate_document(value: &pb::Document) -> Result<()> { + title(value.title.as_ref())?; + match &value.body { + Some(pb::document::Body::Retained(body)) => { + require( + matches!( + body.media_type.as_ref().map(|m| m.value.as_str()), + Some("text/plain" | "text/markdown") + ), + "unsupported document media type", + )?; + require( + !body.content.is_empty() && body.content.len() <= 262_144, + "retained text outside byte bounds", + )?; + let supplied = body + .digest + .as_ref() + .ok_or_else(|| CatalogError("document digest required".into()))?; + digest(supplied)?; + require( + supplied.sha256 == crate::specification::content_digest(&body.content).sha256, + "document digest does not match exact bytes", + )?; + } + Some(pb::document::Body::Link(link)) => { + require( + value.anchors.is_empty(), + "link document cannot declare anchors", + )?; + let contact = pb::ContactPoint { + channel: Some(pb::contact_point::Channel::Web(pb::ContactUrl { + value: link.value.clone(), + })), + label: None, + }; + responsibility::validate_contact(&contact).map_err(failure)?; + } + None => return Err(CatalogError("document body required".into())), + } + require( + value.anchors.len() <= 256 && value.navigation.len() <= 128, + "document collection limit exceeded", + )?; + let mut seen = HashSet::new(); + for anchor in &value.anchors { + title(anchor.label.as_ref())?; + let fragment = &anchor + .fragment + .as_ref() + .ok_or_else(|| CatalogError("anchor fragment required".into()))? + .value; + require( + fragment.len() <= 128 + && fragment + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic()) + && fragment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b)), + "invalid anchor fragment", + )?; + require(seen.insert(fragment), "duplicate anchor fragment")?; + } + validate_selection_list( + value.navigation.iter().map(|v| (&v.document, None)), + value.id.as_ref(), + true, + )?; + for nav in &value.navigation { + if nav.label.is_some() { + title(nav.label.as_ref())?; + } + } + require( + value.subjects.len() + value.navigation.len() <= MAX_ASSOCIATIONS, + "association limit exceeded", + ) +} +pub fn validate_values(entity: &Entity) -> Result<()> { + match &entity.kind { + Some(entity::Kind::Decision(value)) => validate_decision(value)?, + Some(entity::Kind::Document(value)) => validate_document(value)?, + _ => return Ok(()), + } + exact(entity_id(entity))?; + require( + entity.encoded_len() <= MAX_ENCODED_BYTES, + "catalog entity byte limit exceeded", + )?; + validate_subjects(entity)?; + inventory(entity, false)?; + connections::validate_connection_ids(entity).map_err(failure) +} + +fn each_local( + entity: &mut Entity, + mut visit: impl FnMut(&mut Option, &[u8]) -> Result<()>, +) -> Result<()> { + match &mut entity.kind { + Some(entity::Kind::Decision(value)) => { + for option in &mut value.options { + visit(&mut option.option_id, b"option")?; + } + } + Some(entity::Kind::Document(value)) => { + for anchor in &mut value.anchors { + let mut fingerprint = b"anchor:".to_vec(); + fingerprint.extend( + anchor + .fragment + .as_ref() + .map_or(&[][..], |f| f.value.as_bytes()), + ); + visit(&mut anchor.anchor_id, &fingerprint)?; + } + } + _ => {} + } + Ok(()) +} +fn inventory(entity: &Entity, persisted: bool) -> Result>> { + let mut out = HashMap::new(); + each_local(&mut entity.clone(), |id, signature| { + if let Some(value) = id { + let id = value.value.parse::()?; + require( + out.insert(id, signature.to_vec()).is_none(), + "duplicate local value ID", + )?; + } else { + require(!persisted, "persisted local ID missing")?; + } + Ok(()) + })?; + Ok(out) +} +pub fn validate_stored_values(entity: &Entity) -> Result<()> { + inventory(entity, true)?; + if let Some(entity::Kind::Document(value)) = &entity.kind { + let mut seen = HashSet::new(); + require( + value.anchors.len() <= 256, + "anchor inventory limit exceeded", + )?; + for anchor in &value.anchors { + let fragment = &anchor + .fragment + .as_ref() + .ok_or_else(|| CatalogError("anchor fragment required".into()))? + .value; + require( + fragment.len() <= 128 + && fragment + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic()) + && fragment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b)), + "invalid anchor fragment", + )?; + require(seen.insert(fragment), "duplicate anchor fragment")?; + } + } + Ok(()) +} + +pub(crate) fn prepare_values( + previous: Option<&Entity>, + proposed: &mut Entity, + trusted: bool, +) -> std::result::Result<(), ConnectionError> { + let old = previous + .map(|e| inventory(e, true)) + .transpose() + .map_err(connection_error)? + .unwrap_or_default(); + inventory(proposed, false).map_err(connection_error)?; + let stripped = match (previous.and_then(|e| e.kind.as_ref()), &proposed.kind) { + (Some(entity::Kind::Decision(prior)), Some(entity::Kind::Decision(value))) => { + value.options.iter().any(|o| { + o.option_id.is_none() + && prior.options.iter().any(|old| { + old.title == o.title + && old.description == o.description + && !value + .options + .iter() + .any(|current| current.option_id == old.option_id) + }) + }) + } + (Some(entity::Kind::Document(prior)), Some(entity::Kind::Document(value))) => { + value.anchors.iter().any(|a| { + a.anchor_id.is_none() && prior.anchors.iter().any(|old| old.fragment == a.fragment) + }) + } + _ => false, + }; + require(!stripped, "edited manifest cannot strip local identities") + .map_err(connection_error)?; + each_local(proposed, |value, signature| { + if let Some(value) = value { + let id = value.value.parse::()?; + require( + previous.is_none() || trusted || old.contains_key(&id), + "local ID not active; omit IDs for additions", + )?; + require( + old.get(&id).is_none_or(|old| old == signature), + "local value fingerprint changed; use a new ID", + )?; + } else { + *value = Some(LocalValueId::new().into()); + } + Ok(()) + }) + .map_err(connection_error) +} +pub fn equivalent_manifest( + previous: &Entity, + proposed: &Entity, +) -> std::result::Result { + if !matches!( + proposed.kind, + Some(entity::Kind::Decision(_) | entity::Kind::Document(_)) + ) { + return Ok(false); + } + validate_stored_values(previous).map_err(connection_error)?; + connections::validate_stored_connection_ids(previous)?; + let supplied = inventory(proposed, false).map_err(connection_error)?; + let mut comparison = previous.clone(); + if supplied.is_empty() { + each_local(&mut comparison, |id, _| { + *id = None; + Ok(()) + }) + .map_err(connection_error)?; + } + if crate::semantically_equal(&comparison, proposed) { + return Ok(true); + } + connections::equivalent_with_unassigned_ids(&comparison, proposed) +} +pub(crate) fn removed_values( + base: Option<&Entity>, + opposing: Option<&Entity>, + selected: &Entity, +) -> std::result::Result, ConnectionError> { + for owner in base + .into_iter() + .chain(opposing) + .chain(std::iter::once(selected)) + { + validate_stored_values(owner).map_err(connection_error)?; + } + let same = |e: &&Entity| { + e.system + .as_ref() + .zip(selected.system.as_ref()) + .is_some_and(|(a, b)| !a.uid.is_empty() && a.uid == b.uid) + }; + let (Some(base), Some(opposing)) = (base.filter(same), opposing.filter(same)) else { + return Ok(HashSet::new()); + }; + let current = inventory(opposing, true).map_err(connection_error)?; + Ok(inventory(base, true) + .map_err(connection_error)? + .into_keys() + .filter(|id| !current.contains_key(id)) + .collect()) +} +pub(crate) fn reject_removed( + entity: &Entity, + removed: &HashSet, +) -> std::result::Result<(), ConnectionError> { + require( + !inventory(entity, true) + .map_err(connection_error)? + .keys() + .any(|id| removed.contains(id)), + "local ID from branch base removed on opposing side", + ) + .map_err(connection_error) +} +pub(crate) fn restore_values( + entity: &mut Entity, + removed: &HashSet, +) -> std::result::Result<(), ConnectionError> { + let mut map = HashMap::new(); + each_local(entity, |value, _| { + let id = value + .as_ref() + .ok_or_else(|| CatalogError("local ID missing".into()))? + .value + .parse::()?; + if removed.contains(&id) { + let fresh: pb::LocalValueId = LocalValueId::new().into(); + map.insert(id, fresh.clone()); + *value = Some(fresh); + } + Ok(()) + }) + .map_err(connection_error)?; + if let Some(entity::Kind::Decision(value)) = &mut entity.kind { + if let Some(selected) = &mut value.selected_option { + if let Some(fresh) = map.get( + &selected + .value + .parse::() + .map_err(connection_error)?, + ) { + *selected = fresh.clone(); + } + } + } + Ok(()) +} + +pub fn decorate_reference(entity: &Entity, reference: &mut pb::Reference) { + crate::decision_document_refs::decorate_reference(entity, reference); +} diff --git a/rsworkspace/crates/trogon-atlas-core/src/decision_document_refs.rs b/rsworkspace/crates/trogon-atlas-core/src/decision_document_refs.rs new file mode 100644 index 00000000..714fb450 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/src/decision_document_refs.rs @@ -0,0 +1,184 @@ +use trogon_atlas_proto::{self as pb, entity, Entity, EntityKind, Id}; + +use crate::{decision_document, refs::OutboundRef}; + +fn walk(entity: &mut Entity, visit: &mut impl FnMut(EntityKind, &mut Option, String, bool)) { + let subjects = match &mut entity.kind { + Some(entity::Kind::Decision(v)) => { + if let Some(r) = v.replaced_by.as_mut().and_then(|r| r.decision.as_mut()) { + visit( + EntityKind::Decision, + &mut r.id, + "replaced_by.decision".into(), + false, + ); + } + for (i, r) in v.related_decisions.iter_mut().enumerate() { + if let Some(r) = &mut r.decision { + visit( + EntityKind::Decision, + &mut r.id, + format!("related_decisions[{i}].decision"), + false, + ); + } + } + for (i, r) in v.documents.iter_mut().enumerate() { + if let Some(r) = &mut r.document { + document(r, &format!("documents[{i}].document"), visit); + } + } + &mut v.subjects + } + Some(entity::Kind::Document(v)) => { + for (i, r) in v.navigation.iter_mut().enumerate() { + if let Some(r) = &mut r.document { + document(r, &format!("navigation[{i}].document"), visit); + } + } + &mut v.subjects + } + _ => return, + }; + for (i, association) in subjects.iter_mut().enumerate() { + let path = format!("subjects[{i}].subject"); + match association + .subject + .as_mut() + .and_then(|s| s.selection.as_mut()) + { + Some(pb::catalog_subject::Selection::Entity(r)) => { + if let Ok(kind) = EntityKind::try_from(r.kind) { + visit(kind, &mut r.id, format!("{path}.entity"), false); + } + } + Some(pb::catalog_subject::Selection::Connection(r)) => { + if let Some(r) = &mut r.owner { + if let Ok(kind) = EntityKind::try_from(r.kind) { + visit(kind, &mut r.id, format!("{path}.connection.owner"), true); + } + } + } + Some(pb::catalog_subject::Selection::Member(r)) => { + if let Some(r) = &mut r.specification { + visit( + EntityKind::Specification, + &mut r.id, + format!("{path}.member.specification"), + true, + ); + } + } + Some(pb::catalog_subject::Selection::Port(r)) => { + if let Some(r) = &mut r.product { + visit( + EntityKind::DataProduct, + &mut r.id, + format!("{path}.port.product"), + true, + ); + } + } + None => {} + } + } +} +fn document( + selection: &mut pb::DocumentSelection, + path: &str, + visit: &mut impl FnMut(EntityKind, &mut Option, String, bool), +) { + match &mut selection.selection { + Some(pb::document_selection::Selection::Document(r)) => visit( + EntityKind::Document, + &mut r.id, + format!("{path}.document"), + false, + ), + Some(pb::document_selection::Selection::Anchor(r)) => { + if let Some(r) = &mut r.document { + visit( + EntityKind::Document, + &mut r.id, + format!("{path}.anchor.document"), + true, + ); + } + } + None => {} + } +} +pub(crate) fn outbound(entity: &Entity, out: &mut Vec) { + walk(&mut entity.clone(), &mut |kind, id, path, _| { + if let Some(id) = id { + out.push(OutboundRef { + to_kind: Some(kind), + to_id: id.clone(), + from_field: path, + }); + } + }); +} +pub(crate) fn retarget( + entity: &mut Entity, + kind: EntityKind, + from: &Id, + to: &Id, + rewritten: &mut Vec, +) { + walk(entity, &mut |target, id, path, pinned| { + if !pinned && target == kind && id.as_ref() == Some(from) { + *id = Some(to.clone()); + rewritten.push(path); + } + }); +} + +pub(crate) fn decorate_reference(entity: &Entity, reference: &mut pb::Reference) { + let field = reference.from_field.as_str(); + for (i, subject) in decision_document::subjects(entity).iter().enumerate() { + if field.starts_with(&format!("subjects[{i}].subject.")) { + reference.catalog_subject.clone_from(&subject.subject); + reference.connection_id.clone_from(&subject.connection_id); + return; + } + } + match &entity.kind { + Some(entity::Kind::Decision(v)) => { + for (i, declaration) in v.documents.iter().enumerate() { + if field.starts_with(&format!("documents[{i}].document.")) { + reference + .document_selection + .clone_from(&declaration.document); + reference + .connection_id + .clone_from(&declaration.connection_id); + } + } + for (i, declaration) in v.related_decisions.iter().enumerate() { + if field == format!("related_decisions[{i}].decision") { + reference + .connection_id + .clone_from(&declaration.connection_id); + } + } + if field == "replaced_by.decision" { + reference.connection_id = + v.replaced_by.as_ref().and_then(|d| d.connection_id.clone()); + } + } + Some(entity::Kind::Document(v)) => { + for (i, declaration) in v.navigation.iter().enumerate() { + if field.starts_with(&format!("navigation[{i}].document.")) { + reference + .document_selection + .clone_from(&declaration.document); + reference + .connection_id + .clone_from(&declaration.connection_id); + } + } + } + _ => {} + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/src/lib.rs b/rsworkspace/crates/trogon-atlas-core/src/lib.rs index f7e08831..c1f270cf 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/lib.rs @@ -18,6 +18,8 @@ pub mod connections; pub mod content_hash; pub mod data_product; mod data_product_refs; +pub mod decision_document; +mod decision_document_refs; pub mod id_component; pub mod namespace; pub mod operation_id; diff --git a/rsworkspace/crates/trogon-atlas-core/src/refs.rs b/rsworkspace/crates/trogon-atlas-core/src/refs.rs index 6c3ddf82..f16c3bbc 100644 --- a/rsworkspace/crates/trogon-atlas-core/src/refs.rs +++ b/rsworkspace/crates/trogon-atlas-core/src/refs.rs @@ -89,6 +89,8 @@ pub fn outbound_refs(entity: &Entity) -> Vec { EntityOneof::DataProduct(x) => x.supersedes.as_ref(), EntityOneof::Team(x) => x.supersedes.as_ref(), EntityOneof::Person(x) => x.supersedes.as_ref(), + EntityOneof::Decision(x) => x.supersedes.as_ref(), + EntityOneof::Document(x) => x.supersedes.as_ref(), }; if let Some(s) = supersedes_of { if let Some(self_kind) = entity_kind(entity) { @@ -802,6 +804,9 @@ pub fn outbound_refs(entity: &Entity) -> Vec { EntityOneof::Team(_) | EntityOneof::Person(_) => { crate::responsibility_refs::outbound(entity, &mut out); } + EntityOneof::Decision(_) | EntityOneof::Document(_) => { + crate::decision_document_refs::outbound(entity, &mut out); + } EntityOneof::DataStore(x) => { for (i, attachment) in x.attachments.iter().enumerate() { if let Some(id) = attachment.read_model.as_ref().and_then(|r| r.id.as_ref()) { @@ -1091,6 +1096,8 @@ pub fn entity_kind(entity: &Entity) -> Option { EntityOneof::DataProduct(_) => EntityKind::DataProduct, EntityOneof::Team(_) => EntityKind::Team, EntityOneof::Person(_) => EntityKind::Person, + EntityOneof::Decision(_) => EntityKind::Decision, + EntityOneof::Document(_) => EntityKind::Document, }) } @@ -1135,6 +1142,8 @@ pub fn entity_id(entity: &Entity) -> Option<&Id> { EntityOneof::DataProduct(x) => x.id.as_ref(), EntityOneof::Team(x) => x.id.as_ref(), EntityOneof::Person(x) => x.id.as_ref(), + EntityOneof::Decision(x) => x.id.as_ref(), + EntityOneof::Document(x) => x.id.as_ref(), } } @@ -1199,6 +1208,8 @@ pub fn retarget_refs( EntityOneof::DataProduct(x) => x.supersedes.as_mut(), EntityOneof::Team(x) => x.supersedes.as_mut(), EntityOneof::Person(x) => x.supersedes.as_mut(), + EntityOneof::Decision(x) => x.supersedes.as_mut(), + EntityOneof::Document(x) => x.supersedes.as_mut(), }; if self_kind_matches { if let Some(s) = supersedes_opt { @@ -1968,6 +1979,15 @@ pub fn retarget_refs( EntityOneof::DataProduct(x) => { crate::data_product_refs::retarget(x, from_kind, from_id, to_id, &mut rewritten); } + EntityOneof::Decision(_) | EntityOneof::Document(_) => { + crate::decision_document_refs::retarget( + entity, + from_kind, + from_id, + to_id, + &mut rewritten, + ); + } EntityOneof::Team(_) | EntityOneof::Person(_) => { crate::responsibility_refs::retarget(entity, from_kind, from_id, to_id, &mut rewritten); } @@ -2172,6 +2192,8 @@ fn matches_kind(kind: &EntityOneof, ek: EntityKind) -> bool { | (EntityOneof::DataProduct(_), EntityKind::DataProduct) | (EntityOneof::Team(_), EntityKind::Team) | (EntityOneof::Person(_), EntityKind::Person) + | (EntityOneof::Decision(_), EntityKind::Decision) + | (EntityOneof::Document(_), EntityKind::Document) ) } diff --git a/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs b/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs index 13b5beb3..397b22aa 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/data_product.rs @@ -134,7 +134,7 @@ fn new_kind_roundtrips_and_revision_floors_are_coherent() { canonical::parse_kind("DATA_PRODUCT"), Some(EntityKind::DataProduct) ); - assert_eq!(CONTRACT_REVISION, 13); + assert_eq!(CONTRACT_REVISION, 14); assert_eq!(MIN_CLIENT_CONTRACT_REVISION, CONTRACT_REVISION); assert_eq!(MIN_SERVER_CONTRACT_REVISION, CONTRACT_REVISION); let value = stored(); diff --git a/rsworkspace/crates/trogon-atlas-core/tests/decision_document.rs b/rsworkspace/crates/trogon-atlas-core/tests/decision_document.rs new file mode 100644 index 00000000..61b4fd94 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/tests/decision_document.rs @@ -0,0 +1,615 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use trogon_atlas_core::{ + connection_persistence::{prepare_entity, prepare_landing, prepare_resolution}, + connections, decision_document as catalog, entity_id, entity_kind, outbound_refs, + retarget_refs, + specification::content_digest, +}; +use trogon_atlas_proto::{self as pb, entity, Entity, EntityKind}; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "catalog-test".into(), + slug: slug.into(), + version: 1, + } +} +fn title(value: &str) -> pb::CatalogTitle { + pb::CatalogTitle { + value: value.into(), + } +} +fn text(value: &str) -> pb::CatalogText { + pb::CatalogText { + value: value.into(), + } +} +fn subject() -> pb::SubjectAssociation { + pb::SubjectAssociation { + subject: Some(pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(pb::EntityRef { + kind: EntityKind::ReadModel as i32, + id: Some(id("receipt")), + })), + }), + ..Default::default() + } +} +fn document() -> Entity { + Entity { + kind: Some(entity::Kind::Document(pb::Document { + id: Some(id("note")), + title: Some(title("Delivery note")), + subjects: vec![subject()], + body: Some(pb::document::Body::Retained(pb::RetainedDocumentText { + media_type: Some(pb::MediaType { + value: "text/markdown".into(), + }), + content: "# Delivery\n".into(), + digest: Some(content_digest("# Delivery\n")), + })), + anchors: vec![pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: "delivery".into(), + }), + label: Some(title("Delivery")), + ..Default::default() + }], + ..Default::default() + })), + system: None, + } +} +fn decision() -> Entity { + Entity { + kind: Some(entity::Kind::Decision(pb::Decision { + id: Some(id("choice")), + title: Some(title("Delivery choice")), + subjects: vec![subject()], + status: pb::DecisionStatus::Proposed as i32, + context: Some(text("Choose delivery")), + rationale: Some(text("A declared choice")), + options: vec![pb::DecisionOption { + title: Some(title("Notify")), + description: Some(text("Send a notification")), + ..Default::default() + }], + ..Default::default() + })), + system: None, + } +} +fn doc_mut(entity: &mut Entity) -> &mut pb::Document { + let Some(entity::Kind::Document(value)) = &mut entity.kind else { + panic!("Document required") + }; + value +} +fn choice_mut(entity: &mut Entity) -> &mut pb::Decision { + let Some(entity::Kind::Decision(value)) = &mut entity.kind else { + panic!("Decision required") + }; + value +} +fn anchor_selection(owner: &Entity, pinned: bool) -> pb::DocumentSelection { + let Some(entity::Kind::Document(value)) = &owner.kind else { + panic!("Document required") + }; + pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Anchor( + pb::DocumentAnchorRef { + document: Some(pb::DocumentRef { + id: value.id.clone(), + }), + owner_uid: owner.system.as_ref().unwrap().uid.clone(), + anchor_id: value.anchors[0].anchor_id.clone(), + expected_digest: if pinned { + Some(content_digest("# Delivery\n")) + } else { + None + }, + }, + )), + } +} + +#[test] +fn native_resources_route_and_assign_distinct_persisted_identities() { + for proposed in [document(), decision()] { + catalog::validate_values(&proposed).unwrap(); + let stored = prepare_entity(None, &proposed).unwrap(); + catalog::validate_stored_values(&stored).unwrap(); + catalog::validate_values(&stored).unwrap(); + assert!(matches!( + entity_kind(&stored), + Some(EntityKind::Decision | EntityKind::Document) + )); + assert!(entity_id(&stored).is_some()); + assert_eq!(prepare_entity(Some(&stored), &proposed).unwrap(), stored); + assert_eq!( + connections::connection_occurrences(&stored).unwrap().len(), + 1 + ); + assert_eq!(outbound_refs(&stored)[0].to_id, id("receipt")); + } +} +#[test] +fn statuses_enforce_full_proposed_state() { + let base = prepare_entity(None, &decision()).unwrap(); + for status in [ + pb::DecisionStatus::Proposed, + pb::DecisionStatus::Accepted, + pb::DecisionStatus::Rejected, + pb::DecisionStatus::Withdrawn, + pb::DecisionStatus::Superseded, + ] { + let mut candidate = base.clone(); + let value = choice_mut(&mut candidate); + value.status = status as i32; + let adopted = matches!( + status, + pb::DecisionStatus::Accepted | pb::DecisionStatus::Superseded + ); + if adopted { + value.selected_option = value.options[0].option_id.clone(); + value.consequences.push(pb::DecisionConsequence { + kind: pb::decision_consequence::Kind::Risk as i32, + text: Some(text("Delivery may fail")), + }); + } + if status == pb::DecisionStatus::Superseded { + value.replaced_by = Some(pb::DecisionReplacement { + decision: Some(pb::DecisionRef { + id: Some(id("replacement")), + }), + ..Default::default() + }); + } + catalog::validate_values(&candidate).unwrap(); + let value = choice_mut(&mut candidate); + value.selected_option = if adopted { + None + } else { + value.options[0].option_id.clone() + }; + assert!(catalog::validate_values(&candidate).is_err()); + } + let mut unknown = base; + choice_mut(&mut unknown).status = 999; + assert!(catalog::validate_values(&unknown).is_err()); +} +#[test] +fn exact_document_bytes_and_inert_link_rules() { + let mut candidate = document(); + let Some(pb::document::Body::Retained(body)) = &mut doc_mut(&mut candidate).body else { + unreachable!() + }; + body.content.pop(); + assert!(catalog::validate_values(&candidate).is_err()); + for bad in [ + "http://example.com", + "https://user@example.com", + "https://@example.com", + "file:///tmp/note", + "https://example.com/\n", + ] { + let mut candidate = document(); + let value = doc_mut(&mut candidate); + value.anchors.clear(); + value.body = Some(pb::document::Body::Link(pb::ExternalDocumentLink { + value: bad.into(), + })); + assert!( + catalog::validate_values(&candidate).is_err(), + "accepted {bad:?}" + ); + } + let value = doc_mut(&mut candidate); + value.anchors.clear(); + value.body = Some(pb::document::Body::Link(pb::ExternalDocumentLink { + value: "https://example.com/note".into(), + })); + catalog::validate_values(&candidate).unwrap(); +} +#[test] +fn anchors_preserve_incarnation_and_exact_pin_presence() { + let stored = prepare_entity(None, &document()).unwrap(); + let pinned = anchor_selection(&stored, true); + let unpinned = anchor_selection(&stored, false); + catalog::resolve_document_selection(&pinned, &stored).unwrap(); + let mut edited = stored.clone(); + let Some(pb::document::Body::Retained(body)) = &mut doc_mut(&mut edited).body else { + unreachable!() + }; + body.content.push(' '); + body.digest = Some(content_digest(&body.content)); + let edited = prepare_entity(Some(&stored), &edited).unwrap(); + assert!(catalog::resolve_document_selection(&pinned, &edited).is_err()); + catalog::resolve_document_selection(&unpinned, &edited).unwrap(); + let recreated = prepare_entity(None, &document()).unwrap(); + assert!(catalog::resolve_document_selection(&unpinned, &recreated).is_err()); + let whole = pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Document( + pb::DocumentRef { + id: Some(id("note")), + }, + )), + }; + assert!(catalog::resolve_document_selection(&whole, &recreated) + .unwrap() + .is_none()); +} +#[test] +fn catalog_inventory_stays_closed_while_responsibility_admits_resources() { + for kind in [EntityKind::Decision, EntityKind::Document] { + let subject = pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(pb::EntityRef { + kind: kind as i32, + id: Some(id("resource")), + })), + }; + assert!(catalog::validate_subject(&subject).is_err()); + trogon_atlas_core::responsibility::validate_subject(&catalog::as_responsibility_subject( + &subject, + )) + .unwrap(); + } +} +#[test] +fn wording_and_label_edits_preserve_ids_while_fragment_edits_require_fresh_id() { + let stored = prepare_entity(None, &document()).unwrap(); + let mut edited = stored.clone(); + doc_mut(&mut edited).anchors[0].label = Some(title("New wording")); + let prepared = prepare_entity(Some(&stored), &edited).unwrap(); + assert_eq!( + doc_mut(&mut prepared.clone()).anchors[0].anchor_id, + doc_mut(&mut stored.clone()).anchors[0].anchor_id + ); + doc_mut(&mut edited).anchors[0] + .fragment + .as_mut() + .unwrap() + .value = "renamed".into(); + assert!(prepare_entity(Some(&stored), &edited).is_err()); + doc_mut(&mut edited).anchors[0].anchor_id = None; + assert!(prepare_entity(Some(&stored), &edited).is_ok()); + let mut removed = stored.clone(); + doc_mut(&mut removed).anchors.clear(); + let removed = prepare_entity(Some(&stored), &removed).unwrap(); + doc_mut(&mut edited).anchors[0].anchor_id = None; + let fresh = prepare_entity(Some(&removed), &edited).unwrap(); + assert_ne!( + doc_mut(&mut fresh.clone()).anchors[0].anchor_id, + doc_mut(&mut stored.clone()).anchors[0].anchor_id + ); +} +#[test] +fn branch_resolution_renews_removed_anchors_and_selected_options() { + for mut stored in [ + prepare_entity(None, &document()).unwrap(), + prepare_entity(None, &decision()).unwrap(), + ] { + if let Some(entity::Kind::Decision(value)) = &mut stored.kind { + value.selected_option = value.options[0].option_id.clone(); + } + let mut opposing = stored.clone(); + match opposing.kind.as_mut().unwrap() { + entity::Kind::Document(v) => v.anchors.clear(), + entity::Kind::Decision(v) => { + v.options.clear(); + v.selected_option = None; + } + _ => unreachable!(), + } + assert!(prepare_landing(Some(&stored), Some(&opposing), &stored).is_err()); + let resolved = prepare_resolution(Some(&stored), Some(&opposing), &stored).unwrap(); + assert_eq!(resolved.system, stored.system); + match resolved.kind.unwrap() { + entity::Kind::Document(v) => assert_ne!( + v.anchors[0].anchor_id, + doc_mut(&mut stored).anchors[0].anchor_id + ), + entity::Kind::Decision(v) => { + assert_ne!( + v.options[0].option_id, + choice_mut(&mut stored).options[0].option_id + ); + assert_eq!(v.selected_option, v.options[0].option_id); + } + _ => unreachable!(), + } + } +} +#[test] +fn outbound_and_generic_reference_preserve_pinned_anchor_provenance() { + let target = prepare_entity(None, &document()).unwrap(); + let selected = anchor_selection(&target, true); + let mut proposed = decision(); + choice_mut(&mut proposed) + .documents + .push(pb::SupportingDocument { + document: Some(selected.clone()), + ..Default::default() + }); + let owner = prepare_entity(None, &proposed).unwrap(); + let outbound = outbound_refs(&owner) + .into_iter() + .find(|r| r.to_kind == Some(EntityKind::Document)) + .unwrap(); + let mut reference = pb::Reference { + from_field: outbound.from_field.clone(), + ..Default::default() + }; + catalog::decorate_reference(&owner, &mut reference); + assert_eq!(reference.document_selection, Some(selected)); + assert!(reference.connection_id.is_some()); + let mut rewritten = owner.clone(); + assert_eq!( + retarget_refs( + &mut rewritten, + EntityKind::Document, + &id("note"), + &id("other") + ), + Vec::::new() + ); + assert_eq!(rewritten, owner); +} +#[test] +fn duplicate_full_subjects_and_selections_reject() { + let mut candidate = document(); + doc_mut(&mut candidate).subjects.push(subject()); + assert!(catalog::validate_values(&candidate).is_err()); + let mut candidate = decision(); + let target = prepare_entity(None, &document()).unwrap(); + let selection = anchor_selection(&target, false); + let pinned = anchor_selection(&target, true); + choice_mut(&mut candidate).documents = vec![ + pb::SupportingDocument { + document: Some(selection.clone()), + ..Default::default() + }, + pb::SupportingDocument { + document: Some(pinned), + ..Default::default() + }, + ]; + catalog::validate_values(&candidate).unwrap(); + choice_mut(&mut candidate) + .documents + .push(pb::SupportingDocument { + document: Some(selection), + ..Default::default() + }); + assert!(catalog::validate_values(&candidate).is_err()); +} +#[test] +fn selected_anchor_requires_complete_unambiguous_fragment_inventory() { + let stored = prepare_entity(None, &document()).unwrap(); + let selected = anchor_selection(&stored, false); + let mut malformed = stored; + let mut duplicate = doc_mut(&mut malformed).anchors[0].clone(); + duplicate.anchor_id = Some(catalog::LocalValueId::new().into()); + doc_mut(&mut malformed).anchors.push(duplicate); + assert!(catalog::resolve_document_selection(&selected, &malformed).is_err()); +} + +#[test] +fn changed_fragment_can_request_fresh_anchor_identity_in_one_guarded_edit() { + let stored = prepare_entity(None, &document()).unwrap(); + let mut proposed = stored.clone(); + doc_mut(&mut proposed).anchors[0] + .fragment + .as_mut() + .unwrap() + .value = "new-fragment".into(); + doc_mut(&mut proposed).anchors[0].anchor_id = None; + let mut prepared = prepare_entity(Some(&stored), &proposed).unwrap(); + assert_ne!( + doc_mut(&mut prepared).anchors[0].anchor_id, + doc_mut(&mut stored.clone()).anchors[0].anchor_id + ); +} + +#[test] +fn identical_option_wording_can_be_added_as_distinct_local_choice() { + let stored = prepare_entity(None, &decision()).unwrap(); + let mut proposed = stored.clone(); + let mut added = choice_mut(&mut proposed).options[0].clone(); + added.option_id = None; + choice_mut(&mut proposed).options.push(added); + let mut prepared = prepare_entity(Some(&stored), &proposed).unwrap(); + let options = &choice_mut(&mut prepared).options; + assert_eq!(options.len(), 2); + assert_ne!(options[0].option_id, options[1].option_id); + catalog::validate_values(&prepared).unwrap(); +} + +#[test] +fn byte_and_collection_limits_accept_boundaries_and_reject_excess() { + let mut candidate = decision(); + let value = choice_mut(&mut candidate); + value.title = Some(title(&"é".repeat(128))); + value.context = Some(text(&"x".repeat(16_384))); + value.options = vec![value.options[0].clone(); 64]; + value.subjects = (0..1_024) + .map(|i| { + let mut association = subject(); + let Some(pb::catalog_subject::Selection::Entity(reference)) = + association.subject.as_mut().unwrap().selection.as_mut() + else { + unreachable!() + }; + reference.id = Some(id(&format!("subject-{i}"))); + association + }) + .collect(); + catalog::validate_values(&candidate).unwrap(); + let mut excessive = candidate.clone(); + choice_mut(&mut excessive).title = Some(title(&"é".repeat(129))); + assert!(catalog::validate_values(&excessive).is_err()); + let mut excessive = candidate.clone(); + choice_mut(&mut excessive) + .context + .as_mut() + .unwrap() + .value + .push('x'); + assert!(catalog::validate_values(&excessive).is_err()); + let mut excessive = candidate.clone(); + let extra = choice_mut(&mut excessive).options[0].clone(); + choice_mut(&mut excessive).options.push(extra); + assert!(catalog::validate_values(&excessive).is_err()); + choice_mut(&mut candidate).subjects.push(subject()); + assert!(catalog::validate_values(&candidate).is_err()); + + let mut candidate = document(); + let value = doc_mut(&mut candidate); + value.anchors = (0..256) + .map(|i| pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: format!("anchor-{i}"), + }), + label: Some(title("Anchor")), + anchor_id: None, + }) + .collect(); + let Some(pb::document::Body::Retained(body)) = &mut value.body else { + unreachable!() + }; + body.content = "x".repeat(262_144); + body.digest = Some(content_digest(&body.content)); + catalog::validate_values(&candidate).unwrap(); + let mut excessive = candidate.clone(); + doc_mut(&mut excessive).anchors.push(pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: "extra".into(), + }), + label: Some(title("Extra")), + anchor_id: None, + }); + assert!(catalog::validate_values(&excessive).is_err()); + let Some(pb::document::Body::Retained(body)) = &mut doc_mut(&mut candidate).body else { + unreachable!() + }; + body.content.push('x'); + body.digest = Some(content_digest(&body.content)); + assert!(catalog::validate_values(&candidate).is_err()); +} + +#[test] +fn association_labels_are_mutable_while_full_selection_pins_require_fresh_identity() { + let target = prepare_entity(None, &document()).unwrap(); + let mut proposed = document(); + doc_mut(&mut proposed).id = Some(id("index")); + doc_mut(&mut proposed) + .navigation + .push(pb::DocumentNavigation { + document: Some(anchor_selection(&target, false)), + label: Some(title("Initial")), + ..Default::default() + }); + let stored = prepare_entity(None, &proposed).unwrap(); + let mut edited = stored.clone(); + doc_mut(&mut edited).navigation[0].label = Some(title("Reworded")); + let prepared = prepare_entity(Some(&stored), &edited).unwrap(); + assert_eq!( + connections::connection_occurrences(&stored).unwrap(), + connections::connection_occurrences(&prepared).unwrap() + ); + doc_mut(&mut edited).navigation[0].document = Some(anchor_selection(&target, true)); + assert!(prepare_entity(Some(&stored), &edited).is_err()); +} + +#[test] +fn distinguishable_complete_option_replacement_mints_fresh_ids_in_one_guarded_edit() { + let stored = prepare_entity(None, &decision()).unwrap(); + let mut proposed = stored.clone(); + choice_mut(&mut proposed).options = vec![pb::DecisionOption { + option_id: None, + title: Some(title("Poll")), + description: Some(text("Poll the receipt")), + }]; + catalog::validate_values(&proposed).unwrap(); + assert!(!catalog::equivalent_manifest(&stored, &proposed).unwrap()); + let mut prepared = prepare_entity(Some(&stored), &proposed).unwrap(); + catalog::validate_values(&prepared).unwrap(); + assert_eq!( + prepared.system.as_ref().unwrap().uid, + stored.system.as_ref().unwrap().uid + ); + assert_eq!(catalog::subjects(&prepared), catalog::subjects(&stored)); + let fresh = choice_mut(&mut prepared).options[0].option_id.clone(); + assert!(fresh.is_some()); + assert_ne!(fresh, choice_mut(&mut stored.clone()).options[0].option_id); +} + +#[test] +fn edited_manifest_cannot_strip_retained_option_identity_or_supply_unknown_id() { + let stored = prepare_entity(None, &decision()).unwrap(); + let mut stripped = stored.clone(); + let value = choice_mut(&mut stripped); + value.doc = Some("Changed owner prose".into()); + value.options[0].option_id = None; + assert!(prepare_entity(Some(&stored), &stripped).is_err()); + let mut unknown = stored.clone(); + choice_mut(&mut unknown).options[0].option_id = Some(catalog::LocalValueId::new().into()); + assert!(prepare_entity(Some(&stored), &unknown).is_err()); +} + +fn assert_anchor_rejects_corrupt_associations(corrupt: impl FnOnce(&mut pb::Document)) { + let owner = prepare_entity(None, &document()).unwrap(); + let selection = anchor_selection(&owner, false); + let mut malformed = owner; + corrupt(doc_mut(&mut malformed)); + assert!(connections::validate_stored_connection_ids(&malformed).is_err()); + assert!(catalog::resolve_document_selection(&selection, &malformed).is_err()); +} + +#[test] +fn anchor_selection_rejects_missing_persisted_association_identity() { + assert_anchor_rejects_corrupt_associations(|owner| owner.subjects[0].connection_id = None); +} + +#[test] +fn anchor_selection_rejects_malformed_persisted_association_identity() { + assert_anchor_rejects_corrupt_associations(|owner| { + owner.subjects[0].connection_id = Some(pb::ConnectionId { + value: "malformed".into(), + }); + }); +} + +#[test] +fn anchor_selection_rejects_duplicate_persisted_association_identities() { + assert_anchor_rejects_corrupt_associations(|owner| { + let mut duplicate = owner.subjects[0].clone(); + let Some(pb::catalog_subject::Selection::Entity(reference)) = + duplicate.subject.as_mut().unwrap().selection.as_mut() + else { + unreachable!() + }; + reference.id = Some(id("other-receipt")); + owner.subjects.push(duplicate); + }); +} + +#[test] +fn anchor_resolution_checks_identity_without_hydrating_unrelated_outgoing_targets() { + let mut proposed = document(); + doc_mut(&mut proposed) + .navigation + .push(pb::DocumentNavigation { + document: Some(pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Document( + pb::DocumentRef { + id: Some(id("unavailable-navigation")), + }, + )), + }), + ..Default::default() + }); + let owner = prepare_entity(None, &proposed).unwrap(); + catalog::resolve_document_selection(&anchor_selection(&owner, false), &owner).unwrap(); +} diff --git a/rsworkspace/crates/trogon-atlas-core/tests/decision_document_reference_provenance.rs b/rsworkspace/crates/trogon-atlas-core/tests/decision_document_reference_provenance.rs new file mode 100644 index 00000000..55380731 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-core/tests/decision_document_reference_provenance.rs @@ -0,0 +1,168 @@ +use trogon_atlas_core::{decision_document, outbound_refs}; +use trogon_atlas_proto::{self as pb, entity, Entity, EntityKind}; + +fn id(slug: String) -> pb::Id { + pb::Id { + namespace: "catalog-provenance".into(), + slug, + version: 1, + } +} + +fn connection_id(value: String) -> pb::ConnectionId { + pb::ConnectionId { value } +} + +fn subjects() -> Vec { + (0..12) + .map(|i| pb::SubjectAssociation { + connection_id: Some(connection_id(format!("subject-association-{i}"))), + subject: Some(pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Connection( + pb::ConnectionRef { + owner: Some(pb::EntityRef { + kind: EntityKind::AutomationSlice as i32, + id: Some(id(format!("slice-{i}"))), + }), + id: Some(connection_id(format!("observed-occurrence-{i}"))), + owner_uid: format!("slice-incarnation-{i}"), + }, + )), + }), + ..Default::default() + }) + .collect() +} + +fn selection(i: usize) -> pb::DocumentSelection { + let document = pb::DocumentRef { + id: Some(id(format!("document-{i}"))), + }; + pb::DocumentSelection { + selection: Some(if i.is_multiple_of(2) { + pb::document_selection::Selection::Document(document) + } else { + pb::document_selection::Selection::Anchor(pb::DocumentAnchorRef { + document: Some(document), + owner_uid: format!("document-incarnation-{i}"), + anchor_id: Some(pb::LocalValueId { + value: format!("anchor-{i}"), + }), + expected_digest: Some(trogon_atlas_core::specification::content_digest(&format!( + "content-{i}" + ))), + }) + }), + } +} + +fn references(owner: &Entity) -> Vec { + outbound_refs(owner) + .iter() + .map(|outbound| { + let mut reference = pb::Reference { + from_field: outbound.from_field.clone(), + to: outbound.to_entity_ref(), + ..Default::default() + }; + decision_document::decorate_reference(owner, &mut reference); + reference + }) + .collect() +} + +fn assert_subjects(references: &[pb::Reference], associations: &[pb::SubjectAssociation]) { + for (i, association) in associations.iter().enumerate() { + let field = format!("subjects[{i}].subject.connection.owner"); + let matching: Vec<_> = references + .iter() + .filter(|r| r.from_field == field) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].catalog_subject, association.subject); + assert_eq!(matching[0].connection_id, association.connection_id); + assert_eq!(matching[0].document_selection, None); + } +} + +#[test] +fn decision_reference_provenance_preserves_multi_digit_association_indexes() { + let decision = pb::Decision { + id: Some(id("choice".into())), + subjects: subjects(), + documents: (0..12) + .map(|i| pb::SupportingDocument { + connection_id: Some(connection_id(format!("supporting-association-{i}"))), + document: Some(selection(i)), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let owner = Entity { + kind: Some(entity::Kind::Decision(decision.clone())), + system: None, + }; + let references = references(&owner); + assert_eq!(references.len(), 24); + assert_subjects(&references, &decision.subjects); + for (i, declaration) in decision.documents.iter().enumerate() { + let field = format!( + "documents[{i}].document.{}", + if i.is_multiple_of(2) { + "document" + } else { + "anchor.document" + } + ); + let matching: Vec<_> = references + .iter() + .filter(|r| r.from_field == field) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].document_selection, declaration.document); + assert_eq!(matching[0].connection_id, declaration.connection_id); + assert_eq!(matching[0].catalog_subject, None); + } +} + +#[test] +fn document_reference_provenance_preserves_multi_digit_association_indexes() { + let document = pb::Document { + id: Some(id("guide".into())), + subjects: subjects(), + navigation: (0..12) + .map(|i| pb::DocumentNavigation { + connection_id: Some(connection_id(format!("navigation-association-{i}"))), + document: Some(selection(i)), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let owner = Entity { + kind: Some(entity::Kind::Document(document.clone())), + system: None, + }; + let references = references(&owner); + assert_eq!(references.len(), 24); + assert_subjects(&references, &document.subjects); + for (i, declaration) in document.navigation.iter().enumerate() { + let field = format!( + "navigation[{i}].document.{}", + if i.is_multiple_of(2) { + "document" + } else { + "anchor.document" + } + ); + let matching: Vec<_> = references + .iter() + .filter(|r| r.from_field == field) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].document_selection, declaration.document); + assert_eq!(matching[0].connection_id, declaration.connection_id); + assert_eq!(matching[0].catalog_subject, None); + } +} diff --git a/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs b/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs index 3caa37e0..9aff2a5d 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/responsibility.rs @@ -130,7 +130,7 @@ fn wire_allocations_revision_and_presence_are_native() { MIN_CLIENT_CONTRACT_REVISION, MIN_SERVER_CONTRACT_REVISION ), - (13, 13, 13) + (14, 14, 14) ); assert_eq!(canonical::parse_kind("team"), Some(EntityKind::Team)); assert_eq!(canonical::parse_kind("person"), Some(EntityKind::Person)); @@ -1021,6 +1021,9 @@ fn generic_references_preserve_full_typed_assignment_and_membership_provenance() doc: Some("Catalog stewardship".into()), }; let reference = Reference { + catalog_subject: None, + document_selection: None, + connection_id: None, from: from.clone(), from_field: "responsibilities[0].subject".into(), to: Some(selected_owner), @@ -1043,6 +1046,9 @@ fn generic_references_preserve_full_typed_assignment_and_membership_provenance() ); } let reference = Reference { + catalog_subject: None, + document_selection: None, + connection_id: None, from, from_field: "members[0].person".into(), to: Some(entity_ref(EntityKind::Person, "pat")), diff --git a/rsworkspace/crates/trogon-atlas-core/tests/specification.rs b/rsworkspace/crates/trogon-atlas-core/tests/specification.rs index 5ca4e4e7..6dd82660 100644 --- a/rsworkspace/crates/trogon-atlas-core/tests/specification.rs +++ b/rsworkspace/crates/trogon-atlas-core/tests/specification.rs @@ -165,7 +165,7 @@ fn generated_contract_revision_and_canonical_kind_are_complete() { MIN_SERVER_CONTRACT_REVISION, MIN_CLIENT_CONTRACT_REVISION ), - (13, 13, 13) + (14, 14, 14) ); assert_eq!( canonical::parse_kind("specification"), diff --git a/rsworkspace/crates/trogon-atlas-mcp/src/server.rs b/rsworkspace/crates/trogon-atlas-mcp/src/server.rs index 1546b4c6..a1e8481a 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/src/server.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/src/server.rs @@ -22,18 +22,18 @@ use crate::{ error::{attach_operation_id, tool_err, McpError}, tools::{ AnalysisScopeParams, ApplyManifestsParams, BatchGetParams, BatchMutateJsonParams, - BatchMutateParams, CompileTypeLibraryParams, CreateBranchParams, DeleteBranchParams, - DeleteByQueryParams, DeleteEntityParams, DiffBranchParams, DiffEntitiesParams, - DiffManifestsParams, EmptyParams, EntitySpec, EventModelProjectionParams, - ExportNamespaceParams, ExportOpensloParams, ExtractSubgraphParams, GetChangesetParams, - GetEntityHistoryParams, GetLatestVersionParams, GetOperationParams, GetSnapshotIdParams, - ImpactParams, JsonOpts, ListChangesParams, ListChangesetsParams, - ListEntitiesByDomainParams, ListEntitiesParams, ListVersionsParams, MergeBranchParams, - MoveNamespaceParams, ProjectionParams, PutEntityJsonParams, PutEntityParams, - ReferencesParams, RegisterNamespaceParams, ResolveBranchEntryParams, ResolveTypeParams, - ResponsibilityQueryParams, RetargetReferencesParams, RevertChangesetParams, SearchParams, - SliceProjectionParams, SupersessionChainParams, UpdateBranchParams, - ValidateEventModelParams, ValidateProjectParams, + BatchMutateParams, CatalogQueryParams, CompileTypeLibraryParams, CreateBranchParams, + DeleteBranchParams, DeleteByQueryParams, DeleteEntityParams, DiffBranchParams, + DiffEntitiesParams, DiffManifestsParams, EmptyParams, EntitySpec, + EventModelProjectionParams, ExportNamespaceParams, ExportOpensloParams, + ExtractSubgraphParams, GetChangesetParams, GetEntityHistoryParams, GetLatestVersionParams, + GetOperationParams, GetSnapshotIdParams, ImpactParams, JsonOpts, ListChangesParams, + ListChangesetsParams, ListEntitiesByDomainParams, ListEntitiesParams, ListVersionsParams, + MergeBranchParams, MoveNamespaceParams, ProjectionParams, PutEntityJsonParams, + PutEntityParams, ReferencesParams, RegisterNamespaceParams, ResolveBranchEntryParams, + ResolveTypeParams, ResponsibilityQueryParams, RetargetReferencesParams, + RevertChangesetParams, SearchParams, SliceProjectionParams, SupersessionChainParams, + UpdateBranchParams, ValidateEventModelParams, ValidateProjectParams, }, }; @@ -254,6 +254,36 @@ pub struct ToolPolicy { } pub const TOOL_POLICIES: &[ToolPolicy] = &[ + ToolPolicy { + tool: "list_subject_decisions", + rpcs: &["ListSubjectDecisions"], + min_role: ToolRole::Reader, + }, + ToolPolicy { + tool: "list_subject_documents", + rpcs: &["ListSubjectDocuments"], + min_role: ToolRole::Reader, + }, + ToolPolicy { + tool: "get_decision_detail", + rpcs: &["GetDecisionDetail"], + min_role: ToolRole::Reader, + }, + ToolPolicy { + tool: "get_document_detail", + rpcs: &["GetDocumentDetail"], + min_role: ToolRole::Reader, + }, + ToolPolicy { + tool: "list_incoming_decision_declarations", + rpcs: &["ListIncomingDecisionDeclarations"], + min_role: ToolRole::Reader, + }, + ToolPolicy { + tool: "list_incoming_document_declarations", + rpcs: &["ListIncomingDocumentDeclarations"], + min_role: ToolRole::Reader, + }, ToolPolicy { tool: "list_subject_assignments", rpcs: &["ListSubjectAssignments"], @@ -1439,6 +1469,240 @@ impl McpServer { Ok(ok_envelope(&resp)) } + #[tool( + description = "ListSubjectDecisions: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn list_subject_decisions( + &self, + Parameters(p): Parameters, + ) -> Result { + let text = p.request_json.to_json_text(); + if text.len() > MAX_JSON_BYTES { + return Err(rmcp::ErrorData::invalid_params( + "request_json exceeds the 4 MiB limit", + None, + )); + } + let json = serde_json::from_str(&text).map_err(|error| { + rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListSubjectDecisionsRequest", + &json, + ) + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + let result = trogon_atlas_client::decision_document::list_subject_decisions( + &mut self.client(), + request, + ) + .await + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + result.to_string(), + )])) + } + + #[tool( + description = "ListSubjectDocuments: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn list_subject_documents( + &self, + Parameters(p): Parameters, + ) -> Result { + let text = p.request_json.to_json_text(); + if text.len() > MAX_JSON_BYTES { + return Err(rmcp::ErrorData::invalid_params( + "request_json exceeds the 4 MiB limit", + None, + )); + } + let json = serde_json::from_str(&text).map_err(|error| { + rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListSubjectDocumentsRequest", + &json, + ) + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + let result = trogon_atlas_client::decision_document::list_subject_documents( + &mut self.client(), + request, + ) + .await + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + result.to_string(), + )])) + } + + #[tool( + description = "GetDecisionDetail: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn get_decision_detail( + &self, + Parameters(p): Parameters, + ) -> Result { + let text = p.request_json.to_json_text(); + if text.len() > MAX_JSON_BYTES { + return Err(rmcp::ErrorData::invalid_params( + "request_json exceeds the 4 MiB limit", + None, + )); + } + let json = serde_json::from_str(&text).map_err(|error| { + rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "GetDecisionDetailRequest", + &json, + ) + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + let result = trogon_atlas_client::decision_document::get_decision_detail( + &mut self.client(), + request, + ) + .await + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + result.to_string(), + )])) + } + + #[tool( + description = "GetDocumentDetail: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn get_document_detail( + &self, + Parameters(p): Parameters, + ) -> Result { + let text = p.request_json.to_json_text(); + if text.len() > MAX_JSON_BYTES { + return Err(rmcp::ErrorData::invalid_params( + "request_json exceeds the 4 MiB limit", + None, + )); + } + let json = serde_json::from_str(&text).map_err(|error| { + rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "GetDocumentDetailRequest", + &json, + ) + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + let result = trogon_atlas_client::decision_document::get_document_detail( + &mut self.client(), + request, + ) + .await + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + result.to_string(), + )])) + } + + #[tool( + description = "ListIncomingDecisionDeclarations: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn list_incoming_decision_declarations( + &self, + Parameters(p): Parameters, + ) -> Result { + let text = p.request_json.to_json_text(); + if text.len() > MAX_JSON_BYTES { + return Err(rmcp::ErrorData::invalid_params( + "request_json exceeds the 4 MiB limit", + None, + )); + } + let json = serde_json::from_str(&text).map_err(|error| { + rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListIncomingDecisionDeclarationsRequest", + &json, + ) + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + let result = trogon_atlas_client::decision_document::list_incoming_decision_declarations( + &mut self.client(), + request, + ) + .await + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + result.to_string(), + )])) + } + + #[tool( + description = "ListIncomingDocumentDeclarations: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn list_incoming_document_declarations( + &self, + Parameters(p): Parameters, + ) -> Result { + let text = p.request_json.to_json_text(); + if text.len() > MAX_JSON_BYTES { + return Err(rmcp::ErrorData::invalid_params( + "request_json exceeds the 4 MiB limit", + None, + )); + } + let json = serde_json::from_str(&text).map_err(|error| { + rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) + })?; + let request = trogon_atlas_client::decision_document::parse_request( + "ListIncomingDocumentDeclarationsRequest", + &json, + ) + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + let result = trogon_atlas_client::decision_document::list_incoming_document_declarations( + &mut self.client(), + request, + ) + .await + .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + result.to_string(), + )])) + } + #[tool( description = "ListSubjectAssignments: return backend checked exact responsibility declarations and provenance. Incomplete or inaccessible results never prove absence. Team contact member expansion requires explicit includeMembers; no inheritance or authorization grants.", annotations( @@ -2357,7 +2621,13 @@ impl McpServer { .map_err(tool_err("delete_entity")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); - Ok(self.scoped_envelope_with_issues(&resp, &issues_json(&resp.validation))) + let mut result = self.scoped_envelope_with_issues(&resp, &issues_json(&resp.validation)); + if resp.referrers_incomplete { + result.content.push(ContentBlock::text( + "Incoming-reference report is incomplete; missing records do not prove absence or deletion safety.", + )); + } + Ok(result) } #[tool( diff --git a/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs b/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs index e391c673..55f4421d 100644 --- a/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs +++ b/rsworkspace/crates/trogon-atlas-mcp/src/tools.rs @@ -1292,3 +1292,12 @@ pub struct ResponsibilityQueryParams { )] pub request_json: JsonPayload, } + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CatalogQueryParams { + #[schemars( + description = "Exact native query request in proto3 JSON. Preserve selector alternatives, UID, local identity, digest pin presence and checked scope. No inferred historical hydration." + )] + pub request_json: JsonPayload, +} diff --git a/rsworkspace/crates/trogon-atlas-mcp/tests/decision_document_mcp_integration.rs b/rsworkspace/crates/trogon-atlas-mcp/tests/decision_document_mcp_integration.rs new file mode 100644 index 00000000..b08e0e73 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-mcp/tests/decision_document_mcp_integration.rs @@ -0,0 +1,171 @@ +#![allow(clippy::unwrap_used)] +#[allow(clippy::expect_used)] +mod support; +use std::sync::Arc; + +use tokio::net::TcpListener; +use trogon_atlas_proto::event_model_service_server::EventModelServiceServer; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +async fn start_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let store = trogon_atlas_testsupport::shared().await.store().await; + let svc = EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap(); + let grpc = EventModelServiceServer::new(svc); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(grpc) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + format!("http://{addr}") +} + +type McpClient = rmcp::service::RunningService; +type McpServiceRunning = + rmcp::service::RunningService; + +async fn start_mcp_client() -> (McpClient, McpServiceRunning, String) { + let grpc_endpoint = start_server().await; + let mcp_server = support::mcp_server(&grpc_endpoint, None); + let (client_half, server_half) = tokio::io::duplex(64 * 1024); + use rmcp::ServiceExt; + let (mcp_service_result, client_result) = tokio::join!( + mcp_server.serve(server_half), + rmcp::serve_client((), client_half) + ); + ( + client_result.unwrap(), + mcp_service_result.unwrap(), + grpc_endpoint, + ) +} + +fn args_obj(pairs: &[(&str, serde_json::Value)]) -> serde_json::Map { + let mut m = serde_json::Map::new(); + for (k, v) in pairs { + m.insert((*k).to_string(), v.clone()); + } + m +} + +fn call_tool_result_text(result: &rmcp::model::CallToolResult) -> serde_json::Value { + serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap() +} + +const MANIFEST: &str = r#"apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Event +metadata: {namespace: catalog-surfaces, name: receipt} +spec: {title: Receipt} +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Document +metadata: {namespace: catalog-surfaces, name: note} +spec: + title: {value: Retained note} + subjects: [{subject: {entity: {kind: event, id: receipt}}}] + retained: + mediaType: {value: text/markdown} + content: "Retained notes\n" + digest: {sha256: 125a1e44e8f47bf4e244de8f09f31442749f57260dec8319d252e2992a3b16f1} + anchors: [{fragment: {value: Delivery}, label: {value: Delivery}}] +--- +apiVersion: eventmodel.atlas.trogonstack.com/v1alpha1 +kind: Decision +metadata: {namespace: catalog-surfaces, name: choice} +spec: + title: {value: Delivery choice} + subjects: [{subject: {entity: {kind: event, id: receipt}}}] + status: DECISION_STATUS_PROPOSED + context: {value: Delivery question} + rationale: {value: Pending adoption} + options: [{title: {value: Retain}, description: {value: Retain note}}] + documents: [{document: {document: note}}] +"#; + +#[tokio::test(flavor = "multi_thread")] +async fn native_catalog_mcp_create_inspect_query_and_reapply() { + let (client, mcp_service, _endpoint) = start_mcp_client().await; + for _ in 0..2 { + let result = client + .call_tool( + rmcp::model::CallToolRequestParams::new("apply_manifests") + .with_arguments(args_obj(&[("manifests_yaml", serde_json::json!(MANIFEST))])), + ) + .await + .unwrap(); + assert!(!result.is_error.unwrap_or(false), "{result:?}"); + } + let get = client + .call_tool( + rmcp::model::CallToolRequestParams::new("get_entity_json").with_arguments(args_obj(&[ + ("kind", serde_json::json!("document")), + ("namespace", serde_json::json!("catalog-surfaces")), + ("slug", serde_json::json!("note")), + ("version", serde_json::json!(1)), + ])), + ) + .await + .unwrap(); + let body = call_tool_result_text(&get); + assert_eq!( + body["json"]["document"]["retained"]["content"], + "Retained notes\n" + ); + assert!(body["json"]["document"]["anchors"][0]["anchorId"]["value"] + .as_str() + .is_some_and(|s| !s.is_empty())); + let id = |slug| serde_json::json!({"namespace":"catalog-surfaces","slug":slug,"version":"1"}); + let subject = serde_json::json!({"entity":{"kind":"ENTITY_KIND_EVENT","id":id("receipt")}}); + for (tool, request) in [ + ( + "list_subject_decisions", + serde_json::json!({"subject":subject}), + ), + ( + "list_subject_documents", + serde_json::json!({"subject":subject}), + ), + ( + "get_decision_detail", + serde_json::json!({"decision":{"id":id("choice")},"followReplacement":false}), + ), + ( + "get_document_detail", + serde_json::json!({"document":{"document":{"id":id("note")}}}), + ), + ( + "list_incoming_decision_declarations", + serde_json::json!({"decision":{"id":id("choice")}}), + ), + ( + "list_incoming_document_declarations", + serde_json::json!({"document":{"document":{"id":id("note")}}}), + ), + ] { + let result = client + .call_tool( + rmcp::model::CallToolRequestParams::new(tool) + .with_arguments(args_obj(&[("request_json", request)])), + ) + .await + .unwrap(); + assert!(!result.is_error.unwrap_or(false), "{tool}: {result:?}"); + assert!(call_tool_result_text(&result)["scope"].is_object()); + } + let malformed = client + .call_tool( + rmcp::model::CallToolRequestParams::new("get_document_detail").with_arguments( + args_obj(&[( + "request_json", + serde_json::json!({"document":{"document":{},"anchor":{}}}), + )]), + ), + ) + .await; + assert!(malformed.is_err()); + client.cancel().await.unwrap(); + mcp_service.cancel().await.unwrap(); +} diff --git a/rsworkspace/crates/trogon-atlas-mcp/tests/delete_report_mcp_integration.rs b/rsworkspace/crates/trogon-atlas-mcp/tests/delete_report_mcp_integration.rs new file mode 100644 index 00000000..baa864aa --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-mcp/tests/delete_report_mcp_integration.rs @@ -0,0 +1,80 @@ +#![allow(clippy::unwrap_used)] + +use std::convert::Infallible; + +use base64::Engine as _; +use prost::Message as _; +use rmcp::ServiceExt as _; +use trogon_atlas_client::client::{ConnectOptions, Transport}; +use trogon_atlas_mcp::server::McpServer; +use trogon_atlas_proto as pb; + +fn grpc_response(message: &impl prost::Message) -> http::Response { + let encoded = message.encode_to_vec(); + let mut frame = vec![0]; + frame.extend_from_slice(&u32::try_from(encoded.len()).unwrap().to_be_bytes()); + frame.extend_from_slice(&encoded); + http::Response::builder() + .header("content-type", "application/grpc") + .header("grpc-status", "0") + .body(axum::body::Body::from(frame)) + .unwrap() +} + +#[tokio::test] +async fn incomplete_delete_report_retains_typed_flag_and_warns_without_claiming_empty_coverage() { + let transport = Transport::new(tower::service_fn( + |request: http::Request| async move { + let response = if request.uri().path().ends_with("/GetServerInfo") { + grpc_response(&pb::GetServerInfoResponse { + schema_version: pb::SCHEMA_VERSION.into(), + contract_revision: pb::CONTRACT_REVISION, + min_client_contract_revision: pb::MIN_CLIENT_CONTRACT_REVISION, + features: Some(pb::get_server_info_response::Features { + mutations: true, + validate_only: true, + ..Default::default() + }), + ..Default::default() + }) + } else { + assert!(request.uri().path().ends_with("/DeleteEntity")); + grpc_response(&pb::DeleteEntityResponse { + operation_receipt: None, + referrers: vec![], + referrers_incomplete: true, + validation: vec![], + }) + }; + Ok::<_, Infallible>(response) + }, + )); + let server = McpServer::new(transport, ConnectOptions::default()).unwrap(); + let (client_half, server_half) = tokio::io::duplex(64 * 1024); + let (server, client) = tokio::join!( + server.serve(server_half), + rmcp::serve_client((), client_half) + ); + let server = server.unwrap(); + let client = client.unwrap(); + let result = client.call_tool(rmcp::model::CallToolRequestParams::new("delete_entity").with_arguments( + serde_json::json!({"kind":"event","namespace":"catalog-report","slug":"receipt","version":1,"mode":"dry_run"}).as_object().unwrap().clone() + )).await.unwrap(); + let envelope: serde_json::Value = + serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(envelope["binpb_base64"].as_str().unwrap()) + .unwrap(); + let decoded = pb::DeleteEntityResponse::decode(bytes.as_slice()).unwrap(); + assert!(decoded.referrers_incomplete); + assert_eq!(decoded.referrers, []); + assert!(result + .content + .iter() + .filter_map(|item| item.as_text()) + .any(|text| text + .text + .contains("Incoming-reference report is incomplete"))); + client.cancel().await.unwrap(); + server.cancel().await.unwrap(); +} diff --git a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs index 99033ccb..d139b1a2 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.rs @@ -452,6 +452,218 @@ impl DataProductOfferingState { } } } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogQueryFailure { + #[prost(string, tag="1")] + pub field: ::prost::alloc::string::String, + #[prost(message, optional, tag="2")] + pub connection_id: ::core::option::Option, + #[prost(int32, tag="3")] + pub code: i32, + #[prost(string, tag="4")] + pub message: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogCheckedOwner { + #[prost(message, optional, tag="1")] + pub owner: ::core::option::Option, + #[prost(string, tag="2")] + pub uid: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub etag: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CatalogCheckedScope { + #[prost(string, optional, tag="1")] + pub branch: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, repeated, tag="2")] + pub authorized_namespaces: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, tag="3")] + pub revision: ::prost::alloc::string::String, + #[prost(message, repeated, tag="4")] + pub visited: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="5")] + pub visited_subjects: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="6")] + pub visited_documents: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogDeclaration { + #[prost(message, optional, tag="1")] + pub owner: ::core::option::Option, + #[prost(string, tag="2")] + pub field: ::prost::alloc::string::String, + #[prost(enumeration="CatalogResolutionState", tag="3")] + pub state: i32, + #[prost(oneof="catalog_declaration::Declaration", tags="4, 5, 6, 7, 8")] + pub declaration: ::core::option::Option, +} +/// Nested message and enum types in `CatalogDeclaration`. +pub mod catalog_declaration { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Declaration { + #[prost(message, tag="4")] + Subject(super::super::super::super::eventmodel::v1alpha1::SubjectAssociation), + #[prost(message, tag="5")] + Replacement(super::super::super::super::eventmodel::v1alpha1::DecisionReplacement), + #[prost(message, tag="6")] + Related(super::super::super::super::eventmodel::v1alpha1::RelatedDecision), + #[prost(message, tag="7")] + Supporting(super::super::super::super::eventmodel::v1alpha1::SupportingDocument), + #[prost(message, tag="8")] + Navigation(super::super::super::super::eventmodel::v1alpha1::DocumentNavigation), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListSubjectDecisionsRequest { + #[prost(message, optional, tag="1")] + pub subject: ::core::option::Option, + #[prost(enumeration="super::super::super::eventmodel::v1alpha1::DecisionStatus", optional, tag="2")] + pub status: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListSubjectDecisionsResponse { + #[prost(message, repeated, tag="1")] + pub declarations: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="2")] + pub scope: ::core::option::Option, + #[prost(bool, tag="3")] + pub incomplete: bool, + #[prost(message, repeated, tag="4")] + pub failures: ::prost::alloc::vec::Vec, + #[prost(enumeration="CatalogResolutionState", tag="5")] + pub subject_state: i32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListSubjectDocumentsRequest { + #[prost(message, optional, tag="1")] + pub subject: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListSubjectDocumentsResponse { + #[prost(message, repeated, tag="1")] + pub declarations: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="2")] + pub scope: ::core::option::Option, + #[prost(bool, tag="3")] + pub incomplete: bool, + #[prost(message, repeated, tag="4")] + pub failures: ::prost::alloc::vec::Vec, + #[prost(enumeration="CatalogResolutionState", tag="5")] + pub subject_state: i32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetDecisionDetailRequest { + #[prost(message, optional, tag="1")] + pub decision: ::core::option::Option, + #[prost(bool, tag="2")] + pub follow_replacement: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDecisionDetailResponse { + #[prost(message, optional, tag="1")] + pub entity: ::core::option::Option, + #[prost(string, tag="2")] + pub etag: ::prost::alloc::string::String, + #[prost(message, repeated, tag="3")] + pub declarations: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="4")] + pub scope: ::core::option::Option, + #[prost(bool, tag="5")] + pub incomplete: bool, + #[prost(message, repeated, tag="6")] + pub failures: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="7")] + pub replacement_chain: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetDocumentDetailRequest { + #[prost(message, optional, tag="1")] + pub document: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDocumentDetailResponse { + #[prost(message, optional, tag="1")] + pub entity: ::core::option::Option, + #[prost(string, tag="2")] + pub etag: ::prost::alloc::string::String, + #[prost(message, optional, tag="3")] + pub anchor: ::core::option::Option, + #[prost(message, repeated, tag="4")] + pub declarations: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="5")] + pub scope: ::core::option::Option, + #[prost(bool, tag="6")] + pub incomplete: bool, + #[prost(message, repeated, tag="7")] + pub failures: ::prost::alloc::vec::Vec, + #[prost(enumeration="CatalogResolutionState", tag="8")] + pub selection_state: i32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListIncomingDecisionDeclarationsRequest { + #[prost(message, optional, tag="1")] + pub decision: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListIncomingDecisionDeclarationsResponse { + #[prost(message, repeated, tag="1")] + pub declarations: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="2")] + pub scope: ::core::option::Option, + #[prost(bool, tag="3")] + pub incomplete: bool, + #[prost(message, repeated, tag="4")] + pub failures: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListIncomingDocumentDeclarationsRequest { + #[prost(message, optional, tag="1")] + pub document: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListIncomingDocumentDeclarationsResponse { + #[prost(message, repeated, tag="1")] + pub declarations: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="2")] + pub scope: ::core::option::Option, + #[prost(bool, tag="3")] + pub incomplete: bool, + #[prost(message, repeated, tag="4")] + pub failures: ::prost::alloc::vec::Vec, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum CatalogResolutionState { + Unspecified = 0, + Resolved = 1, + Unavailable = 2, + Unresolved = 3, +} +impl CatalogResolutionState { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "CATALOG_RESOLUTION_STATE_UNSPECIFIED", + Self::Resolved => "CATALOG_RESOLUTION_STATE_RESOLVED", + Self::Unavailable => "CATALOG_RESOLUTION_STATE_UNAVAILABLE", + Self::Unresolved => "CATALOG_RESOLUTION_STATE_UNRESOLVED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "CATALOG_RESOLUTION_STATE_UNSPECIFIED" => Some(Self::Unspecified), + "CATALOG_RESOLUTION_STATE_RESOLVED" => Some(Self::Resolved), + "CATALOG_RESOLUTION_STATE_UNAVAILABLE" => Some(Self::Unavailable), + "CATALOG_RESOLUTION_STATE_UNRESOLVED" => Some(Self::Unresolved), + _ => None, + } + } +} /// Static catalog of every EntityKind the system stores. Same shape and /// motivation as ListValidationRules: avoids the per-client three-name /// map (proto enum string ENTITY_KIND_FOO, JSON discriminator `event`, @@ -494,6 +706,12 @@ pub struct Reference { pub membership: ::core::option::Option, #[prost(string, tag="6")] pub from_uid: ::prost::alloc::string::String, + #[prost(message, optional, tag="7")] + pub catalog_subject: ::core::option::Option, + #[prost(message, optional, tag="8")] + pub document_selection: ::core::option::Option, + #[prost(message, optional, tag="9")] + pub connection_id: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ImpactNode { @@ -1540,6 +1758,8 @@ pub struct DeleteEntityResponse { /// current view to judge. #[prost(message, repeated, tag="3")] pub validation: ::prost::alloc::vec::Vec, + #[prost(bool, tag="4")] + pub referrers_incomplete: bool, } // ---------- Batch mutation (atomic multi-entity write) ----------------------- // diff --git a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.tonic.rs b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.tonic.rs index 3bfb37ff..e02d4c09 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.tonic.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.api.eventmodel.v1alpha1.tonic.rs @@ -92,6 +92,190 @@ pub mod event_model_service_client { self } /// + pub async fn list_subject_decisions( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectDecisions", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "trogonatlas.api.eventmodel.v1alpha1.EventModelService", + "ListSubjectDecisions", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn list_subject_documents( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectDocuments", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "trogonatlas.api.eventmodel.v1alpha1.EventModelService", + "ListSubjectDocuments", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn get_decision_detail( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetDecisionDetail", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "trogonatlas.api.eventmodel.v1alpha1.EventModelService", + "GetDecisionDetail", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn get_document_detail( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetDocumentDetail", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "trogonatlas.api.eventmodel.v1alpha1.EventModelService", + "GetDocumentDetail", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn list_incoming_decision_declarations( + &mut self, + request: impl tonic::IntoRequest< + super::ListIncomingDecisionDeclarationsRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListIncomingDecisionDeclarations", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "trogonatlas.api.eventmodel.v1alpha1.EventModelService", + "ListIncomingDecisionDeclarations", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn list_incoming_document_declarations( + &mut self, + request: impl tonic::IntoRequest< + super::ListIncomingDocumentDeclarationsRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListIncomingDocumentDeclarations", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "trogonatlas.api.eventmodel.v1alpha1.EventModelService", + "ListIncomingDocumentDeclarations", + ), + ); + self.inner.unary(req, path, codec).await + } + /// pub async fn list_subject_assignments( &mut self, request: impl tonic::IntoRequest, @@ -1872,6 +2056,54 @@ pub mod event_model_service_server { /// Generated trait containing gRPC methods that should be implemented for use with EventModelServiceServer. #[async_trait] pub trait EventModelService: std::marker::Send + std::marker::Sync + 'static { + /// + async fn list_subject_decisions( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn list_subject_documents( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn get_decision_detail( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn get_document_detail( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn list_incoming_decision_declarations( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn list_incoming_document_declarations( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// async fn list_subject_assignments( &self, @@ -2467,6 +2699,310 @@ pub mod event_model_service_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectDecisions" => { + #[allow(non_camel_case_types)] + struct ListSubjectDecisionsSvc(pub Arc); + impl< + T: EventModelService, + > tonic::server::UnaryService + for ListSubjectDecisionsSvc { + type Response = super::ListSubjectDecisionsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_subject_decisions( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListSubjectDecisionsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectDocuments" => { + #[allow(non_camel_case_types)] + struct ListSubjectDocumentsSvc(pub Arc); + impl< + T: EventModelService, + > tonic::server::UnaryService + for ListSubjectDocumentsSvc { + type Response = super::ListSubjectDocumentsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_subject_documents( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListSubjectDocumentsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetDecisionDetail" => { + #[allow(non_camel_case_types)] + struct GetDecisionDetailSvc(pub Arc); + impl< + T: EventModelService, + > tonic::server::UnaryService + for GetDecisionDetailSvc { + type Response = super::GetDecisionDetailResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_decision_detail( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetDecisionDetailSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetDocumentDetail" => { + #[allow(non_camel_case_types)] + struct GetDocumentDetailSvc(pub Arc); + impl< + T: EventModelService, + > tonic::server::UnaryService + for GetDocumentDetailSvc { + type Response = super::GetDocumentDetailResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_document_detail( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetDocumentDetailSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListIncomingDecisionDeclarations" => { + #[allow(non_camel_case_types)] + struct ListIncomingDecisionDeclarationsSvc( + pub Arc, + ); + impl< + T: EventModelService, + > tonic::server::UnaryService< + super::ListIncomingDecisionDeclarationsRequest, + > for ListIncomingDecisionDeclarationsSvc { + type Response = super::ListIncomingDecisionDeclarationsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::ListIncomingDecisionDeclarationsRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_incoming_decision_declarations( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListIncomingDecisionDeclarationsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListIncomingDocumentDeclarations" => { + #[allow(non_camel_case_types)] + struct ListIncomingDocumentDeclarationsSvc( + pub Arc, + ); + impl< + T: EventModelService, + > tonic::server::UnaryService< + super::ListIncomingDocumentDeclarationsRequest, + > for ListIncomingDocumentDeclarationsSvc { + type Response = super::ListIncomingDocumentDeclarationsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::ListIncomingDocumentDeclarationsRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_incoming_document_declarations( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListIncomingDocumentDeclarationsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectAssignments" => { #[allow(non_camel_case_types)] struct ListSubjectAssignmentsSvc(pub Arc); diff --git a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs index 850ddddd..a2f52984 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/gen/trogonatlas.eventmodel.v1alpha1.rs @@ -120,6 +120,16 @@ pub struct PersonRef { #[prost(message, optional, tag="1")] pub id: ::core::option::Option, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionRef { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentRef { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, +} /// Discriminated union over every addressable thing. The shape of the inner /// message determines the kind unambiguously; EntityKind is provided /// separately on the service surface so callers don't need to inspect the @@ -163,6 +173,8 @@ pub enum EntityKind { DataProduct = 33, Team = 34, Person = 35, + Decision = 36, + Document = 37, } impl EntityKind { /// String value of the enum field names used in the ProtoBuf definition. @@ -207,6 +219,8 @@ impl EntityKind { Self::DataProduct => "ENTITY_KIND_DATA_PRODUCT", Self::Team => "ENTITY_KIND_TEAM", Self::Person => "ENTITY_KIND_PERSON", + Self::Decision => "ENTITY_KIND_DECISION", + Self::Document => "ENTITY_KIND_DOCUMENT", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -248,6 +262,8 @@ impl EntityKind { "ENTITY_KIND_DATA_PRODUCT" => Some(Self::DataProduct), "ENTITY_KIND_TEAM" => Some(Self::Team), "ENTITY_KIND_PERSON" => Some(Self::Person), + "ENTITY_KIND_DECISION" => Some(Self::Decision), + "ENTITY_KIND_DOCUMENT" => Some(Self::Document), _ => None, } } @@ -3391,185 +3407,6 @@ impl DataPortDirection { } } } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DirectoryDisplayName { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DirectoryRoleText { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct EmailAddress { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ContactUrl { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DirectoryProvider { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct OpaqueDirectoryKey { - #[prost(string, tag="1")] - pub value: ::prost::alloc::string::String, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ChatContact { - #[prost(message, optional, tag="1")] - pub service: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub handle: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalDirectoryPointer { - #[prost(message, optional, tag="1")] - pub provider: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub key: ::core::option::Option, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ContactPoint { - #[prost(string, optional, tag="4")] - pub label: ::core::option::Option<::prost::alloc::string::String>, - #[prost(oneof="contact_point::Channel", tags="1, 2, 3")] - pub channel: ::core::option::Option, -} -/// Nested message and enum types in `ContactPoint`. -pub mod contact_point { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Channel { - #[prost(message, tag="1")] - Email(super::EmailAddress), - #[prost(message, tag="2")] - Web(super::ContactUrl), - #[prost(message, tag="3")] - Chat(super::ChatContact), - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ResponsibilitySubject { - #[prost(oneof="responsibility_subject::Selection", tags="1, 2, 3, 4")] - pub selection: ::core::option::Option, -} -/// Nested message and enum types in `ResponsibilitySubject`. -pub mod responsibility_subject { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Selection { - #[prost(message, tag="1")] - Entity(super::EntityRef), - #[prost(message, tag="2")] - Connection(super::ConnectionRef), - #[prost(message, tag="3")] - Member(super::ContractMemberRef), - #[prost(message, tag="4")] - Port(super::DataPortRef), - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct Responsibility { - #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, - #[prost(enumeration="ResponsibilityKind", tag="2")] - pub kind: i32, - #[prost(message, optional, tag="3")] - pub subject: ::core::option::Option, - #[prost(string, optional, tag="4")] - pub doc: ::core::option::Option<::prost::alloc::string::String>, -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct TeamMember { - #[prost(message, optional, tag="1")] - pub connection_id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub person: ::core::option::Option, - #[prost(string, optional, tag="3")] - pub doc: ::core::option::Option<::prost::alloc::string::String>, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Team { - #[prost(message, optional, tag="1")] - pub id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, - #[prost(string, optional, tag="3")] - pub doc: ::core::option::Option<::prost::alloc::string::String>, - #[prost(message, repeated, tag="4")] - pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, - #[prost(message, optional, tag="5")] - pub supersedes: ::core::option::Option, - #[prost(message, repeated, tag="6")] - pub contacts: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="7")] - pub responsibilities: ::prost::alloc::vec::Vec, - #[prost(string, optional, tag="8")] - pub purpose: ::core::option::Option<::prost::alloc::string::String>, - #[prost(message, repeated, tag="9")] - pub members: ::prost::alloc::vec::Vec, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Person { - #[prost(message, optional, tag="1")] - pub id: ::core::option::Option, - #[prost(message, optional, tag="2")] - pub title: ::core::option::Option, - #[prost(string, optional, tag="3")] - pub doc: ::core::option::Option<::prost::alloc::string::String>, - #[prost(message, repeated, tag="4")] - pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, - #[prost(message, optional, tag="5")] - pub supersedes: ::core::option::Option, - #[prost(message, repeated, tag="6")] - pub contacts: ::prost::alloc::vec::Vec, - #[prost(message, repeated, tag="7")] - pub responsibilities: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag="8")] - pub role: ::core::option::Option, - #[prost(message, optional, tag="9")] - pub directory_pointer: ::core::option::Option, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ResponsibilityKind { - Unspecified = 0, - Owner = 1, - Maintainer = 2, - Steward = 3, - Contact = 4, -} -impl ResponsibilityKind { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "RESPONSIBILITY_KIND_UNSPECIFIED", - Self::Owner => "RESPONSIBILITY_KIND_OWNER", - Self::Maintainer => "RESPONSIBILITY_KIND_MAINTAINER", - Self::Steward => "RESPONSIBILITY_KIND_STEWARD", - Self::Contact => "RESPONSIBILITY_KIND_CONTACT", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "RESPONSIBILITY_KIND_UNSPECIFIED" => Some(Self::Unspecified), - "RESPONSIBILITY_KIND_OWNER" => Some(Self::Owner), - "RESPONSIBILITY_KIND_MAINTAINER" => Some(Self::Maintainer), - "RESPONSIBILITY_KIND_STEWARD" => Some(Self::Steward), - "RESPONSIBILITY_KIND_CONTACT" => Some(Self::Contact), - _ => None, - } - } -} /// The bounded context itself, as data (Decision #27): one per namespace, /// identified BY the namespace (id.slug == id.namespace). Carries the /// context charter -- what this boundary owns, its links and metadata. @@ -4008,6 +3845,469 @@ impl TrackStatus { } } } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DirectoryDisplayName { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DirectoryRoleText { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct EmailAddress { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContactUrl { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DirectoryProvider { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OpaqueDirectoryKey { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ChatContact { + #[prost(message, optional, tag="1")] + pub service: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub handle: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExternalDirectoryPointer { + #[prost(message, optional, tag="1")] + pub provider: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub key: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContactPoint { + #[prost(string, optional, tag="4")] + pub label: ::core::option::Option<::prost::alloc::string::String>, + #[prost(oneof="contact_point::Channel", tags="1, 2, 3")] + pub channel: ::core::option::Option, +} +/// Nested message and enum types in `ContactPoint`. +pub mod contact_point { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Channel { + #[prost(message, tag="1")] + Email(super::EmailAddress), + #[prost(message, tag="2")] + Web(super::ContactUrl), + #[prost(message, tag="3")] + Chat(super::ChatContact), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ResponsibilitySubject { + #[prost(oneof="responsibility_subject::Selection", tags="1, 2, 3, 4")] + pub selection: ::core::option::Option, +} +/// Nested message and enum types in `ResponsibilitySubject`. +pub mod responsibility_subject { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Selection { + #[prost(message, tag="1")] + Entity(super::EntityRef), + #[prost(message, tag="2")] + Connection(super::ConnectionRef), + #[prost(message, tag="3")] + Member(super::ContractMemberRef), + #[prost(message, tag="4")] + Port(super::DataPortRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Responsibility { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(enumeration="ResponsibilityKind", tag="2")] + pub kind: i32, + #[prost(message, optional, tag="3")] + pub subject: ::core::option::Option, + #[prost(string, optional, tag="4")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TeamMember { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub person: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Team { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="4")] + pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, + #[prost(message, optional, tag="5")] + pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="6")] + pub contacts: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="7")] + pub responsibilities: ::prost::alloc::vec::Vec, + #[prost(string, optional, tag="8")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="9")] + pub members: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Person { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="4")] + pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, + #[prost(message, optional, tag="5")] + pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="6")] + pub contacts: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="7")] + pub responsibilities: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="8")] + pub role: ::core::option::Option, + #[prost(message, optional, tag="9")] + pub directory_pointer: ::core::option::Option, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ResponsibilityKind { + Unspecified = 0, + Owner = 1, + Maintainer = 2, + Steward = 3, + Contact = 4, +} +impl ResponsibilityKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "RESPONSIBILITY_KIND_UNSPECIFIED", + Self::Owner => "RESPONSIBILITY_KIND_OWNER", + Self::Maintainer => "RESPONSIBILITY_KIND_MAINTAINER", + Self::Steward => "RESPONSIBILITY_KIND_STEWARD", + Self::Contact => "RESPONSIBILITY_KIND_CONTACT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "RESPONSIBILITY_KIND_UNSPECIFIED" => Some(Self::Unspecified), + "RESPONSIBILITY_KIND_OWNER" => Some(Self::Owner), + "RESPONSIBILITY_KIND_MAINTAINER" => Some(Self::Maintainer), + "RESPONSIBILITY_KIND_STEWARD" => Some(Self::Steward), + "RESPONSIBILITY_KIND_CONTACT" => Some(Self::Contact), + _ => None, + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogTitle { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogText { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LocalValueId { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExternalDocumentLink { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AnchorFragment { + #[prost(string, tag="1")] + pub value: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CatalogSubject { + #[prost(oneof="catalog_subject::Selection", tags="1, 2, 3, 4")] + pub selection: ::core::option::Option, +} +/// Nested message and enum types in `CatalogSubject`. +pub mod catalog_subject { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Selection { + #[prost(message, tag="1")] + Entity(super::EntityRef), + #[prost(message, tag="2")] + Connection(super::ConnectionRef), + #[prost(message, tag="3")] + Member(super::ContractMemberRef), + #[prost(message, tag="4")] + Port(super::DataPortRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SubjectAssociation { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub subject: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub doc: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionOption { + #[prost(message, optional, tag="1")] + pub option_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub description: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionConsequence { + #[prost(enumeration="decision_consequence::Kind", tag="1")] + pub kind: i32, + #[prost(message, optional, tag="2")] + pub text: ::core::option::Option, +} +/// Nested message and enum types in `DecisionConsequence`. +pub mod decision_consequence { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Kind { + Unspecified = 0, + Benefit = 1, + Cost = 2, + Risk = 3, + } + impl Kind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "KIND_UNSPECIFIED", + Self::Benefit => "KIND_BENEFIT", + Self::Cost => "KIND_COST", + Self::Risk => "KIND_RISK", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "KIND_UNSPECIFIED" => Some(Self::Unspecified), + "KIND_BENEFIT" => Some(Self::Benefit), + "KIND_COST" => Some(Self::Cost), + "KIND_RISK" => Some(Self::Risk), + _ => None, + } + } + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DecisionReplacement { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub decision: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelatedDecision { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub decision: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub doc: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentAnchorRef { + #[prost(message, optional, tag="1")] + pub document: ::core::option::Option, + #[prost(string, tag="2")] + pub owner_uid: ::prost::alloc::string::String, + #[prost(message, optional, tag="3")] + pub anchor_id: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub expected_digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentSelection { + #[prost(oneof="document_selection::Selection", tags="1, 2")] + pub selection: ::core::option::Option, +} +/// Nested message and enum types in `DocumentSelection`. +pub mod document_selection { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Selection { + #[prost(message, tag="1")] + Document(super::DocumentRef), + #[prost(message, tag="2")] + Anchor(super::DocumentAnchorRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SupportingDocument { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub document: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub doc: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Decision { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="4")] + pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, + #[prost(message, optional, tag="5")] + pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="6")] + pub subjects: ::prost::alloc::vec::Vec, + #[prost(enumeration="DecisionStatus", tag="7")] + pub status: i32, + #[prost(message, optional, tag="8")] + pub context: ::core::option::Option, + #[prost(message, optional, tag="9")] + pub rationale: ::core::option::Option, + #[prost(message, repeated, tag="10")] + pub options: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="11")] + pub selected_option: ::core::option::Option, + #[prost(message, repeated, tag="12")] + pub consequences: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag="13")] + pub replaced_by: ::core::option::Option, + #[prost(message, repeated, tag="14")] + pub related_decisions: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="15")] + pub documents: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RetainedDocumentText { + #[prost(message, optional, tag="1")] + pub media_type: ::core::option::Option, + #[prost(string, tag="2")] + pub content: ::prost::alloc::string::String, + #[prost(message, optional, tag="3")] + pub digest: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentAnchor { + #[prost(message, optional, tag="1")] + pub anchor_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub fragment: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub label: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DocumentNavigation { + #[prost(message, optional, tag="1")] + pub connection_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub document: ::core::option::Option, + #[prost(message, optional, tag="3")] + pub label: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Document { + #[prost(message, optional, tag="1")] + pub id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub title: ::core::option::Option, + #[prost(string, optional, tag="3")] + pub doc: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag="4")] + pub metadata: ::prost::alloc::vec::Vec<::prost_types::Any>, + #[prost(message, optional, tag="5")] + pub supersedes: ::core::option::Option, + #[prost(message, repeated, tag="6")] + pub subjects: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="9")] + pub anchors: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="10")] + pub navigation: ::prost::alloc::vec::Vec, + #[prost(oneof="document::Body", tags="7, 8")] + pub body: ::core::option::Option, +} +/// Nested message and enum types in `Document`. +pub mod document { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Body { + #[prost(message, tag="7")] + Retained(super::RetainedDocumentText), + #[prost(message, tag="8")] + Link(super::ExternalDocumentLink), + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum DecisionStatus { + Unspecified = 0, + Proposed = 1, + Accepted = 2, + Rejected = 3, + Withdrawn = 4, + Superseded = 5, +} +impl DecisionStatus { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "DECISION_STATUS_UNSPECIFIED", + Self::Proposed => "DECISION_STATUS_PROPOSED", + Self::Accepted => "DECISION_STATUS_ACCEPTED", + Self::Rejected => "DECISION_STATUS_REJECTED", + Self::Withdrawn => "DECISION_STATUS_WITHDRAWN", + Self::Superseded => "DECISION_STATUS_SUPERSEDED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "DECISION_STATUS_UNSPECIFIED" => Some(Self::Unspecified), + "DECISION_STATUS_PROPOSED" => Some(Self::Proposed), + "DECISION_STATUS_ACCEPTED" => Some(Self::Accepted), + "DECISION_STATUS_REJECTED" => Some(Self::Rejected), + "DECISION_STATUS_WITHDRAWN" => Some(Self::Withdrawn), + "DECISION_STATUS_SUPERSEDED" => Some(Self::Superseded), + _ => None, + } + } +} // ============================================================================= // Event Modeling schema. // @@ -4496,7 +4796,7 @@ impl TrackStatus { pub struct Entity { #[prost(message, optional, tag="29")] pub system: ::core::option::Option, - #[prost(oneof="entity::Kind", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 35, 36")] + #[prost(oneof="entity::Kind", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 35, 36, 37, 38")] pub kind: ::core::option::Option, } /// Nested message and enum types in `Entity`. @@ -4573,6 +4873,10 @@ pub mod entity { Team(super::Team), #[prost(message, tag="36")] Person(super::Person), + #[prost(message, tag="37")] + Decision(super::Decision), + #[prost(message, tag="38")] + Document(super::Document), } } // @@protoc_insertion_point(module) diff --git a/rsworkspace/crates/trogon-atlas-proto/src/lib.rs b/rsworkspace/crates/trogon-atlas-proto/src/lib.rs index 99bc555e..49140a36 100644 --- a/rsworkspace/crates/trogon-atlas-proto/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-proto/src/lib.rs @@ -13,15 +13,15 @@ pub const SCHEMA_VERSION: &str = "trogonatlas.eventmodel.v1alpha1"; /// Wire contract revision this build speaks, advertised by the server as /// `GetServerInfoResponse.contract_revision` and checked by clients before /// mutating. See `docs/explanation/wire-compatibility.md` for when it changes. -pub const CONTRACT_REVISION: u32 = 13; +pub const CONTRACT_REVISION: u32 = 14; /// Oldest server contract revision clients built from this tree mutate /// against. -pub const MIN_SERVER_CONTRACT_REVISION: u32 = 13; +pub const MIN_SERVER_CONTRACT_REVISION: u32 = 14; /// Oldest client contract revision a server built from this tree accepts /// mutations from. -pub const MIN_CLIENT_CONTRACT_REVISION: u32 = 13; +pub const MIN_CLIENT_CONTRACT_REVISION: u32 = 14; /// Serialized `FileDescriptorSet` for the schema, for reflection-based /// tooling (textproto/JSON transcoding). @@ -229,6 +229,8 @@ pub mod canonical { EntityKind::DataProduct => "data_product", EntityKind::Team => "team", EntityKind::Person => "person", + EntityKind::Decision => "decision", + EntityKind::Document => "document", } } @@ -272,6 +274,8 @@ pub mod canonical { EntityKind::DataProduct, EntityKind::Team, EntityKind::Person, + EntityKind::Decision, + EntityKind::Document, ]; /// Parse a kind string into an `EntityKind`. Accepts the canonical diff --git a/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs b/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs index 6874a40f..5c412de2 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/architecture_admission.rs @@ -17,6 +17,15 @@ pub(super) struct ArchitectureProposal<'a> { enum ArchitectureTargetAccess<'a> { Required(&'a Visibility), Diagnostic(&'a Lens), + ExactRequired(&'a Visibility), + ExactDiagnostic(&'a Lens), +} + +fn is_catalog(entity: &pb::Entity) -> bool { + matches!( + entity.kind, + Some(pb::entity::Kind::Decision(_) | pb::entity::Kind::Document(_)) + ) } impl EventModelServiceImpl { @@ -88,6 +97,8 @@ impl EventModelServiceImpl { | pb::EntityKind::DataProduct | pb::EntityKind::Team | pb::EntityKind::Person + | pb::EntityKind::Decision + | pb::EntityKind::Document ) }) .filter_map(|meta| meta.entity.as_ref()) @@ -95,6 +106,12 @@ impl EventModelServiceImpl { if resources.is_empty() { return Ok(()); } + let mut catalog_budget = crate::decision_document_resources::CatalogBudget::default(); + if resources.iter().any(|entity| is_catalog(entity)) { + for entity in &resources { + catalog_budget.owner(entity)?; + } + } let overlay: HashMap<_, _> = metas .iter() .map(|meta| { @@ -108,6 +125,9 @@ impl EventModelServiceImpl { let mut world = HashMap::new(); let mut required = Vec::new(); for entity in &resources { + if is_catalog(entity) { + continue; + } required.extend( trogon_atlas_core::refs::outbound_refs(entity) .into_iter() @@ -124,6 +144,21 @@ impl EventModelServiceImpl { &mut world, ) .await?; + let catalog_targets = resources + .iter() + .filter(|entity| is_catalog(entity)) + .flat_map(|entity| trogon_atlas_core::refs::outbound_refs(entity)) + .filter_map(|reference| reference.to_kind.map(|kind| (kind, reference.to_id))) + .collect(); + self.load_architecture_targets( + &proposal, + branch, + ArchitectureTargetAccess::ExactRequired(vis), + catalog_targets, + &mut loaded, + &mut world, + ) + .await?; let mut endpoints = observation_endpoints(&resources, &world); endpoints.extend(crate::data_product_resources::selected_endpoints( &resources, &world, @@ -138,6 +173,25 @@ impl EventModelServiceImpl { &mut world, ) .await?; + let catalog_targets = catalog_endpoints(&resources, &world); + self.load_architecture_targets( + &proposal, + branch, + ArchitectureTargetAccess::ExactRequired(vis), + catalog_targets, + &mut loaded, + &mut world, + ) + .await?; + self.validate_catalog_replacements( + &proposal, + branch, + vis, + &mut loaded, + &mut world, + &mut catalog_budget, + ) + .await?; let context: Vec<_> = world.into_values().collect(); let event_products: Vec<_> = resources .iter() @@ -226,6 +280,8 @@ impl EventModelServiceImpl { | pb::entity::Kind::DataProduct(_) | pb::entity::Kind::Team(_) | pb::entity::Kind::Person(_) + | pb::entity::Kind::Decision(_) + | pb::entity::Kind::Document(_) ) ) }) @@ -241,6 +297,7 @@ impl EventModelServiceImpl { let access = ArchitectureTargetAccess::Diagnostic(&lens); let required = resources .iter() + .filter(|entity| !is_catalog(entity)) .flat_map(|entity| { trogon_atlas_core::refs::outbound_refs(entity) .into_iter() @@ -258,6 +315,21 @@ impl EventModelServiceImpl { &mut world, ) .await?; + let catalog_targets = resources + .iter() + .filter(|entity| is_catalog(entity)) + .flat_map(|entity| trogon_atlas_core::refs::outbound_refs(entity)) + .filter_map(|reference| reference.to_kind.map(|kind| (kind, reference.to_id))) + .collect(); + self.load_architecture_targets( + &proposal, + branch, + ArchitectureTargetAccess::ExactDiagnostic(&lens), + catalog_targets, + &mut loaded, + &mut world, + ) + .await?; let mut endpoints = observation_endpoints(&resources, &world); endpoints.extend(crate::data_product_resources::selected_endpoints( &resources, &world, @@ -272,6 +344,60 @@ impl EventModelServiceImpl { &mut world, ) .await?; + let catalog_targets = catalog_endpoints(&resources, &world); + self.load_architecture_targets( + &proposal, + branch, + ArchitectureTargetAccess::ExactDiagnostic(&lens), + catalog_targets, + &mut loaded, + &mut world, + ) + .await?; + let mut budget = crate::decision_document_resources::CatalogBudget::default(); + for entity in &resources { + let Some(pb::entity::Kind::Decision(decision)) = &entity.kind else { + continue; + }; + let mut next = decision + .replaced_by + .as_ref() + .and_then(|replacement| replacement.decision.as_ref()) + .and_then(|reference| reference.id.clone()); + let mut visited = HashSet::new(); + visited.insert(entity_id(entity)?.clone()); + while let Some(id) = next.take() { + budget.examine(1)?; + if !visited.insert(id.clone()) { + break; + } + self.load_architecture_targets( + &proposal, + branch, + ArchitectureTargetAccess::ExactDiagnostic(&lens), + vec![(pb::EntityKind::Decision, id.clone())], + &mut loaded, + &mut world, + ) + .await?; + let Some(row) = world.get(&pb::EntityKey::new(pb::EntityKind::Decision, &id)) + else { + break; + }; + budget.owner(&row.entity)?; + let Some(pb::entity::Kind::Decision(target)) = &row.entity.kind else { + break; + }; + if target.status != pb::DecisionStatus::Superseded as i32 { + break; + } + next = target + .replaced_by + .as_ref() + .and_then(|replacement| replacement.decision.as_ref()) + .and_then(|reference| reference.id.clone()); + } + } Ok(world.into_values().collect()) } @@ -346,6 +472,8 @@ impl EventModelServiceImpl { pb::EntityKind::DataProduct, pb::EntityKind::Team, pb::EntityKind::Person, + pb::EntityKind::Decision, + pb::EntityKind::Document, ], namespaces: &namespaces, ..Default::default() @@ -393,10 +521,12 @@ impl EventModelServiceImpl { let mut pending = targets; while let Some((kind, id)) = pending.pop() { match access { - ArchitectureTargetAccess::Required(vis) => { + ArchitectureTargetAccess::Required(vis) + | ArchitectureTargetAccess::ExactRequired(vis) => { self.require_visible(vis, &id.namespace).await?; } - ArchitectureTargetAccess::Diagnostic(lens) => { + ArchitectureTargetAccess::Diagnostic(lens) + | ArchitectureTargetAccess::ExactDiagnostic(lens) => { if !lens.admits(&id.namespace) || id.version == 0 || super::require_id(Some(&id), "target").is_err() @@ -430,6 +560,14 @@ impl EventModelServiceImpl { }; if let Some(stored) = next { if let Some(pb::entity::Kind::TypeLibrary(library)) = &stored.entity.kind { + if matches!( + access, + ArchitectureTargetAccess::ExactRequired(_) + | ArchitectureTargetAccess::ExactDiagnostic(_) + ) { + world.insert(key, stored); + continue; + } for dependency in &library.dependencies { if let Some(target) = &dependency.id { if target.namespace != id.namespace { @@ -445,6 +583,108 @@ impl EventModelServiceImpl { Ok(()) } + async fn validate_catalog_replacements( + &self, + proposal: &ArchitectureProposal<'_>, + branch: Option<&str>, + vis: &Visibility, + loaded: &mut HashSet, + world: &mut HashMap, + budget: &mut crate::decision_document_resources::CatalogBudget, + ) -> Result<(), Status> { + let resources: Vec<_> = proposal + .overlay + .values() + .filter_map(|entity| *entity) + .filter(|entity| is_catalog(entity)) + .collect(); + if resources.is_empty() { + return Ok(()); + } + for row in world.values() { + budget.owner(&row.entity)?; + } + for entity in &resources { + for association in crate::decision_document_resources::subjects(entity) { + if let Some(subject) = &association.subject { + if let Ok(reference) = + trogon_atlas_core::decision_document::subject_owner(subject) + { + if let (Ok(kind), Some(id)) = ( + pb::EntityKind::try_from(reference.kind), + reference.id.as_ref(), + ) { + if let Some(owner) = world.get(&pb::EntityKey::new(kind, id)) { + budget.selection(subject, &owner.entity)?; + } + } + } + } + } + let Some(pb::entity::Kind::Decision(decision)) = &entity.kind else { + continue; + }; + let Some(replacement) = &decision.replaced_by else { + continue; + }; + let mut next = replacement + .decision + .as_ref() + .and_then(|reference| reference.id.clone()); + let mut visited = HashSet::new(); + visited.insert(pb::EntityKey::new( + pb::EntityKind::Decision, + entity_id(entity)?, + )); + loop { + budget.examine(1)?; + let id = next.take().ok_or_else(|| { + Status::invalid_argument("Replacement chain is absent, stale or unavailable") + })?; + let key = pb::EntityKey::new(pb::EntityKind::Decision, &id); + if !visited.insert(key.clone()) { + return Err(Status::invalid_argument("Decision replacement cycle")); + } + self.load_architecture_targets( + proposal, + branch, + ArchitectureTargetAccess::ExactRequired(vis), + vec![(pb::EntityKind::Decision, id)], + loaded, + world, + ) + .await?; + let Some(row) = world.get(&key) else { + return Err(Status::invalid_argument( + "Replacement chain is absent, stale or unavailable", + )); + }; + budget.owner(&row.entity)?; + let Some(pb::entity::Kind::Decision(target)) = &row.entity.kind else { + return Err(Status::invalid_argument( + "Replacement target must be a Decision", + )); + }; + match pb::DecisionStatus::try_from(target.status) { + Ok(pb::DecisionStatus::Accepted) => break, + Ok(pb::DecisionStatus::Superseded) => { + next = target + .replaced_by + .as_ref() + .and_then(|value| value.decision.as_ref()) + .and_then(|reference| reference.id.clone()); + } + _ => { + return Err(Status::invalid_argument( + "Replacement chain must terminate at an ACCEPTED decision", + )) + } + } + } + } + Ok(()) + } + pub(super) async fn data_product_emission_rows( &self, branch: Option<&str>, @@ -562,6 +802,8 @@ impl EventModelServiceImpl { | pb::entity::Kind::DataProduct(_) | pb::entity::Kind::Team(_) | pb::entity::Kind::Person(_) + | pb::entity::Kind::Decision(_) + | pb::entity::Kind::Document(_) ) ) { return Ok(()); @@ -682,6 +924,32 @@ fn responsibility_endpoints( .collect() } +fn catalog_endpoints( + resources: &[&pb::Entity], + world: &HashMap, +) -> Vec<(pb::EntityKind, pb::Id)> { + resources + .iter() + .flat_map(|entity| crate::decision_document_resources::subjects(entity)) + .filter_map(|association| association.subject.as_ref()) + .flat_map(|subject| { + let converted = crate::decision_document_resources::responsibility_subject(subject); + crate::responsibility_resources::subject_owner(&converted) + .and_then(|reference| { + Some(pb::EntityKey::new( + pb::EntityKind::try_from(reference.kind).ok()?, + reference.id.as_ref()?, + )) + }) + .and_then(|key| world.get(&key)) + .map(|stored| { + crate::responsibility_resources::selected_endpoints(&converted, &stored.entity) + }) + .unwrap_or_default() + }) + .collect() +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod specification_validation_tests { diff --git a/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs index 4ba65d0b..42cffbdd 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/architecture_resources.rs @@ -119,7 +119,7 @@ impl<'a> ArchitectureContext<'a> { } } -pub(super) fn is_resource(entity: &pb::Entity) -> bool { +pub(crate) fn is_resource(entity: &pb::Entity) -> bool { matches!( entity.kind, Some( @@ -129,6 +129,8 @@ pub(super) fn is_resource(entity: &pb::Entity) -> bool { | pb::entity::Kind::DataProduct(_) | pb::entity::Kind::Team(_) | pb::entity::Kind::Person(_) + | pb::entity::Kind::Decision(_) + | pb::entity::Kind::Document(_) ) ) } @@ -310,6 +312,12 @@ pub(super) fn validate_in_context( entity: &pb::Entity, context: &ArchitectureContext<'_>, ) -> Vec { + if matches!( + entity.kind, + Some(pb::entity::Kind::Decision(_) | pb::entity::Kind::Document(_)) + ) { + return crate::decision_document_resources::validate_in_context(entity, context); + } if matches!(entity.kind, Some(pb::entity::Kind::DataProduct(_))) { return crate::data_product_resources::validate_in_context(entity, context); } diff --git a/rsworkspace/crates/trogon-atlas-server/src/auth.rs b/rsworkspace/crates/trogon-atlas-server/src/auth.rs index c796d4d1..64f76ad0 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/auth.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/auth.rs @@ -157,7 +157,13 @@ pub fn required_role(grpc_path: &str) -> Option { | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListAssigneeResponsibilities" | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListTeamMembers" | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListPersonTeams" - | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetTeamContacts" => Role::Reader, + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetTeamContacts" + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectDecisions" + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListSubjectDocuments" + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetDecisionDetail" + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/GetDocumentDetail" + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListIncomingDecisionDeclarations" + | "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/ListIncomingDocumentDeclarations" => Role::Reader, "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/BatchGetEntities" => Role::Reader, // Discovery diff --git a/rsworkspace/crates/trogon-atlas-server/src/conv.rs b/rsworkspace/crates/trogon-atlas-server/src/conv.rs index 87cb5bc2..3dbbfb2f 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/conv.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/conv.rs @@ -330,6 +330,8 @@ pub fn entity_kind(entity: &pb::Entity) -> Result { Some(K::DataProduct(_)) => Ok(pb::EntityKind::DataProduct), Some(K::Team(_)) => Ok(pb::EntityKind::Team), Some(K::Person(_)) => Ok(pb::EntityKind::Person), + Some(K::Decision(_)) => Ok(pb::EntityKind::Decision), + Some(K::Document(_)) => Ok(pb::EntityKind::Document), None => Err(Status::invalid_argument("entity.kind oneof is required")), } } @@ -372,6 +374,8 @@ pub fn entity_id(entity: &pb::Entity) -> Result<&pb::Id, Status> { Some(K::DataProduct(x)) => x.id.as_ref(), Some(K::Team(x)) => x.id.as_ref(), Some(K::Person(x)) => x.id.as_ref(), + Some(K::Decision(x)) => x.id.as_ref(), + Some(K::Document(x)) => x.id.as_ref(), None => return Err(Status::invalid_argument("entity.kind oneof is required")), }; let id = id.ok_or_else(|| Status::invalid_argument("entity.id is required"))?; @@ -477,6 +481,18 @@ pub fn validate_entity_content(entity: &pb::Entity) -> Result<(), Status> { 0, x.metadata.len(), ), + Some(K::Decision(x)) => ( + x.title.as_ref().map_or("", |v| v.value.as_str()), + x.doc.as_deref().unwrap_or_default(), + 0, + x.metadata.len(), + ), + Some(K::Document(x)) => ( + x.title.as_ref().map_or("", |v| v.value.as_str()), + x.doc.as_deref().unwrap_or_default(), + 0, + x.metadata.len(), + ), None => return Ok(()), }; diff --git a/rsworkspace/crates/trogon-atlas-server/src/decision_document_queries.rs b/rsworkspace/crates/trogon-atlas-server/src/decision_document_queries.rs new file mode 100644 index 00000000..78da2eb3 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/src/decision_document_queries.rs @@ -0,0 +1,746 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; + +use pb::CatalogResolutionState as State; +use prost::Message as _; +use sha2::{Digest, Sha256}; +use tonic::Status; + +use super::{ + entity_id, entity_kind, pb, store_err, EventModelServiceImpl, Lens, StoredEntity, Visibility, +}; +use crate::decision_document_resources::{self as resources, CatalogBudget}; + +struct CatalogQuery { + world: HashMap>, + rows: Vec, + lens: Lens, + branch: Option, + budget: CatalogBudget, + subjects: Vec, + documents: Vec, +} + +fn checked(row: &StoredEntity) -> Result { + Ok(pb::CatalogCheckedOwner { + owner: Some(crate::conv::entity_to_ref(&row.entity)?), + uid: row + .entity + .system + .as_ref() + .map_or_else(String::new, |system| system.uid.clone()), + etag: row.etag.clone(), + }) +} + +fn exact(reference: &pb::EntityRef) -> Result<(pb::EntityKind, &pb::Id), Status> { + let kind = super::kind_from_i32(reference.kind, "catalog.kind")?; + let id = super::require_id(reference.id.as_ref(), "catalog.id")?; + if id.version == 0 { + return Err(Status::invalid_argument( + "catalog selection requires an exact positive version", + )); + } + Ok((kind, id)) +} + +fn decision_ref(reference: &pb::DecisionRef) -> Result { + let result = pb::EntityRef { + kind: pb::EntityKind::Decision as i32, + id: reference.id.clone(), + }; + exact(&result)?; + Ok(result) +} + +impl CatalogQuery { + fn scope(&self) -> Result { + let namespaces: BTreeSet<_> = match &self.lens { + Lens::Everything => self.world.keys().map(|key| key.namespace.clone()).collect(), + Lens::Only(namespaces) => namespaces + .iter() + .map(|namespace| namespace.as_str().to_owned()) + .collect(), + }; + let mut rows: Vec<_> = self.world.iter().collect(); + rows.sort_by_key(|(key, _)| (key.kind as i32, &key.namespace, &key.slug, key.version)); + let mut hash = Sha256::new(); + let mut feed = |bytes: &[u8]| { + hash.update((bytes.len() as u64).to_le_bytes()); + hash.update(bytes); + }; + feed( + &serde_json::to_vec(&( + &self.branch, + &namespaces, + matches!(self.lens, Lens::Everything), + )) + .map_err(|_| Status::internal("cannot encode catalog scope"))?, + ); + let mut visited = Vec::new(); + for (key, row) in rows { + let reference = pb::EntityRef { + kind: key.kind as i32, + id: Some(key.id()), + }; + feed(&reference.encode_to_vec()); + if let Some(row) = row { + let owner = checked(row)?; + feed(&owner.encode_to_vec()); + let content = + trogon_atlas_core::entity_content_hash(&row.entity).map_err(|_| { + Status::failed_precondition("checked entity has no content identity") + })?; + feed(content.to_hex().as_bytes()); + visited.push(owner); + } else { + feed(b"absent"); + visited.push(pb::CatalogCheckedOwner { + owner: Some(reference), + ..Default::default() + }); + } + } + let mut subjects = self.subjects.clone(); + subjects.sort_by_key(prost::Message::encode_to_vec); + subjects.dedup(); + let mut documents = self.documents.clone(); + documents.sort_by_key(prost::Message::encode_to_vec); + documents.dedup(); + for subject in &subjects { + feed(&subject.encode_to_vec()); + } + for document in &documents { + feed(&document.encode_to_vec()); + } + let digest = hash + .finalize() + .iter() + .fold(String::new(), |mut text, byte| { + const HEX: &[u8; 16] = b"0123456789abcdef"; + text.push(char::from(HEX[usize::from(byte >> 4)])); + text.push(char::from(HEX[usize::from(byte & 15)])); + text + }); + Ok(pb::CatalogCheckedScope { + branch: self.branch.clone(), + authorized_namespaces: namespaces.into_iter().collect(), + revision: format!("catalog-checked-v1:{digest}"), + visited, + visited_subjects: subjects, + visited_documents: documents, + }) + } +} + +impl EventModelServiceImpl { + async fn catalog_query( + &self, + branch: Option<&str>, + vis: &Visibility, + scan: bool, + ) -> Result { + if let Some(branch) = branch { + self.require_branch(branch).await?; + } + let lens = self.lens(vis).await?; + let mut rows = if scan { + self.architecture_reference_snapshot(&[], branch, vis) + .await? + } else { + Vec::new() + }; + rows.sort_by_key(|row| { + crate::conv::entity_to_ref(&row.entity) + .map(|reference| reference.encode_to_vec()) + .unwrap_or_default() + }); + let mut world = HashMap::new(); + let mut budget = CatalogBudget::default(); + for row in &rows { + budget.owner(&row.entity)?; + world.insert( + pb::EntityKey::new(entity_kind(&row.entity)?, entity_id(&row.entity)?), + Some(row.clone()), + ); + } + Ok(CatalogQuery { + world, + rows, + lens, + branch: branch.map(str::to_owned), + budget, + subjects: Vec::new(), + documents: Vec::new(), + }) + } + + async fn catalog_read( + &self, + query: &mut CatalogQuery, + reference: &pb::EntityRef, + ) -> Result, Status> { + let (kind, id) = exact(reference)?; + if !query.lens.admits(&id.namespace) { + return Err(Status::not_found("target not found")); + } + let key = pb::EntityKey::new(kind, id); + if let Some(row) = query.world.get(&key) { + return Ok(row.clone()); + } + let row = match self.store.get(kind, id, query.branch.as_deref()).await { + Ok(row) => { + query.budget.owner(&row.entity)?; + Some(row) + } + Err(trogon_atlas_store::error::StoreError::NotFound) => None, + Err(error) => return Err(store_err(error)), + }; + query.world.insert(key, row.clone()); + Ok(row) + } + + async fn catalog_subject_state( + &self, + query: &mut CatalogQuery, + subject: &pb::CatalogSubject, + ) -> Result { + query.subjects.push(subject.clone()); + if trogon_atlas_core::decision_document::validate_subject(subject).is_err() { + return Ok(State::Unresolved); + } + let converted = resources::responsibility_subject(subject); + let Some(reference) = crate::responsibility_resources::subject_owner(&converted) else { + return Ok(State::Unresolved); + }; + let (_, id) = exact(&reference)?; + if !query.lens.admits(&id.namespace) { + return Ok(State::Unavailable); + } + let Some(row) = self.catalog_read(query, &reference).await? else { + return Ok(State::Unavailable); + }; + query.budget.selection(subject, &row.entity)?; + if trogon_atlas_core::decision_document::resolve_subject(subject, &row.entity).is_err() { + return Ok(State::Unresolved); + } + for (kind, id) in + crate::responsibility_resources::selected_endpoints(&converted, &row.entity) + { + if !query.lens.admits(&id.namespace) { + return Ok(State::Unavailable); + } + if self + .catalog_read( + query, + &pb::EntityRef { + kind: kind as i32, + id: Some(id), + }, + ) + .await? + .is_none() + { + return Ok(State::Unavailable); + } + } + Ok(State::Resolved) + } + + async fn catalog_document_state( + &self, + query: &mut CatalogQuery, + selection: &pb::DocumentSelection, + ) -> Result { + query.documents.push(selection.clone()); + if trogon_atlas_core::decision_document::validate_document_selection(selection).is_err() { + return Ok(State::Unresolved); + } + let reference = trogon_atlas_core::decision_document::document_owner(selection) + .map_err(|_| Status::invalid_argument("invalid Document selection"))?; + let (_, id) = exact(&reference)?; + if !query.lens.admits(&id.namespace) { + return Ok(State::Unavailable); + } + let Some(row) = self.catalog_read(query, &reference).await? else { + return Ok(State::Unavailable); + }; + Ok( + if trogon_atlas_core::decision_document::resolve_document_selection( + selection, + &row.entity, + ) + .is_ok() + { + State::Resolved + } else { + State::Unresolved + }, + ) + } + + async fn catalog_decision_state( + &self, + query: &mut CatalogQuery, + selection: &pb::DecisionRef, + replacement: bool, + ) -> Result { + let reference = decision_ref(selection)?; + let (_, id) = exact(&reference)?; + if !query.lens.admits(&id.namespace) { + return Ok(State::Unavailable); + } + let Some(row) = self.catalog_read(query, &reference).await? else { + return Ok(State::Unavailable); + }; + let Some(pb::entity::Kind::Decision(value)) = &row.entity.kind else { + return Ok(State::Unresolved); + }; + Ok( + if !replacement + || matches!( + pb::DecisionStatus::try_from(value.status), + Ok(pb::DecisionStatus::Accepted | pb::DecisionStatus::Superseded) + ) + { + State::Resolved + } else { + State::Unresolved + }, + ) + } + + async fn catalog_declarations( + &self, + query: &mut CatalogQuery, + row: &StoredEntity, + ) -> Result, Status> { + use pb::catalog_declaration::Declaration as D; + let owner = checked(row)?; + let mut declarations = Vec::new(); + for (index, association) in resources::subjects(&row.entity).iter().enumerate() { + let state = if let Some(subject) = &association.subject { + self.catalog_subject_state(query, subject).await? + } else { + State::Unresolved + }; + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(owner.clone()), + field: format!("subjects[{index}].subject"), + state: state as i32, + declaration: Some(D::Subject(association.clone())), + }); + } + match &row.entity.kind { + Some(pb::entity::Kind::Decision(value)) => { + if let Some(replacement) = &value.replaced_by { + let state = if let Some(reference) = &replacement.decision { + self.catalog_decision_state(query, reference, true).await? + } else { + State::Unresolved + }; + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(owner.clone()), + field: "replaced_by.decision".into(), + state: state as i32, + declaration: Some(D::Replacement(replacement.clone())), + }); + } + for (index, related) in value.related_decisions.iter().enumerate() { + let state = if let Some(reference) = &related.decision { + self.catalog_decision_state(query, reference, false).await? + } else { + State::Unresolved + }; + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(owner.clone()), + field: format!("related_decisions[{index}].decision"), + state: state as i32, + declaration: Some(D::Related(related.clone())), + }); + } + for (index, supporting) in value.documents.iter().enumerate() { + let state = if let Some(selection) = &supporting.document { + self.catalog_document_state(query, selection).await? + } else { + State::Unresolved + }; + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(owner.clone()), + field: format!("documents[{index}].document"), + state: state as i32, + declaration: Some(D::Supporting(supporting.clone())), + }); + } + } + Some(pb::entity::Kind::Document(value)) => { + for (index, navigation) in value.navigation.iter().enumerate() { + let state = if let Some(selection) = &navigation.document { + self.catalog_document_state(query, selection).await? + } else { + State::Unresolved + }; + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(owner.clone()), + field: format!("navigation[{index}].document"), + state: state as i32, + declaration: Some(D::Navigation(navigation.clone())), + }); + } + } + _ => {} + } + Ok(declarations) + } + + async fn catalog_subject_matches( + &self, + subject: &pb::CatalogSubject, + status: Option, + decisions: bool, + branch: Option<&str>, + vis: &Visibility, + ) -> Result<(Vec, pb::CatalogCheckedScope, State), Status> { + trogon_atlas_core::decision_document::validate_subject(subject) + .map_err(|_| Status::invalid_argument("invalid CatalogSubject"))?; + let reference = trogon_atlas_core::decision_document::subject_owner(subject) + .map_err(|_| Status::invalid_argument("invalid CatalogSubject"))?; + let (_, id) = exact(&reference)?; + self.require_visible(vis, &id.namespace).await?; + if status.is_some_and(|status| { + !matches!( + pb::DecisionStatus::try_from(status), + Ok(pb::DecisionStatus::Proposed + | pb::DecisionStatus::Accepted + | pb::DecisionStatus::Rejected + | pb::DecisionStatus::Withdrawn + | pb::DecisionStatus::Superseded) + ) + }) { + return Err(Status::invalid_argument("invalid Decision status filter")); + } + let mut query = self.catalog_query(branch, vis, true).await?; + let state = self.catalog_subject_state(&mut query, subject).await?; + let mut declarations = Vec::new(); + for row in query.rows.clone() { + let selected = match &row.entity.kind { + Some(pb::entity::Kind::Decision(value)) if decisions => { + status.is_none_or(|status| value.status == status) + } + Some(pb::entity::Kind::Document(_)) => !decisions, + _ => false, + }; + if !selected { + continue; + } + for (index, association) in resources::subjects(&row.entity).iter().enumerate() { + if association.subject.as_ref() != Some(subject) { + continue; + } + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(checked(&row)?), + field: format!("subjects[{index}].subject"), + state: state as i32, + declaration: Some(pb::catalog_declaration::Declaration::Subject( + association.clone(), + )), + }); + } + } + Ok((declarations, query.scope()?, state)) + } + + pub(super) async fn subject_decisions( + &self, + req: pb::ListSubjectDecisionsRequest, + branch: Option<&str>, + vis: &Visibility, + ) -> Result { + let subject = req + .subject + .ok_or_else(|| Status::invalid_argument("subject is required"))?; + let (declarations, scope, state) = self + .catalog_subject_matches(&subject, req.status, true, branch, vis) + .await?; + Ok(pb::ListSubjectDecisionsResponse { + declarations, + scope: Some(scope), + subject_state: state as i32, + incomplete: false, + failures: Vec::new(), + }) + } + + pub(super) async fn subject_documents( + &self, + req: pb::ListSubjectDocumentsRequest, + branch: Option<&str>, + vis: &Visibility, + ) -> Result { + let subject = req + .subject + .ok_or_else(|| Status::invalid_argument("subject is required"))?; + let (declarations, scope, state) = self + .catalog_subject_matches(&subject, None, false, branch, vis) + .await?; + Ok(pb::ListSubjectDocumentsResponse { + declarations, + scope: Some(scope), + subject_state: state as i32, + incomplete: false, + failures: Vec::new(), + }) + } + + pub(super) async fn decision_detail( + &self, + req: pb::GetDecisionDetailRequest, + branch: Option<&str>, + vis: &Visibility, + ) -> Result { + let reference = decision_ref( + &req.decision + .ok_or_else(|| Status::invalid_argument("decision is required"))?, + )?; + let mut query = self.catalog_query(branch, vis, false).await?; + let row = self + .catalog_read(&mut query, &reference) + .await? + .ok_or_else(|| Status::not_found("Decision not found"))?; + let declarations = self.catalog_declarations(&mut query, &row).await?; + let mut replacement_chain = Vec::new(); + let mut failures = Vec::new(); + let mut incomplete = false; + if req.follow_replacement { + let mut current = row.clone(); + let mut visited = HashSet::new(); + loop { + query.budget.examine(1)?; + let key = + pb::EntityKey::new(entity_kind(¤t.entity)?, entity_id(¤t.entity)?); + if !visited.insert(key) { + incomplete = true; + break; + } + query.budget.output()?; + replacement_chain.push(checked(¤t)?); + let Some(pb::entity::Kind::Decision(value)) = ¤t.entity.kind else { + incomplete = true; + break; + }; + if value.status == pb::DecisionStatus::Accepted as i32 { + break; + } + if value.status != pb::DecisionStatus::Superseded as i32 { + incomplete = value.replaced_by.is_some() || replacement_chain.len() > 1; + break; + } + let Some(next) = value + .replaced_by + .as_ref() + .and_then(|replacement| replacement.decision.as_ref()) + else { + incomplete = true; + break; + }; + let next = decision_ref(next)?; + let (_, id) = exact(&next)?; + if !query.lens.admits(&id.namespace) { + incomplete = true; + break; + } + let Some(next) = self.catalog_read(&mut query, &next).await? else { + incomplete = true; + break; + }; + current = next; + } + if incomplete { + failures.push(pb::CatalogQueryFailure { + field: "replaced_by".into(), + code: tonic::Code::FailedPrecondition as i32, + message: "Replacement chain does not establish an available ACCEPTED terminus" + .into(), + ..Default::default() + }); + } + } + Ok(pb::GetDecisionDetailResponse { + entity: Some(row.entity), + etag: row.etag, + declarations, + scope: Some(query.scope()?), + replacement_chain, + incomplete, + failures, + }) + } + + pub(super) async fn document_detail( + &self, + req: pb::GetDocumentDetailRequest, + branch: Option<&str>, + vis: &Visibility, + ) -> Result { + let selection = req + .document + .ok_or_else(|| Status::invalid_argument("document is required"))?; + trogon_atlas_core::decision_document::validate_document_selection(&selection) + .map_err(|_| Status::invalid_argument("invalid Document selection"))?; + let reference = trogon_atlas_core::decision_document::document_owner(&selection) + .map_err(|_| Status::invalid_argument("invalid Document selection"))?; + let mut query = self.catalog_query(branch, vis, false).await?; + let row = self + .catalog_read(&mut query, &reference) + .await? + .ok_or_else(|| Status::not_found("Document not found"))?; + let state = self.catalog_document_state(&mut query, &selection).await?; + let anchor = trogon_atlas_core::decision_document::resolve_document_selection( + &selection, + &row.entity, + ) + .ok() + .flatten() + .cloned(); + let declarations = self.catalog_declarations(&mut query, &row).await?; + Ok(pb::GetDocumentDetailResponse { + entity: Some(row.entity), + etag: row.etag, + anchor, + declarations, + scope: Some(query.scope()?), + selection_state: state as i32, + incomplete: false, + failures: Vec::new(), + }) + } + + pub(super) async fn incoming_decisions( + &self, + req: pb::ListIncomingDecisionDeclarationsRequest, + branch: Option<&str>, + vis: &Visibility, + ) -> Result { + let selection = req + .decision + .ok_or_else(|| Status::invalid_argument("decision is required"))?; + let reference = decision_ref(&selection)?; + self.require_visible(vis, &exact(&reference)?.1.namespace) + .await?; + let mut query = self.catalog_query(branch, vis, true).await?; + let state = self + .catalog_decision_state(&mut query, &selection, false) + .await?; + let mut declarations = Vec::new(); + for row in query.rows.clone() { + let Some(pb::entity::Kind::Decision(value)) = &row.entity.kind else { + continue; + }; + if let Some(replacement) = value + .replaced_by + .as_ref() + .filter(|replacement| replacement.decision.as_ref() == Some(&selection)) + { + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(checked(&row)?), + field: "replaced_by.decision".into(), + state: self + .catalog_decision_state(&mut query, &selection, true) + .await? as i32, + declaration: Some(pb::catalog_declaration::Declaration::Replacement( + replacement.clone(), + )), + }); + } + for (index, related) in value.related_decisions.iter().enumerate() { + if related.decision.as_ref() != Some(&selection) { + continue; + } + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(checked(&row)?), + field: format!("related_decisions[{index}].decision"), + state: state as i32, + declaration: Some(pb::catalog_declaration::Declaration::Related( + related.clone(), + )), + }); + } + } + Ok(pb::ListIncomingDecisionDeclarationsResponse { + declarations, + scope: Some(query.scope()?), + incomplete: false, + failures: Vec::new(), + }) + } + + pub(super) async fn incoming_documents( + &self, + req: pb::ListIncomingDocumentDeclarationsRequest, + branch: Option<&str>, + vis: &Visibility, + ) -> Result { + let selection = req + .document + .ok_or_else(|| Status::invalid_argument("document is required"))?; + trogon_atlas_core::decision_document::validate_document_selection(&selection) + .map_err(|_| Status::invalid_argument("invalid Document selection"))?; + let reference = trogon_atlas_core::decision_document::document_owner(&selection) + .map_err(|_| Status::invalid_argument("invalid Document selection"))?; + self.require_visible(vis, &exact(&reference)?.1.namespace) + .await?; + let mut query = self.catalog_query(branch, vis, true).await?; + let state = self.catalog_document_state(&mut query, &selection).await?; + let mut declarations = Vec::new(); + for row in query.rows.clone() { + match &row.entity.kind { + Some(pb::entity::Kind::Decision(value)) => { + for (index, supporting) in value.documents.iter().enumerate() { + if supporting.document.as_ref() != Some(&selection) { + continue; + } + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(checked(&row)?), + field: format!("documents[{index}].document"), + state: state as i32, + declaration: Some(pb::catalog_declaration::Declaration::Supporting( + supporting.clone(), + )), + }); + } + } + Some(pb::entity::Kind::Document(value)) => { + for (index, navigation) in value.navigation.iter().enumerate() { + if navigation.document.as_ref() != Some(&selection) { + continue; + } + query.budget.output()?; + declarations.push(pb::CatalogDeclaration { + owner: Some(checked(&row)?), + field: format!("navigation[{index}].document"), + state: state as i32, + declaration: Some(pb::catalog_declaration::Declaration::Navigation( + navigation.clone(), + )), + }); + } + } + _ => {} + } + } + Ok(pb::ListIncomingDocumentDeclarationsResponse { + declarations, + scope: Some(query.scope()?), + incomplete: false, + failures: Vec::new(), + }) + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs b/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs new file mode 100644 index 00000000..91cd3d8f --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/src/decision_document_resources.rs @@ -0,0 +1,418 @@ +use std::collections::HashSet; + +use tonic::Status; +use trogon_atlas_proto as pb; + +use crate::validation::architecture_resources::ArchitectureContext; + +pub(crate) fn subjects(entity: &pb::Entity) -> &[pb::SubjectAssociation] { + match &entity.kind { + Some(pb::entity::Kind::Decision(value)) => &value.subjects, + Some(pb::entity::Kind::Document(value)) => &value.subjects, + _ => &[], + } +} + +pub(crate) fn documents(entity: &pb::Entity) -> Vec<&pb::DocumentSelection> { + match &entity.kind { + Some(pb::entity::Kind::Decision(value)) => value + .documents + .iter() + .filter_map(|v| v.document.as_ref()) + .collect(), + Some(pb::entity::Kind::Document(value)) => value + .navigation + .iter() + .filter_map(|v| v.document.as_ref()) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn responsibility_subject(subject: &pb::CatalogSubject) -> pb::ResponsibilitySubject { + trogon_atlas_core::decision_document::as_responsibility_subject(subject) +} + +pub(crate) fn occurrence_count(entity: &pb::Entity) -> usize { + match &entity.kind { + Some(pb::entity::Kind::Decision(value)) => { + value.subjects.len() + + value.related_decisions.len() + + value.documents.len() + + usize::from(value.replaced_by.is_some()) + } + Some(pb::entity::Kind::Document(value)) => { + value.subjects.len() + value.navigation.len() + value.anchors.len() + } + _ => crate::responsibility_resources::occurrence_count(entity), + } +} + +#[derive(Default)] +pub(crate) struct CatalogBudget { + rows: HashSet, + inventoried: HashSet, + selections: HashSet<(pb::EntityKey, u8)>, + examined: usize, + output: usize, +} + +impl CatalogBudget { + pub(crate) fn examine(&mut self, count: usize) -> Result<(), Status> { + self.examined = self.examined.saturating_add(count); + if self.examined > 10_000 { + return Err(Status::resource_exhausted("catalog work budget exceeded")); + } + Ok(()) + } + + pub(crate) fn owner(&mut self, entity: &pb::Entity) -> Result<(), Status> { + let key = pb::EntityKey::new( + crate::conv::entity_kind(entity)?, + crate::conv::entity_id(entity)?, + ); + if crate::validation::architecture_resources::is_resource(entity) + && self.rows.insert(key.clone()) + && self.rows.len() > 5_000 + { + return Err(Status::resource_exhausted( + "catalog resource budget exceeded", + )); + } + if self.inventoried.insert(key) { + self.examine(occurrence_count(entity))?; + } + Ok(()) + } + + pub(crate) fn output(&mut self) -> Result<(), Status> { + self.output += 1; + if self.output > 4_096 { + return Err(Status::resource_exhausted("catalog output budget exceeded")); + } + Ok(()) + } + + pub(crate) fn selection( + &mut self, + subject: &pb::CatalogSubject, + owner: &pb::Entity, + ) -> Result<(), Status> { + let key = pb::EntityKey::new( + crate::conv::entity_kind(owner)?, + crate::conv::entity_id(owner)?, + ); + let kind = match &subject.selection { + Some(pb::catalog_subject::Selection::Connection(_)) => 1, + Some(pb::catalog_subject::Selection::Member(_)) => 2, + Some(pb::catalog_subject::Selection::Port(_)) => 3, + _ => 0, + }; + if !self.selections.insert((key, kind)) { + return Ok(()); + } + let count = match kind { + 1 => trogon_atlas_core::connections::connection_occurrences(owner) + .map_err(|_| Status::internal("cannot inspect native connection inventory"))? + .len(), + 2 => match &owner.kind { + Some(pb::entity::Kind::Specification(value)) => { + value.artifacts.len() + + value.schemas.len() + + value.messages.len() + + value.operations.len() + } + _ => 0, + }, + 3 => match &owner.kind { + Some(pb::entity::Kind::DataProduct(value)) => { + value.inputs.len() + value.outputs.len() + } + _ => 0, + }, + _ => 0, + }; + self.examine(count) + } +} + +fn replacement_resolves(decision: &pb::Decision, context: &ArchitectureContext<'_>) -> bool { + let Some(replacement) = &decision.replaced_by else { + return true; + }; + let mut next = replacement + .decision + .as_ref() + .and_then(|reference| reference.id.as_ref()); + let mut visited = HashSet::new(); + if let Some(id) = &decision.id { + visited.insert(id); + } + for _ in 0..10_000 { + let Some(id) = next else { return false }; + if id.version == 0 || !visited.insert(id) { + return false; + } + let Some(pb::Entity { + kind: Some(pb::entity::Kind::Decision(target)), + .. + }) = context.find(pb::EntityKind::Decision, id) + else { + return false; + }; + match pb::DecisionStatus::try_from(target.status) { + Ok(pb::DecisionStatus::Accepted) => return true, + Ok(pb::DecisionStatus::Superseded) => { + next = target + .replaced_by + .as_ref() + .and_then(|replacement| replacement.decision.as_ref()) + .and_then(|reference| reference.id.as_ref()); + } + _ => return false, + } + } + false +} + +fn has_dedicated_check(field: &str) -> bool { + matches!(field, "replaced_by.decision" | "supersedes") + || ["subjects[", "documents[", "navigation["] + .iter() + .any(|prefix| field.starts_with(prefix)) +} + +pub(crate) fn validate_in_context( + entity: &pb::Entity, + context: &ArchitectureContext<'_>, +) -> Vec { + let mut issues = Vec::new(); + let mut issue = |field: &str, message: &str| { + issues.push(crate::validation::issue_error( + "CATALOG_REFERENCE_UNRESOLVED", + message.into(), + crate::conv::entity_to_ref(entity).ok(), + field, + )); + }; + if let Err(error) = trogon_atlas_core::decision_document::validate_values(entity) { + issue("catalog", &error.to_string()); + } + if let Some(pb::entity::Kind::Decision(decision)) = &entity.kind { + if !replacement_resolves(decision, context) { + issue( + "replaced_by.decision", + "Replacement chain is absent, stale or unavailable", + ); + } + } + for reference in trogon_atlas_core::refs::outbound_refs(entity) { + if has_dedicated_check(&reference.from_field) { + continue; + } + if !reference.to_kind.is_some_and(|kind| { + reference.to_id.version > 0 && context.find(kind, &reference.to_id).is_some() + }) { + issue( + &reference.from_field, + "Catalog declaration is absent, stale or unavailable", + ); + } + } + for (index, association) in subjects(entity).iter().enumerate() { + let resolves = association.subject.as_ref().is_some_and(|subject| { + let converted = responsibility_subject(subject); + crate::responsibility_resources::subject_owner(&converted) + .and_then(|reference| { + context.find( + pb::EntityKind::try_from(reference.kind).ok()?, + reference.id.as_ref()?, + ) + }) + .is_some_and(|owner| { + trogon_atlas_core::decision_document::resolve_subject(subject, owner).is_ok() + && crate::responsibility_resources::selected_endpoints(&converted, owner) + .into_iter() + .all(|(kind, id)| context.find(kind, &id).is_some()) + }) + }); + if !resolves { + issue( + &format!("subjects[{index}].subject"), + "Catalog subject is absent, stale or unavailable", + ); + } + } + let (field, selections): (_, Vec<_>) = match &entity.kind { + Some(pb::entity::Kind::Decision(value)) => ( + "documents", + value + .documents + .iter() + .map(|association| &association.document) + .collect(), + ), + Some(pb::entity::Kind::Document(value)) => ( + "navigation", + value + .navigation + .iter() + .map(|association| &association.document) + .collect(), + ), + _ => ("", Vec::new()), + }; + for (index, selection) in selections.into_iter().enumerate() { + let Some(selection) = selection else { continue }; + + let resolves = trogon_atlas_core::decision_document::document_owner(selection) + .ok() + .and_then(|reference| context.find(pb::EntityKind::Document, reference.id.as_ref()?)) + .is_some_and(|owner| { + trogon_atlas_core::decision_document::resolve_document_selection(selection, owner) + .is_ok() + }); + if !resolves { + issue( + &format!("{field}[{index}].document"), + "Document selection is absent, stale or unavailable", + ); + } + } + let (kind, current, prior) = match &entity.kind { + Some(pb::entity::Kind::Decision(value)) => ( + pb::EntityKind::Decision, + value.id.as_ref(), + value.supersedes.as_ref(), + ), + Some(pb::entity::Kind::Document(value)) => ( + pb::EntityKind::Document, + value.id.as_ref(), + value.supersedes.as_ref(), + ), + _ => return issues, + }; + if let Some(prior) = prior { + if !current.is_some_and(|current| { + current.namespace == prior.namespace + && current.slug == prior.slug + && prior.version > 0 + && prior.version < current.version + && context.find(kind, prior).is_some() + }) { + issue( + "supersedes", + "Version ancestry must select an existing exact earlier version in the same family", + ); + } + } + crate::validation::enrich_issues_with_catalog(&mut issues); + issues +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_supersedes_target_is_reported_once() { + let id = |version| pb::Id { + namespace: "catalog".into(), + slug: "choice".into(), + version, + }; + for supersedes in [id(1), id(0)] { + let entity = pb::Entity { + kind: Some(pb::entity::Kind::Decision(pb::Decision { + id: Some(id(2)), + supersedes: Some(supersedes), + ..Default::default() + })), + ..Default::default() + }; + let issues = validate_in_context(&entity, &ArchitectureContext::new([])); + let ancestry: Vec<_> = issues + .iter() + .filter(|issue| issue.subject_field == "supersedes") + .collect(); + assert_eq!(ancestry.len(), 1, "{issues:?}"); + } + } + + #[test] + fn broken_associations_are_reported_once_each() { + let missing = |slug: &str| pb::Id { + namespace: "catalog".into(), + slug: slug.into(), + version: 1, + }; + let selection = || pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Document( + pb::DocumentRef { + id: Some(missing("note")), + }, + )), + }; + let subjects = vec![pb::SubjectAssociation { + subject: Some(pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(pb::EntityRef { + kind: pb::EntityKind::ReadModel as i32, + id: Some(missing("receipt")), + })), + }), + ..Default::default() + }]; + let decision = pb::Entity { + kind: Some(pb::entity::Kind::Decision(pb::Decision { + id: Some(missing("choice")), + subjects: subjects.clone(), + related_decisions: vec![pb::RelatedDecision { + decision: Some(pb::DecisionRef { + id: Some(missing("other")), + }), + ..Default::default() + }], + documents: vec![pb::SupportingDocument { + document: Some(selection()), + ..Default::default() + }], + ..Default::default() + })), + ..Default::default() + }; + let document = pb::Entity { + kind: Some(pb::entity::Kind::Document(pb::Document { + id: Some(missing("guide")), + subjects, + navigation: vec![pb::DocumentNavigation { + document: Some(selection()), + ..Default::default() + }], + ..Default::default() + })), + ..Default::default() + }; + for (entity, expected) in [ + ( + decision, + vec![ + "related_decisions[0].decision", + "subjects[0].subject", + "documents[0].document", + ], + ), + ( + document, + vec!["subjects[0].subject", "navigation[0].document"], + ), + ] { + let issues = validate_in_context(&entity, &ArchitectureContext::new([])); + let fields: Vec<_> = issues + .iter() + .map(|issue| issue.subject_field.as_str()) + .filter(|field| *field != "catalog") + .collect(); + assert_eq!(fields, expected, "{issues:?}"); + } + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/src/diff.rs b/rsworkspace/crates/trogon-atlas-server/src/diff.rs index 3fee291a..f4da2e9c 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/diff.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/diff.rs @@ -58,6 +58,8 @@ fn kind_name(e: &pb::Entity) -> &'static str { Some(K::DataProduct(_)) => "DataProduct", Some(K::Team(_)) => "Team", Some(K::Person(_)) => "Person", + Some(K::Decision(_)) => "Decision", + Some(K::Document(_)) => "Document", None => "", } } @@ -638,6 +640,49 @@ fn diff_common(a: &pb::Entity, b: &pb::Entity, ops: &mut Vec) { ); resource_metadata(ops, &x.metadata, &y.metadata); } + (Some(K::Decision(x)), Some(K::Decision(y))) => { + resource_fields( + ops, + "Decision", + x, + y, + &[ + ("title", "title"), + ("doc", "doc"), + ("supersedes", "supersedes"), + ("subjects", "subjects"), + ("status", "status"), + ("context", "context"), + ("rationale", "rationale"), + ("options", "options"), + ("selected_option", "selectedOption"), + ("consequences", "consequences"), + ("replaced_by", "replacedBy"), + ("related_decisions", "relatedDecisions"), + ("documents", "documents"), + ], + ); + resource_metadata(ops, &x.metadata, &y.metadata); + } + (Some(K::Document(x)), Some(K::Document(y))) => { + resource_fields( + ops, + "Document", + x, + y, + &[ + ("title", "title"), + ("doc", "doc"), + ("supersedes", "supersedes"), + ("subjects", "subjects"), + ("retained", "retained"), + ("link", "link"), + ("anchors", "anchors"), + ("navigation", "navigation"), + ], + ); + resource_metadata(ops, &x.metadata, &y.metadata); + } (Some(K::DataProduct(x)), Some(K::DataProduct(y))) => { resource_fields( ops, diff --git a/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs b/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs index fcbb23c3..79ab985c 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/git_mirror.rs @@ -406,6 +406,8 @@ fn kind_dir(kind: pb::EntityKind) -> &'static str { pb::EntityKind::DataProduct => "data_products", pb::EntityKind::Team => "teams", pb::EntityKind::Person => "persons", + pb::EntityKind::Decision => "decisions", + pb::EntityKind::Document => "documents", pb::EntityKind::Unspecified => "unspecified", } } diff --git a/rsworkspace/crates/trogon-atlas-server/src/graph.rs b/rsworkspace/crates/trogon-atlas-server/src/graph.rs index 0980b49c..f9957d7c 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/graph.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/graph.rs @@ -65,6 +65,8 @@ impl ReverseIndex { let key = pb::EntityKey::new(to_kind, &o.to_id); let (responsibility, membership) = responsibility_provenance(&se.entity, &o.from_field); + let (catalog_subject, document_selection, connection_id) = + catalog_provenance(&se.entity, &o.from_field); by_target.entry(key).or_default().push(pb::Reference { from: Some(from_ref.clone()), from_field: o.from_field.clone(), @@ -75,6 +77,9 @@ impl ReverseIndex { responsibility, membership, from_uid: entity_uid(&se.entity), + catalog_subject, + document_selection, + connection_id, }); } } @@ -110,13 +115,18 @@ fn responsibility_occurrence_count(all: &[StoredEntity]) -> usize { let entity = &stored.entity; if !matches!( entity.kind, - Some(pb::entity::Kind::Team(_) | pb::entity::Kind::Person(_)) + Some( + pb::entity::Kind::Team(_) + | pb::entity::Kind::Person(_) + | pb::entity::Kind::Decision(_) + | pb::entity::Kind::Document(_) + ) ) { return None; } let key = pb::EntityKey::new(ref_entity_kind(entity)?, ref_entity_id(entity)?); seen.insert(key) - .then(|| crate::responsibility_resources::occurrence_count(entity)) + .then(|| crate::decision_document_resources::occurrence_count(entity)) }) .fold(0, usize::saturating_add) } @@ -336,6 +346,8 @@ pub fn outgoing_of_resolved( for o in outbound_refs(entity) { for to_kind in resolve_to_kinds(o.to_kind, &o.to_id, known) { let (responsibility, membership) = responsibility_provenance(entity, &o.from_field); + let (catalog_subject, document_selection, connection_id) = + catalog_provenance(entity, &o.from_field); out.push(pb::Reference { from: Some(from_ref.clone()), from_field: o.from_field.clone(), @@ -346,6 +358,9 @@ pub fn outgoing_of_resolved( responsibility, membership, from_uid: entity_uid(entity), + catalog_subject, + document_selection, + connection_id, }); } } @@ -541,7 +556,7 @@ pub fn closure( continue; }; responsibility_occurrences = responsibility_occurrences - .saturating_add(crate::responsibility_resources::occurrence_count(entity)); + .saturating_add(crate::decision_document_resources::occurrence_count(entity)); if check_responsibility_occurrence_count(responsibility_occurrences).is_err() { truncated = true; break; @@ -791,6 +806,8 @@ fn supersedes_of(entity: &pb::Entity) -> Option<&pb::Id> { K::DataProduct(x) => x.supersedes.as_ref(), K::Team(x) => x.supersedes.as_ref(), K::Person(x) => x.supersedes.as_ref(), + K::Decision(x) => x.supersedes.as_ref(), + K::Document(x) => x.supersedes.as_ref(), } } @@ -931,6 +948,26 @@ fn responsibility_provenance( (assignment, membership) } +fn catalog_provenance( + entity: &pb::Entity, + field: &str, +) -> ( + Option, + Option, + Option, +) { + let mut reference = pb::Reference { + from_field: field.to_owned(), + ..Default::default() + }; + trogon_atlas_core::decision_document::decorate_reference(entity, &mut reference); + ( + reference.catalog_subject, + reference.document_selection, + reference.connection_id, + ) +} + #[cfg(test)] mod impact_tests { use trogon_atlas_store::store::StoredEntity; diff --git a/rsworkspace/crates/trogon-atlas-server/src/lib.rs b/rsworkspace/crates/trogon-atlas-server/src/lib.rs index ada4cb04..5c1a51cc 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/lib.rs @@ -5,6 +5,7 @@ pub mod batch_recovery; pub mod change_cursor; pub mod conv; mod data_product_resources; +mod decision_document_resources; pub mod diff; pub mod fixtures; pub mod git_mirror; diff --git a/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs b/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs index 95da6191..c61f10ed 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/llm_analysis.rs @@ -294,6 +294,8 @@ fn kind_label(k: pb::EntityKind) -> &'static str { pb::EntityKind::DataProduct => "data_product", pb::EntityKind::Team => "team", pb::EntityKind::Person => "person", + pb::EntityKind::Decision => "decision", + pb::EntityKind::Document => "document", pb::EntityKind::Unspecified => "unspecified", } } diff --git a/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs b/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs index fd2792c1..a41b7b99 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/responsibility_queries.rs @@ -8,9 +8,11 @@ use tonic::Status; use super::{ entity_id, entity_kind, pb, store_err, EventModelServiceImpl, Lens, StoredEntity, Visibility, }; -use crate::responsibility_resources::{ - assignments, occurrence_count as occurrences, selected_endpoints, subject_owner, - MAX_RESPONSIBILITY_OCCURRENCES, +use crate::{ + decision_document_resources::occurrence_count as occurrences, + responsibility_resources::{ + assignments, selected_endpoints, subject_owner, MAX_RESPONSIBILITY_OCCURRENCES, + }, }; type World = HashMap>; @@ -195,7 +197,7 @@ impl EventModelServiceImpl { if !seen.insert(pb::EntityKey::new(kind, id)) { continue; } - if seen.len() > super::MAX_PROJECTION_ENTITIES { + if seen.len() > 4_096 { incomplete = true; break; } diff --git a/rsworkspace/crates/trogon-atlas-server/src/search.rs b/rsworkspace/crates/trogon-atlas-server/src/search.rs index f32ff746..5d88fc34 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/search.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/search.rs @@ -426,6 +426,8 @@ fn kind_label(entity: &pb::Entity) -> String { Some(K::DataProduct(_)) => "data_product", Some(K::Team(_)) => "team", Some(K::Person(_)) => "person", + Some(K::Decision(_)) => "decision", + Some(K::Document(_)) => "document", None => "", } .to_string() @@ -469,6 +471,8 @@ pub fn kind_label_for(kind: pb::EntityKind) -> &'static str { pb::EntityKind::DataProduct => "data_product", pb::EntityKind::Team => "team", pb::EntityKind::Person => "person", + pb::EntityKind::Decision => "decision", + pb::EntityKind::Document => "document", pb::EntityKind::Unspecified => "", } } @@ -523,6 +527,14 @@ fn title_of(entity: &pb::Entity) -> String { .title .as_ref() .map_or_else(String::new, |value| value.value.clone()), + Some(K::Decision(x)) => x + .title + .as_ref() + .map_or_else(String::new, |v| v.value.clone()), + Some(K::Document(x)) => x + .title + .as_ref() + .map_or_else(String::new, |v| v.value.clone()), None => String::new(), } } @@ -599,6 +611,41 @@ fn body_of(entity: &pb::Entity) -> String { push(&mut s, &x.target); } Some(K::Channel(x)) => push(&mut s, &x.doc), + Some(K::Decision(x)) => { + push(&mut s, x.doc.as_deref().unwrap_or_default()); + if let Some(value) = &x.context { + push(&mut s, &value.value); + } + if let Some(value) = &x.rationale { + push(&mut s, &value.value); + } + for option in &x.options { + if let Some(title) = &option.title { + push(&mut s, &title.value); + } + if let Some(description) = &option.description { + push(&mut s, &description.value); + } + } + for consequence in &x.consequences { + if let Some(text) = &consequence.text { + push(&mut s, &text.value); + } + } + } + Some(K::Document(x)) => { + push(&mut s, x.doc.as_deref().unwrap_or_default()); + match &x.body { + Some(pb::document::Body::Retained(body)) => push(&mut s, &body.content), + Some(pb::document::Body::Link(link)) => push(&mut s, &link.value), + None => {} + } + for anchor in &x.anchors { + if let Some(label) = &anchor.label { + push(&mut s, &label.value); + } + } + } Some(K::Team(x)) => { push(&mut s, x.doc.as_deref().unwrap_or_default()); push(&mut s, x.purpose.as_deref().unwrap_or_default()); diff --git a/rsworkspace/crates/trogon-atlas-server/src/service.rs b/rsworkspace/crates/trogon-atlas-server/src/service.rs index 017434d5..9116d090 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/service.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/service.rs @@ -4,6 +4,8 @@ mod architecture_admission; mod data_product_events; #[path = "data_product_inspection.rs"] mod data_product_inspection; +#[path = "decision_document_queries.rs"] +mod decision_document_queries; #[path = "responsibility_queries.rs"] mod responsibility_queries; @@ -56,6 +58,44 @@ enum ReferenceIndexPurpose { CompleteReferrers, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DeleteReportCoverage { + Complete, + SnapshotTruncated, + VisibleResourceBudget, +} + +struct DeleteReportingIndex { + index: Arc, + coverage: DeleteReportCoverage, + emitted: usize, +} + +impl DeleteReportingIndex { + fn incomplete(&self) -> bool { + self.coverage != DeleteReportCoverage::Complete + } + + fn take_referrers( + &mut self, + kind: pb::EntityKind, + id: &pb::Id, + ) -> Result, Status> { + let references = self.index.incoming(kind, id); + let remaining = 4_096 - self.emitted; + if references.len() > remaining + && self.coverage != DeleteReportCoverage::VisibleResourceBudget + { + return Err(Status::resource_exhausted( + "delete report output budget exceeded", + )); + } + let result: Vec<_> = references.iter().take(remaining).cloned().collect(); + self.emitted += result.len(); + Ok(result) + } +} + impl ReferenceIndexPurpose { fn check(self, index: &graph::ReverseIndex) -> Result<(), Status> { match self { @@ -1058,19 +1098,34 @@ impl EventModelServiceImpl { &self, branch: Option<&str>, vis: &Visibility, - ) -> Result, Status> { + ) -> Result { match self .reference_index_for(branch, vis, ReferenceIndexPurpose::CompleteReferrers) .await { - Ok(index) => Ok(index), + Ok(index) => { + let (_, incomplete) = self.snapshot_with_truncation(branch, vis).await?; + Ok(DeleteReportingIndex { + index, + coverage: if incomplete { + DeleteReportCoverage::SnapshotTruncated + } else { + DeleteReportCoverage::Complete + }, + emitted: 0, + }) + } Err(status) if status.code() == tonic::Code::ResourceExhausted && status.message() == "architecture reference scan exceeds its entity budget" => { let snapshot = self.snapshot_for(branch, vis).await?; - Ok(Arc::new(graph::ReverseIndex::build(&snapshot))) + Ok(DeleteReportingIndex { + index: Arc::new(graph::ReverseIndex::build(&snapshot)), + coverage: DeleteReportCoverage::VisibleResourceBudget, + emitted: 0, + }) } Err(status) => Err(status), } @@ -2467,6 +2522,7 @@ fn build_op_results( validated: bool, per_op_validation: &[Vec], per_op_referrers: &[Vec], + referrers_incomplete: bool, ) -> Vec { metas .iter() @@ -2511,6 +2567,7 @@ fn build_op_results( pb::BatchMutateOpResult { result: Some(pb::batch_mutate_op_result::Result::Delete( pb::DeleteEntityResponse { + referrers_incomplete, referrers, operation_receipt: None, validation, @@ -2960,6 +3017,114 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl })) } + async fn list_subject_decisions( + &self, + req: Request, + ) -> Result, Status> { + let vis = Self::visibility_of(&req); + let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } + self.subject_decisions(req.into_inner(), branch.as_deref(), &vis) + .await + .map(Response::new) + } + + async fn list_subject_documents( + &self, + req: Request, + ) -> Result, Status> { + let vis = Self::visibility_of(&req); + let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } + self.subject_documents(req.into_inner(), branch.as_deref(), &vis) + .await + .map(Response::new) + } + + async fn get_decision_detail( + &self, + req: Request, + ) -> Result, Status> { + let vis = Self::visibility_of(&req); + let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } + self.decision_detail(req.into_inner(), branch.as_deref(), &vis) + .await + .map(Response::new) + } + + async fn get_document_detail( + &self, + req: Request, + ) -> Result, Status> { + let vis = Self::visibility_of(&req); + let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } + self.document_detail(req.into_inner(), branch.as_deref(), &vis) + .await + .map(Response::new) + } + + async fn list_incoming_decision_declarations( + &self, + req: Request, + ) -> Result, Status> { + let vis = Self::visibility_of(&req); + let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } + self.incoming_decisions(req.into_inner(), branch.as_deref(), &vis) + .await + .map(Response::new) + } + + async fn list_incoming_document_declarations( + &self, + req: Request, + ) -> Result, Status> { + let vis = Self::visibility_of(&req); + let branch = Self::branch_from_metadata(&req)?; + let principal = Self::principal_from_metadata(&req); + if branch + .as_deref() + .is_some_and(|branch| !Self::branch_visible(principal.as_ref(), branch)) + { + return Err(Status::not_found("branch not found")); + } + self.incoming_documents(req.into_inner(), branch.as_deref(), &vis) + .await + .map(Response::new) + } + async fn list_subject_assignments( &self, req: Request, @@ -3770,6 +3935,20 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl }); } + all_nodes.sort_by_cached_key(|node| { + ( + node.depth, + node.entity.as_ref().map(|entity| { + ( + entity.kind, + entity + .id + .as_ref() + .map(|id| (id.namespace.clone(), id.slug.clone(), id.version)), + ) + }), + ) + }); let total = all_nodes.len(); let nodes: Vec = all_nodes.into_iter().skip(offset).take(page_size).collect(); @@ -3882,6 +4061,21 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .collect() }; + let mut result_owners = std::collections::HashSet::new(); + for reference in &referrers { + let owner = reference + .from + .as_ref() + .ok_or_else(|| Status::internal("reverse index entry missing from"))?; + let kind = kind_from_i32(owner.kind, "referrer.kind")?; + let id = require_id(owner.id.as_ref(), "referrer")?; + if result_owners.insert(pb::EntityKey::new(kind, id)) && result_owners.len() > 4_096 { + return Err(Status::resource_exhausted( + "retarget output budget exceeded", + )); + } + } + // Load every referrer, apply the in-memory rewrite, and stage the // rewritten entity for a batch write. Per-referrer load errors are // captured in results and do not abort the rest of the referrers. @@ -3935,6 +4129,36 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl "Responsibility local selectors require an explicit guarded replacement using returned owner and local identities", )); } + if crate::decision_document_resources::subjects(&stored.entity) + .iter() + .any(|association| { + association.subject.as_ref().is_some_and(|subject| { + !matches!( + subject.selection, + Some(pb::catalog_subject::Selection::Entity(_)) + ) && trogon_atlas_core::decision_document::subject_owner(subject).is_ok_and( + |owner| { + owner.kind == from_kind as i32 + && owner.id.as_ref() == Some(&from_id) + }, + ) + }) + }) + || crate::decision_document_resources::documents(&stored.entity) + .iter() + .any(|selection| { + matches!( + selection.selection, + Some(pb::document_selection::Selection::Anchor(_)) + ) && trogon_atlas_core::decision_document::document_owner(selection) + .is_ok_and(|owner| { + owner.kind == from_kind as i32 + && owner.id.as_ref() == Some(&from_id) + }) + }) + { + return Err(Status::failed_precondition("Catalog local selectors require an explicit guarded replacement using returned owner and local identities")); + } let mut entity = stored.entity.clone(); let fields = trogon_atlas_core::refs::retarget_refs(&mut entity, from_kind, &from_id, &to_id); @@ -4244,7 +4468,10 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl let (all, responsibility_incomplete) = if matches!( pb::EntityKind::try_from(root_ref.kind), - Ok(pb::EntityKind::Team | pb::EntityKind::Person) + Ok(pb::EntityKind::Team + | pb::EntityKind::Person + | pb::EntityKind::Decision + | pb::EntityKind::Document) ) { let id = require_id(root_ref.id.as_ref(), "scope")?; self.require_visible(&vis, &id.namespace).await?; @@ -4792,6 +5019,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl // current to report; an empty list is the honest // answer, not a stale one. return Ok(Response::new(pb::DeleteEntityResponse { + referrers_incomplete: true, referrers: Vec::new(), operation_receipt: Some(operation_receipts::replay_receipt( &req.operation_id, @@ -4865,8 +5093,30 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .has_incoming_reference(kind, &id, branch.as_deref()) .await .map_err(store_err)?; - let index = self.delete_reporting_index(branch.as_deref(), &vis).await?; - let referrers = index.incoming(kind, &id).to_vec(); + if is_referenced { + let status = Status::failed_precondition( + "entity is referenced; pass mode=FORCE or repair referrers first", + ); + if let Some(key) = &claim_key { + operation_receipts::settle_for_status(&*self.store, key, &status).await; + } + return Err(status); + } + let report = async { + let mut report = self.delete_reporting_index(branch.as_deref(), &vis).await?; + let references = report.take_referrers(kind, &id)?; + Ok::<_, Status>((references, report.incomplete())) + } + .await; + let (referrers, referrers_incomplete) = match report { + Ok(report) => report, + Err(status) => { + if let Some(key) = &claim_key { + operation_receipts::settle_for_status(&*self.store, key, &status).await; + } + return Err(status); + } + }; match mode { pb::delete_entity_request::Mode::DryRun => { @@ -4876,20 +5126,12 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .delete_strategic_findings(kind, &id, branch.as_deref(), &vis) .await?; return Ok(Response::new(pb::DeleteEntityResponse { + referrers_incomplete, referrers, operation_receipt: None, validation, })); } - pb::delete_entity_request::Mode::FailIfReferenced if is_referenced => { - let status = Status::failed_precondition( - "entity is referenced; pass mode=FORCE or repair referrers first", - ); - if let Some(key) = &claim_key { - operation_receipts::settle_for_status(&*self.store, key, &status).await; - } - return Err(status); - } pb::delete_entity_request::Mode::Unspecified => { let status = Status::invalid_argument( "delete mode must not be Unspecified (server defaults to FAIL_IF_REFERENCED)", @@ -4962,6 +5204,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .await; } Ok(Response::new(pb::DeleteEntityResponse { + referrers_incomplete, referrers, operation_receipt: claim_key.as_ref().map(|_| { operation_receipts::fresh_receipt(&req.operation_id, &receipt_changeset_id) @@ -5316,6 +5559,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl .collect(); let mut per_op_referrers: Vec> = std::iter::repeat_with(Vec::new).take(metas.len()).collect(); + let mut referrers_incomplete = false; if delete_modes.iter().any(Option::is_some) { let guarded_targets: Vec = delete_modes .iter() @@ -5360,11 +5604,12 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl return Ok(Response::new(Self::batch_referenced_response(&metas, i))); } } - let index = self.delete_reporting_index(branch.as_deref(), &vis).await?; + let mut report = self.delete_reporting_index(branch.as_deref(), &vis).await?; + referrers_incomplete = report.incomplete(); for (i, mode) in delete_modes.iter().enumerate() { if mode.is_some() { let meta = &metas[i]; - per_op_referrers[i] = index.incoming(meta.kind, &meta.id).to_vec(); + per_op_referrers[i] = report.take_referrers(meta.kind, &meta.id)?; } } } @@ -5511,6 +5756,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl /*validated=*/ true, &per_op_validation, &per_op_referrers, + referrers_incomplete, ); Ok(Response::new(pb::BatchMutateResponse { status: pb::batch_mutate_response::Status::Validated as i32, @@ -5611,6 +5857,7 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl /*validated=*/ false, &per_op_validation, &per_op_referrers, + referrers_incomplete, ); let receipt_changeset_id = changeset.id.clone(); if let Some(key) = &claim_key { @@ -7856,6 +8103,8 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl | pb::EntityKind::DataProduct | pb::EntityKind::Team | pb::EntityKind::Person + | pb::EntityKind::Decision + | pb::EntityKind::Document ) { self.authorize_write( principal.as_ref(), diff --git a/rsworkspace/crates/trogon-atlas-server/src/validation.rs b/rsworkspace/crates/trogon-atlas-server/src/validation.rs index 3106012e..40cb4d6c 100644 --- a/rsworkspace/crates/trogon-atlas-server/src/validation.rs +++ b/rsworkspace/crates/trogon-atlas-server/src/validation.rs @@ -172,6 +172,10 @@ pub fn entity_kind_catalog() -> Vec { "A retained team profile with explicit membership and catalog responsibility."), k(pb::EntityKind::Person, "ENTITY_KIND_PERSON", "person", "Person", "People", "A retained directory profile with explicit catalog responsibility."), + k(pb::EntityKind::Decision, "ENTITY_KIND_DECISION", "decision", "Decision", "Decisions", + "A catalog choice and its rationale against exact model subjects."), + k(pb::EntityKind::Document, "ENTITY_KIND_DOCUMENT", "document", "Document", "Documents", + "Retained authored text or an unverified external link with exact model associations."), k(TypeLibrary, "ENTITY_KIND_TYPE_LIBRARY", "typeLibrary", "Type Library", "Type Libraries", "Tenant-owned protobuf source files under a package prefix the library owns. The server compiles them on demand; the source is the only stored form."), ] @@ -224,6 +228,9 @@ pub fn rule_catalog() -> Vec { category: category.into(), }; vec![ + r("CATALOG_REFERENCE_UNRESOLVED", Error, "Catalog declaration does not resolve", + "Decision and Document declarations require valid native values and exact readable subject, relationship, ancestry and local selections. Repair the visible declaration using current returned identities and content pins.", + Unspecified, "", "architecture"), r("RESPONSIBILITY_VALUE_INVALID", Error, "Invalid responsibility declaration", "Profiles and assignments must satisfy the native responsibility contract.", pb::EntityKind::Team, "", "architecture"), @@ -614,6 +621,7 @@ mod catalog_drift_tests { // OR remove one, update both the catalog AND this list so the diff // test below can guard against drift. const EMITTED_CODES: &[&str] = &[ + "CATALOG_REFERENCE_UNRESOLVED", "RESPONSIBILITY_VALUE_INVALID", "RESPONSIBILITY_REFERENCE_UNRESOLVED", "DATA_PRODUCT_VALUE_INVALID", @@ -777,6 +785,8 @@ mod catalog_drift_tests { pb::EntityKind::DataProduct, pb::EntityKind::Team, pb::EntityKind::Person, + pb::EntityKind::Decision, + pb::EntityKind::Document, ]; let got: HashSet = entity_kind_catalog().iter().map(|i| i.kind).collect(); let missing: Vec = want @@ -1149,6 +1159,8 @@ fn entity_metadata(entity: &pb::Entity) -> &[prost_types::Any] { Some(pb::entity::Kind::DataProduct(x)) => &x.metadata, Some(pb::entity::Kind::Team(x)) => &x.metadata, Some(pb::entity::Kind::Person(x)) => &x.metadata, + Some(pb::entity::Kind::Decision(x)) => &x.metadata, + Some(pb::entity::Kind::Document(x)) => &x.metadata, None => &[], } } diff --git a/rsworkspace/crates/trogon-atlas-server/tests/decision_document_backend.rs b/rsworkspace/crates/trogon-atlas-server/tests/decision_document_backend.rs new file mode 100644 index 00000000..65922f81 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/decision_document_backend.rs @@ -0,0 +1,1815 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::Arc; + +use pb::{ + event_model_service_client::EventModelServiceClient, + event_model_service_server::EventModelServiceServer, +}; +use tokio::net::TcpListener; +use tonic::{service::interceptor::InterceptedService, transport::Channel, Code, Request}; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{ + auth::{AuthzLayer, BearerAuth, TokenRegistry}, + metrics::ServerMetrics, + service::EventModelServiceImpl, +}; + +const ADMIN: &str = "catalog-admin"; +const READER: &str = "catalog-reader"; +const LIMITED: &str = "catalog-limited"; +type Client = EventModelServiceClient; +fn request(body: T, token: &str, branch: Option<&str>) -> Request { + let mut req = Request::new(body); + req.metadata_mut() + .insert("authorization", format!("Bearer {token}").parse().unwrap()); + if let Some(branch) = branch { + req.metadata_mut() + .insert("x-trogon-atlas-branch", branch.parse().unwrap()); + } + req +} +async fn client() -> Client { + client_with_cap(0).await +} +async fn client_with_cap(cap: usize) -> Client { + client_with_store( + Arc::new(trogon_atlas_testsupport::shared().await.store().await), + cap, + ) + .await +} +async fn client_with_store(store: Arc, cap: usize) -> Client { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let registry = TokenRegistry::from_file_contents( + toml::from_str(&format!( + r#" +[principals.admin] +role = "admin" +tokens = ["{ADMIN}"] +[principals.reader] +role = "reader" +tokens = ["{READER}"] +[principals.limited] +role = "writer" +parent = "visible" +tokens = ["{LIMITED}"] +"# + )) + .unwrap(), + ) + .unwrap(); + let service = EventModelServiceImpl::try_new(store, ServerMetrics::default()) + .unwrap() + .with_snapshot_cap(cap); + let grpc = InterceptedService::new( + tower::ServiceBuilder::new() + .layer(AuthzLayer) + .service(EventModelServiceServer::new(service)), + BearerAuth::new(Arc::new(registry)), + ); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(grpc) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + Client::connect(format!("http://{addr}")).await.unwrap() +} + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "catalog".into(), + slug: slug.into(), + version: 1, + } +} +fn entity(kind: pb::entity::Kind) -> pb::Entity { + pb::Entity { + kind: Some(kind), + ..Default::default() + } +} +fn reference(value: &pb::Entity) -> pb::EntityRef { + trogon_atlas_server::conv::entity_to_ref(value).unwrap() +} +fn subject(reference: pb::EntityRef) -> pb::CatalogSubject { + pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(reference)), + } +} +fn title(value: &str) -> pb::CatalogTitle { + pb::CatalogTitle { + value: value.into(), + } +} +fn text(value: &str) -> pb::CatalogText { + pb::CatalogText { + value: value.into(), + } +} +fn read_model(slug: &str) -> pb::Entity { + entity(pb::entity::Kind::ReadModel(pb::ReadModel { + id: Some(id(slug)), + title: slug.into(), + ..Default::default() + })) +} +fn document(slug: &str, selected: pb::CatalogSubject) -> pb::Entity { + let content = "# Delivery\nRetain literally.\n"; + entity(pb::entity::Kind::Document(pb::Document { + id: Some(id(slug)), + title: Some(title(slug)), + subjects: vec![pb::SubjectAssociation { + subject: Some(selected), + ..Default::default() + }], + body: Some(pb::document::Body::Retained(pb::RetainedDocumentText { + media_type: Some(pb::MediaType { + value: "text/markdown".into(), + }), + content: content.into(), + digest: Some(trogon_atlas_core::specification::content_digest(content)), + })), + anchors: vec![pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: "delivery".into(), + }), + label: Some(title("Delivery")), + ..Default::default() + }], + ..Default::default() + })) +} +fn decision(slug: &str, selected: pb::CatalogSubject, adopted: bool) -> pb::Entity { + let option_id = pb::LocalValueId { + value: uuid::Uuid::now_v7().to_string(), + }; + entity(pb::entity::Kind::Decision(pb::Decision { + id: Some(id(slug)), + title: Some(title(slug)), + subjects: vec![pb::SubjectAssociation { + subject: Some(selected), + ..Default::default() + }], + status: if adopted { + pb::DecisionStatus::Accepted + } else { + pb::DecisionStatus::Proposed + } as i32, + context: Some(text("Choose delivery")), + rationale: Some(text("Keep delivery explicit")), + options: vec![pb::DecisionOption { + option_id: Some(option_id.clone()), + title: Some(title("Native delivery")), + description: Some(text("Retain the selected choice")), + }], + selected_option: adopted.then_some(option_id), + consequences: if adopted { + vec![pb::DecisionConsequence { + kind: pb::decision_consequence::Kind::Risk as i32, + text: Some(text("Delivery may need repair")), + }] + } else { + Vec::new() + }, + ..Default::default() + })) +} +async fn put( + client: &mut Client, + value: pb::Entity, + branch: Option<&str>, +) -> pb::PutEntityResponse { + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(value), + ..Default::default() + }, + ADMIN, + branch, + )) + .await + .unwrap() + .into_inner() +} +async fn detail( + client: &mut Client, + value: &pb::Entity, + token: &str, +) -> pb::GetDecisionDetailResponse { + client + .get_decision_detail(request( + pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: reference(value).id, + }), + follow_replacement: false, + }, + token, + None, + )) + .await + .unwrap() + .into_inner() +} +async fn delete( + client: &mut Client, + value: &pb::Entity, + force: bool, + branch: Option<&str>, +) -> Result, tonic::Status> { + let selected = reference(value); + client + .delete_entity(request( + pb::DeleteEntityRequest { + kind: selected.kind, + id: selected.id, + mode: if force { + pb::delete_entity_request::Mode::Force + } else { + pb::delete_entity_request::Mode::FailIfReferenced + } as i32, + ..Default::default() + }, + ADMIN, + branch, + )) + .await +} +fn anchor_selection(value: &pb::Entity, pin: bool) -> pb::DocumentSelection { + let Some(pb::entity::Kind::Document(document)) = &value.kind else { + panic!("document"); + }; + let Some(pb::document::Body::Retained(body)) = &document.body else { + panic!("retained"); + }; + pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Anchor( + pb::DocumentAnchorRef { + document: Some(pb::DocumentRef { + id: document.id.clone(), + }), + owner_uid: value.system.as_ref().unwrap().uid.clone(), + anchor_id: document.anchors[0].anchor_id.clone(), + expected_digest: pin.then(|| body.digest.clone()).flatten(), + }, + )), + } +} +fn whole_selection(value: &pb::Entity) -> pb::DocumentSelection { + pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Document( + pb::DocumentRef { + id: reference(value).id, + }, + )), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_public_exact_queries_preserve_bytes_pins_and_provenance_outside_display_cap() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let note = put( + &mut client, + document("delivery-note", selected.clone()), + None, + ) + .await + .entity + .unwrap(); + let pinned = anchor_selection(¬e, true); + let whole = whole_selection(¬e); + let mut choice = decision("delivery-choice", selected.clone(), true); + if let Some(pb::entity::Kind::Decision(value)) = &mut choice.kind { + value.documents = [pinned.clone(), whole.clone()] + .into_iter() + .map(|document| pb::SupportingDocument { + document: Some(document), + ..Default::default() + }) + .collect(); + } + let saved = put(&mut client, choice, None).await; + let choice = saved.entity.unwrap(); + let response = detail(&mut client, &choice, READER).await; + assert_eq!(response.entity.as_ref(), Some(&choice)); + assert_eq!(response.etag, saved.etag); + assert!(!response.incomplete); + assert_eq!(response.declarations.len(), 3); + assert!(response + .declarations + .iter() + .all(|declaration| declaration.state == pb::CatalogResolutionState::Resolved as i32)); + assert!(response + .scope + .unwrap() + .visited + .iter() + .any(|owner| owner.owner.as_ref() == Some(&reference(¬e)) + && owner.uid == note.system.as_ref().unwrap().uid + && !owner.etag.is_empty())); + let reverse = client + .list_subject_decisions(request( + pb::ListSubjectDecisionsRequest { + subject: Some(selected.clone()), + status: Some(pb::DecisionStatus::Accepted as i32), + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(reverse.declarations.len(), 1); + let Some(pb::catalog_declaration::Declaration::Subject(association)) = + &reverse.declarations[0].declaration + else { + panic!("subject provenance"); + }; + assert_eq!(association.subject.as_ref(), Some(&selected)); + assert!(association.connection_id.is_some()); + for selection in [pinned.clone(), whole] { + let incoming = client + .list_incoming_document_declarations(request( + pb::ListIncomingDocumentDeclarationsRequest { + document: Some(selection.clone()), + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(incoming.declarations.len(), 1); + let Some(pb::catalog_declaration::Declaration::Supporting(association)) = + &incoming.declarations[0].declaration + else { + panic!("supporting provenance"); + }; + assert_eq!(association.document.as_ref(), Some(&selection)); + } + let note_detail = client + .get_document_detail(request( + pb::GetDocumentDetailRequest { + document: Some(pinned.clone()), + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(note_detail.entity.as_ref(), Some(¬e)); + assert_eq!( + note_detail.anchor.unwrap().fragment.unwrap().value, + "delivery" + ); + for _ in 0..2 { + let incoming = client + .get_incoming_references(request( + pb::GetReferencesRequest { + kind: pb::EntityKind::Document as i32, + id: reference(¬e).id, + ..Default::default() + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(incoming.references.len(), 2); + assert!(incoming + .references + .iter() + .all(|reference| reference.connection_id.is_some() + && reference.from_uid == choice.system.as_ref().unwrap().uid)); + assert!(incoming + .references + .iter() + .any(|reference| reference.document_selection.as_ref() == Some(&pinned))); + } + assert_eq!( + delete(&mut client, ¬e, false, None) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + delete(&mut client, ¬e, true, None).await.unwrap(); + let unresolved = detail(&mut client, &choice, READER).await; + assert_eq!( + unresolved + .declarations + .iter() + .filter( + |declaration| declaration.state == pb::CatalogResolutionState::Unavailable as i32 + ) + .count(), + 2 + ); + put(&mut client, document("unrelated", selected), None).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_replacement_uses_final_overlay_and_does_not_revalidate_unchanged_incoming_owners() +{ + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let replacing = put( + &mut client, + decision("new-choice", selected.clone(), true), + None, + ) + .await + .entity + .unwrap(); + let mut old = decision("old-choice", selected.clone(), true); + if let Some(pb::entity::Kind::Decision(value)) = &mut old.kind { + value.status = pb::DecisionStatus::Superseded as i32; + value.replaced_by = Some(pb::DecisionReplacement { + decision: Some(pb::DecisionRef { + id: reference(&replacing).id, + }), + ..Default::default() + }); + } + let old = put(&mut client, old, None).await.entity.unwrap(); + let mut changed = replacing.clone(); + if let Some(pb::entity::Kind::Decision(value)) = &mut changed.kind { + value.status = pb::DecisionStatus::Withdrawn as i32; + value.selected_option = None; + value.consequences.clear(); + } + put(&mut client, changed, None).await; + let chain = client + .get_decision_detail(request( + pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: reference(&old).id, + }), + follow_replacement: true, + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert!(chain.incomplete); + assert_eq!(chain.failures[0].code, Code::FailedPrecondition as i32); + put(&mut client, document("unrelated", selected.clone()), None).await; + let invalid_edit = client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(old.clone()), + ..Default::default() + }, + ADMIN, + None, + )) + .await; + assert_eq!(invalid_edit.unwrap_err().code(), Code::InvalidArgument); + let a = decision("cycle-a", selected.clone(), true); + let b = decision("cycle-b", selected, true); + let mut values = [a, b]; + let ids = [reference(&values[0]).id, reference(&values[1]).id]; + for (index, value) in values.iter_mut().enumerate() { + if let Some(pb::entity::Kind::Decision(value)) = &mut value.kind { + value.status = pb::DecisionStatus::Superseded as i32; + value.replaced_by = Some(pb::DecisionReplacement { + decision: Some(pb::DecisionRef { + id: ids[1 - index].clone(), + }), + ..Default::default() + }); + } + } + let result = client + .batch_mutate(request( + pb::BatchMutateRequest { + ops: values + .into_iter() + .map(|value| pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(value), + ..Default::default() + })), + }) + .collect(), + ..Default::default() + }, + ADMIN, + None, + )) + .await; + assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_admission_rejects_invalid_body_state_and_closed_subject_inventory() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let note = document("note", selected.clone()); + let mut invalid = Vec::new(); + let mut bad = note.clone(); + if let Some(pb::entity::Kind::Document(value)) = &mut bad.kind { + if let Some(pb::document::Body::Retained(body)) = &mut value.body { + body.digest.as_mut().unwrap().sha256 = "0".repeat(64); + } + } + invalid.push(bad); + let mut bad = note.clone(); + if let Some(pb::entity::Kind::Document(value)) = &mut bad.kind { + value.body = Some(pb::document::Body::Link(pb::ExternalDocumentLink { + value: "https://example.test/guide".into(), + })); + } + invalid.push(bad); + let mut bad = note.clone(); + if let Some(pb::entity::Kind::Document(value)) = &mut bad.kind { + value.subjects.clear(); + } + invalid.push(bad); + let mut bad = decision("bad", selected.clone(), true); + if let Some(pb::entity::Kind::Decision(value)) = &mut bad.kind { + value.status = pb::DecisionStatus::Proposed as i32; + } + invalid.push(bad); + for value in invalid { + assert_eq!( + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(value), + ..Default::default() + }, + ADMIN, + None + )) + .await + .unwrap_err() + .code(), + Code::InvalidArgument + ); + } + let note = put(&mut client, note, None).await.entity.unwrap(); + let bad_subject = subject(reference(¬e)); + assert_eq!( + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(decision("recursive", bad_subject, false)), + ..Default::default() + }, + ADMIN, + None + )) + .await + .unwrap_err() + .code(), + Code::InvalidArgument + ); + assert_eq!( + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(decision("denied", selected, false)), + ..Default::default() + }, + READER, + None + )) + .await + .unwrap_err() + .code(), + Code::PermissionDenied + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_generic_retarget_refuses_anchor_owner_substitution() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let original = put(&mut client, document("note", selected.clone()), None) + .await + .entity + .unwrap(); + let mut next = document("note", selected.clone()); + if let Some(pb::entity::Kind::Document(value)) = &mut next.kind { + value.id.as_mut().unwrap().version = 2; + } + let next = put(&mut client, next, None).await.entity.unwrap(); + let mut choice = decision("choice", selected, false); + if let Some(pb::entity::Kind::Decision(value)) = &mut choice.kind { + value.documents = vec![pb::SupportingDocument { + document: Some(anchor_selection(&original, true)), + ..Default::default() + }]; + } + let choice = put(&mut client, choice, None).await.entity.unwrap(); + for dry_run in [true, false] { + let result = client + .retarget_references(request( + pb::RetargetReferencesRequest { + from: Some(reference(&original)), + to: Some(reference(&next)), + dry_run, + ..Default::default() + }, + ADMIN, + None, + )) + .await; + assert_eq!(result.unwrap_err().code(), Code::FailedPrecondition); + assert_eq!( + detail(&mut client, &choice, READER).await.entity.as_ref(), + Some(&choice) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_hidden_reads_hydration_and_referrers_preserve_authorization_boundaries() { + let mut client = client().await; + let mut namespaces = Vec::new(); + for (name, parent) in [ + ("catalog-visible", "visible"), + ("catalog-private", "hidden"), + ] { + namespaces.push( + client + .register_namespace(request( + pb::RegisterNamespaceRequest { + name: name.into(), + parent: parent.into(), + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id, + ); + } + let mut target = read_model("receipt"); + if let Some(pb::entity::Kind::ReadModel(value)) = &mut target.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[0]); + } + let target = put(&mut client, target, None).await.entity.unwrap(); + let selected = subject(reference(&target)); + let mut hidden = document("hidden-note", selected.clone()); + if let Some(pb::entity::Kind::Document(value)) = &mut hidden.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[1]); + value.title = Some(title("Private retained title")); + } + let hidden = put(&mut client, hidden, None).await.entity.unwrap(); + let hidden_selection = anchor_selection(&hidden, true); + let mut choice = decision("visible-choice", selected.clone(), false); + if let Some(pb::entity::Kind::Decision(value)) = &mut choice.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[0]); + value.documents = vec![pb::SupportingDocument { + document: Some(hidden_selection.clone()), + ..Default::default() + }]; + } + let choice = put(&mut client, choice, None).await.entity.unwrap(); + let direct = client + .get_document_detail(request( + pb::GetDocumentDetailRequest { + document: Some(hidden_selection.clone()), + }, + LIMITED, + None, + )) + .await + .unwrap_err(); + assert_eq!(direct.code(), Code::NotFound); + assert!(!direct.message().contains("Private retained title")); + let visible = detail(&mut client, &choice, LIMITED).await; + let support = visible + .declarations + .iter() + .find(|declaration| declaration.field.starts_with("documents[")) + .unwrap(); + assert_eq!( + support.state, + pb::CatalogResolutionState::Unavailable as i32 + ); + let Some(pb::catalog_declaration::Declaration::Supporting(support)) = &support.declaration + else { + panic!("supporting"); + }; + assert_eq!(support.document.as_ref(), Some(&hidden_selection)); + assert!(visible.scope.unwrap().visited.iter().all(|owner| owner + .owner + .as_ref() + .and_then(|owner| owner.id.as_ref()) + .is_none_or(|id| id.namespace != namespaces[1]))); + let reverse = client + .list_subject_documents(request( + pb::ListSubjectDocumentsRequest { + subject: Some(selected), + }, + LIMITED, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(reverse.declarations, Vec::::new()); + let protected = client + .delete_entity(request( + pb::DeleteEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: reference(&target).id, + ..Default::default() + }, + LIMITED, + None, + )) + .await + .unwrap_err(); + assert_eq!(protected.code(), Code::FailedPrecondition); + assert!(!protected.message().contains("hidden-note")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_safe_batch_uses_final_graph_and_branch_force_delete_leaves_exact_selectors() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let note = put(&mut client, document("note", selected.clone()), None) + .await + .entity + .unwrap(); + let mut choice = decision("choice", selected.clone(), false); + if let Some(pb::entity::Kind::Decision(value)) = &mut choice.kind { + value.documents = vec![pb::SupportingDocument { + document: Some(anchor_selection(¬e, true)), + ..Default::default() + }]; + } + let choice = put(&mut client, choice, None).await.entity.unwrap(); + client + .create_branch(request( + pb::CreateBranchRequest { + name: "catalog-branch".into(), + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap(); + delete(&mut client, ¬e, true, Some("catalog-branch")) + .await + .unwrap(); + let branch = client + .get_decision_detail(request( + pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: reference(&choice).id, + }), + follow_replacement: false, + }, + READER, + Some("catalog-branch"), + )) + .await + .unwrap() + .into_inner(); + assert_eq!(branch.entity.as_ref(), Some(&choice)); + assert!(branch + .declarations + .iter() + .any(|declaration| declaration.state == pb::CatalogResolutionState::Unavailable as i32)); + assert!(detail(&mut client, &choice, READER) + .await + .declarations + .iter() + .all(|declaration| declaration.state == pb::CatalogResolutionState::Resolved as i32)); + put( + &mut client, + decision("unrelated", selected, false), + Some("catalog-branch"), + ) + .await; + let merged = client + .merge_branch(request( + pb::MergeBranchRequest { + name: "catalog-branch".into(), + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(merged.conflicts, Vec::::new()); + assert_eq!( + detail(&mut client, &choice, READER).await.entity.as_ref(), + Some(&choice) + ); + let targets = [&target, &choice]; + let ops = targets + .into_iter() + .map(|value| { + let reference = reference(value); + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: reference.kind, + id: reference.id, + ..Default::default() + })), + } + }) + .collect(); + let denied = client + .batch_mutate(request( + pb::BatchMutateRequest { + ops, + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!( + denied.status, + pb::batch_mutate_response::Status::Failed as i32 + ); + let unrelated = client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::Decision as i32, + id: Some(id("unrelated")), + }, + READER, + None, + )) + .await + .unwrap() + .into_inner() + .entity + .unwrap(); + let ops = [&target, &choice, &unrelated] + .into_iter() + .map(|value| { + let reference = reference(value); + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: reference.kind, + id: reference.id, + ..Default::default() + })), + } + }) + .collect(); + let applied = client + .batch_mutate(request( + pb::BatchMutateRequest { + ops, + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!( + applied.status, + pb::batch_mutate_response::Status::Applied as i32 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_repeated_native_connections_keep_occurrences_and_responsibility_kinds() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let processor = put( + &mut client, + entity(pb::entity::Kind::Processor(pb::Processor { + id: Some(id("notify")), + title: "Notify".into(), + ..Default::default() + })), + None, + ) + .await + .entity + .unwrap(); + let automation = put( + &mut client, + entity(pb::entity::Kind::AutomationSlice(pb::AutomationSlice { + id: Some(id("observe")), + title: "Observe".into(), + processor: Some(pb::ProcessorEdge { + processor: Some(pb::ProcessorRef { + id: reference(&processor).id, + }), + ..Default::default() + }), + source_read_models: (0..2) + .map(|_| pb::ReadModelEdge { + read_model: Some(pb::ReadModelRef { + id: reference(&target).id, + }), + ..Default::default() + }) + .collect(), + ..Default::default() + })), + None, + ) + .await + .entity + .unwrap(); + let Some(pb::entity::Kind::AutomationSlice(value)) = &automation.kind else { + panic!("automation"); + }; + let selectors: Vec<_> = value + .source_read_models + .iter() + .map(|edge| pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Connection( + pb::ConnectionRef { + owner: Some(reference(&automation)), + owner_uid: automation.system.as_ref().unwrap().uid.clone(), + id: edge.connection_id.clone(), + }, + )), + }) + .collect(); + assert_ne!(selectors[0], selectors[1]); + let mut choice = decision("choice", selectors[0].clone(), false); + if let Some(pb::entity::Kind::Decision(value)) = &mut choice.kind { + value.subjects.push(pb::SubjectAssociation { + subject: Some(selectors[1].clone()), + ..Default::default() + }); + } + let choice = put(&mut client, choice, None).await.entity.unwrap(); + let note = put( + &mut client, + document("note", subject(reference(&target))), + None, + ) + .await + .entity + .unwrap(); + for selected in selectors { + let found = client + .list_subject_decisions(request( + pb::ListSubjectDecisionsRequest { + subject: Some(selected.clone()), + status: None, + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(found.declarations.len(), 1); + assert_eq!( + found.subject_state, + pb::CatalogResolutionState::Resolved as i32 + ); + let Some(pb::catalog_declaration::Declaration::Subject(value)) = + &found.declarations[0].declaration + else { + panic!("subject"); + }; + assert_eq!(value.subject.as_ref(), Some(&selected)); + } + for (index, target) in [&choice, ¬e].into_iter().enumerate() { + let selected = pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Entity(reference( + target, + ))), + }; + let person = entity(pb::entity::Kind::Person(pb::Person { + id: Some(id(&format!("maintainer-{index}"))), + title: Some(pb::DirectoryDisplayName { + value: "Maintainer".into(), + }), + responsibilities: vec![pb::Responsibility { + kind: pb::ResponsibilityKind::Steward as i32, + subject: Some(selected.clone()), + ..Default::default() + }], + ..Default::default() + })); + put(&mut client, person, None).await; + let assignments = client + .list_subject_assignments(request( + pb::ListSubjectAssignmentsRequest { + subject: Some(selected), + kind: None, + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(assignments.assignments.len(), 1); + assert_eq!( + assignments.subject_state, + pb::ResponsibilityResolutionState::Resolved as i32 + ); + assert_eq!( + delete(&mut client, target, false, None) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_backend_failure_is_operational_and_cannot_authorize_delete() { + let nats = trogon_atlas_testsupport::TestNats::start().await; + let mut client = client_with_store(Arc::new(nats.store().await), 0).await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let choice = put( + &mut client, + decision("choice", subject(reference(&target)), false), + None, + ) + .await + .entity + .unwrap(); + nats.stop().await.unwrap(); + let failure = client + .get_decision_detail(request( + pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: reference(&choice).id, + }), + follow_replacement: false, + }, + READER, + None, + )) + .await + .unwrap_err(); + assert!( + matches!( + failure.code(), + Code::Unavailable | Code::Internal | Code::DeadlineExceeded + ), + "{failure}" + ); + let failure = delete(&mut client, &target, false, None).await.unwrap_err(); + assert!( + matches!( + failure.code(), + Code::Unavailable | Code::Internal | Code::DeadlineExceeded | Code::FailedPrecondition + ), + "{failure}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_discovery_work_budget_accepts_boundary_and_shares_directory_rows() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + for batch in 0..10 { + let ops = (0..100) + .map(|index| { + let value = entity(pb::entity::Kind::Person(pb::Person { + id: Some(id(&format!("member-{}", batch * 100 + index))), + title: Some(pb::DirectoryDisplayName { + value: "Member".into(), + }), + ..Default::default() + })); + pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(value), + ..Default::default() + })), + } + }) + .collect(); + let result = client + .batch_mutate(request( + pb::BatchMutateRequest { + ops, + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!( + result.status, + pb::batch_mutate_response::Status::Applied as i32 + ); + } + for index in 0..10 { + let count = if index == 9 { 999 } else { 1_000 }; + put( + &mut client, + entity(pb::entity::Kind::Team(pb::Team { + id: Some(id(&format!("team-{index}"))), + title: Some(pb::DirectoryDisplayName { + value: "Team".into(), + }), + members: (0..count) + .map(|member| pb::TeamMember { + person: Some(pb::PersonRef { + id: Some(id(&format!("member-{member}"))), + }), + ..Default::default() + }) + .collect(), + ..Default::default() + })), + None, + ) + .await; + } + let selected = subject(reference(&target)); + let mut note = document("boundary", selected.clone()); + if let Some(pb::entity::Kind::Document(value)) = &mut note.kind { + value.anchors.clear(); + } + put(&mut client, note, None).await; + let request_body = pb::ListSubjectDocumentsRequest { + subject: Some(selected.clone()), + }; + let boundary = client + .list_subject_documents(request(request_body.clone(), READER, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(boundary.declarations.len(), 1); + assert!(!boundary.incomplete); + let mut extra = document("beyond", selected); + if let Some(pb::entity::Kind::Document(value)) = &mut extra.kind { + value.anchors.clear(); + } + put(&mut client, extra, None).await; + let failure = client + .list_subject_documents(request(request_body, READER, None)) + .await + .unwrap_err(); + assert_eq!(failure.code(), Code::ResourceExhausted); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_discovery_output_and_shared_row_boundaries_are_exact() { + use trogon_atlas_store::{store::MutationOp, Store, WriteContext}; + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let mut client = client_with_store(store.clone(), 10_000).await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let destination = put(&mut client, read_model("retarget-destination"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + for start in (0..4_096).step_by(128) { + let ops: Vec<_> = (start..(start + 128).min(4_096)) + .map(|index| { + let mut value = document(&format!("note-{index}"), selected.clone()); + if let Some(pb::entity::Kind::Document(value)) = &mut value.kind { + value.anchors.clear(); + } + MutationOp::Create { + kind: pb::EntityKind::Document, + entity: value, + } + }) + .collect(); + store + .batch_apply(&ops, WriteContext::baseline()) + .await + .unwrap(); + } + let request_body = pb::ListSubjectDocumentsRequest { + subject: Some(selected.clone()), + }; + let boundary = client + .list_subject_documents(request(request_body.clone(), READER, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(boundary.declarations.len(), 4_096); + assert!(!boundary.incomplete); + let boundary_retarget = client + .retarget_references(request( + pb::RetargetReferencesRequest { + from: Some(reference(&target)), + to: Some(reference(&destination)), + dry_run: true, + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(boundary_retarget.results.len(), 4_096); + assert_eq!(boundary_retarget.rewritten, 4_096); + assert_eq!(boundary_retarget.failed, 0); + let original_owner = client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::Document as i32, + id: Some(id("note-0")), + }, + READER, + None, + )) + .await + .unwrap() + .into_inner() + .entity + .unwrap(); + let report_request = pb::DeleteEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: reference(&target).id, + mode: pb::delete_entity_request::Mode::DryRun as i32, + ..Default::default() + }; + let report = client + .delete_entity(request(report_request.clone(), ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(report.referrers.len(), 4_096); + assert!(!report.referrers_incomplete); + let mut extra = document("extra", selected.clone()); + if let Some(pb::entity::Kind::Document(value)) = &mut extra.kind { + value.anchors.clear(); + } + store + .create(pb::EntityKind::Document, &extra, WriteContext::baseline()) + .await + .unwrap(); + let beyond = client + .list_subject_documents(request(request_body, READER, None)) + .await + .unwrap_err(); + assert_eq!(beyond.code(), Code::ResourceExhausted); + assert!(beyond.message().contains("output")); + let mut unexpected = Vec::new(); + for dry_run in [true, false] { + match client + .retarget_references(request( + pb::RetargetReferencesRequest { + from: Some(reference(&target)), + to: Some(reference(&destination)), + dry_run, + ..Default::default() + }, + ADMIN, + None, + )) + .await + { + Ok(response) => { + let response = response.into_inner(); + unexpected.push(format!( + "dry_run={dry_run} returned {} owners and {} rewrites", + response.results.len(), + response.rewritten + )); + } + Err(error) => assert_eq!(error.code(), Code::ResourceExhausted), + } + } + assert!(unexpected.is_empty(), "{}", unexpected.join("; ")); + assert_eq!( + client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::Document as i32, + id: Some(id("note-0")) + }, + READER, + None + )) + .await + .unwrap() + .into_inner() + .entity + .as_ref(), + Some(&original_owner) + ); + for mode in [ + pb::delete_entity_request::Mode::DryRun, + pb::delete_entity_request::Mode::Force, + ] { + let operation_id = if mode == pb::delete_entity_request::Mode::Force { + uuid::Uuid::now_v7().to_string() + } else { + String::new() + }; + let attempt = pb::DeleteEntityRequest { + mode: mode as i32, + operation_id: operation_id.clone(), + ..report_request.clone() + }; + for _ in 0..2 { + let failure = match client + .delete_entity(request(attempt.clone(), ADMIN, None)) + .await + { + Err(failure) => failure, + Ok(report) => panic!( + "Expected output-budget refusal, received {} referrers", + report.into_inner().referrers.len() + ), + }; + assert_eq!(failure.code(), Code::ResourceExhausted); + } + if !operation_id.is_empty() { + let outcome = client + .get_operation(request( + pb::GetOperationRequest { operation_id }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_eq!(outcome.status, pb::OperationStatus::Rejected as i32); + assert_eq!(outcome.changeset_id, ""); + } + assert_eq!( + client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: reference(&target).id + }, + READER, + None + )) + .await + .unwrap() + .into_inner() + .entity + .as_ref(), + Some(&target) + ); + } + for start in (0..903).step_by(128) { + let ops: Vec<_> = (start..(start + 128).min(903)) + .map(|index| MutationOp::Create { + kind: pb::EntityKind::Person, + entity: entity(pb::entity::Kind::Person(pb::Person { + id: Some(id(&format!("profile-{index}"))), + title: Some(pb::DirectoryDisplayName { + value: "Profile".into(), + }), + ..Default::default() + })), + }) + .collect(); + store + .batch_apply(&ops, WriteContext::baseline()) + .await + .unwrap(); + } + let query = pb::ListSubjectDecisionsRequest { + subject: Some(selected), + status: None, + }; + let boundary = client + .list_subject_decisions(request(query.clone(), READER, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(boundary.declarations, Vec::::new()); + assert!(!boundary.incomplete); + store + .create( + pb::EntityKind::Person, + &entity(pb::entity::Kind::Person(pb::Person { + id: Some(id("beyond-profile")), + title: Some(pb::DirectoryDisplayName { + value: "Profile".into(), + }), + ..Default::default() + })), + WriteContext::baseline(), + ) + .await + .unwrap(); + let beyond = client + .list_subject_decisions(request(query, READER, None)) + .await + .unwrap_err(); + assert_eq!(beyond.code(), Code::ResourceExhausted); + assert!(beyond.message().contains("entity budget")); + let report = client + .delete_entity(request(report_request, ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(report.referrers.len(), 4_096); + assert!(report.referrers_incomplete); +} + +#[tokio::test(flavor = "multi_thread")] +async fn referenced_delete_refuses_as_referenced_beyond_the_report_budget() { + use trogon_atlas_store::{store::MutationOp, Store, WriteContext}; + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let mut client = client_with_store(store.clone(), 10_000).await; + let target = put(&mut client, read_model("hub"), None) + .await + .entity + .unwrap(); + for start in (0..4_097).step_by(128) { + let ops: Vec<_> = (start..(start + 128).min(4_097)) + .map(|index| { + let mut value = document(&format!("note-{index}"), subject(reference(&target))); + if let Some(pb::entity::Kind::Document(value)) = &mut value.kind { + value.anchors.clear(); + } + MutationOp::Create { + kind: pb::EntityKind::Document, + entity: value, + } + }) + .collect(); + store + .batch_apply(&ops, WriteContext::baseline()) + .await + .unwrap(); + } + let failure = delete(&mut client, &target, false, None).await.unwrap_err(); + assert_eq!(failure.code(), Code::FailedPrecondition, "{failure:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_batch_delete_reporting_shares_output_budget_before_mutation() { + use trogon_atlas_store::{store::MutationOp, Store, WriteContext}; + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let mut client = client_with_store(store.clone(), 10_000).await; + let mut targets = Vec::new(); + for slug in ["first", "second"] { + targets.push( + put(&mut client, read_model(slug), None) + .await + .entity + .unwrap(), + ); + } + for start in (0..2_049).step_by(128) { + let ops: Vec<_> = (start..(start + 128).min(2_049)) + .map(|index| { + let mut value = document(&format!("note-{index}"), subject(reference(&targets[0]))); + if let Some(pb::entity::Kind::Document(value)) = &mut value.kind { + value.anchors.clear(); + value.subjects.push(pb::SubjectAssociation { + subject: Some(subject(reference(&targets[1]))), + ..Default::default() + }); + } + MutationOp::Create { + kind: pb::EntityKind::Document, + entity: value, + } + }) + .collect(); + store + .batch_apply(&ops, WriteContext::baseline()) + .await + .unwrap(); + } + for validate_only in [true, false] { + let operation_id = if validate_only { + String::new() + } else { + uuid::Uuid::now_v7().to_string() + }; + let batch = pb::BatchMutateRequest { + ops: targets + .iter() + .map(|value| pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: reference(value).id, + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })), + }) + .collect(), + validate_only, + operation_id: operation_id.clone(), + }; + let failure = match client.batch_mutate(request(batch, ADMIN, None)).await { + Err(failure) => failure, + Ok(response) => panic!( + "Expected shared report-budget refusal, received batch status {}", + response.into_inner().status + ), + }; + assert_eq!(failure.code(), Code::ResourceExhausted); + if !operation_id.is_empty() { + let outcome = client + .get_operation(request( + pb::GetOperationRequest { operation_id }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + assert_ne!(outcome.status, pb::OperationStatus::Applied as i32); + assert_eq!(outcome.changeset_id, ""); + } + for target in &targets { + assert_eq!( + client + .get_entity(request( + pb::GetEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: reference(target).id + }, + READER, + None + )) + .await + .unwrap() + .into_inner() + .entity + .as_ref(), + Some(target) + ); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_delete_report_incompleteness_survives_force_batch_preview_and_replay() { + use trogon_atlas_store::{store::MutationOp, Store, WriteContext}; + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let mut client = client_with_store(store.clone(), 10_000).await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let note = put(&mut client, document("note", selected), None) + .await + .entity + .unwrap(); + let query = pb::DeleteEntityRequest { + kind: pb::EntityKind::ReadModel as i32, + id: reference(&target).id, + mode: pb::delete_entity_request::Mode::DryRun as i32, + ..Default::default() + }; + let complete = client + .delete_entity(request(query.clone(), ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert_eq!(complete.referrers.len(), 1); + assert!(!complete.referrers_incomplete); + for start in (0..5_000).step_by(128) { + let ops: Vec<_> = (start..(start + 128).min(5_000)) + .map(|index| MutationOp::Create { + kind: pb::EntityKind::Person, + entity: entity(pb::entity::Kind::Person(pb::Person { + id: Some(id(&format!("profile-{index}"))), + title: Some(pb::DirectoryDisplayName { + value: "Profile".into(), + }), + ..Default::default() + })), + }) + .collect(); + store + .batch_apply(&ops, WriteContext::baseline()) + .await + .unwrap(); + } + let incomplete = client + .delete_entity(request(query.clone(), ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert!( + incomplete.referrers_incomplete, + "visible-row fallback must be explicit" + ); + assert_eq!( + delete(&mut client, &target, false, None) + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + let batch = pb::BatchMutateRequest { + ops: vec![pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { + kind: pb::EntityKind::Person as i32, + id: Some(id("profile-0")), + ..Default::default() + })), + }], + validate_only: true, + ..Default::default() + }; + let preview = client + .batch_mutate(request(batch.clone(), ADMIN, None)) + .await + .unwrap() + .into_inner(); + let Some(pb::batch_mutate_op_result::Result::Delete(deleted)) = &preview.results[0].result + else { + panic!("delete preview"); + }; + assert!(deleted.referrers_incomplete); + let applied = client + .batch_mutate(request( + pb::BatchMutateRequest { + validate_only: false, + ..batch + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner(); + let Some(pb::batch_mutate_op_result::Result::Delete(deleted)) = &applied.results[0].result + else { + panic!("delete result"); + }; + assert!(deleted.referrers_incomplete); + store + .create( + pb::EntityKind::Person, + &entity(pb::entity::Kind::Person(pb::Person { + id: Some(id("replacement-profile")), + title: Some(pb::DirectoryDisplayName { + value: "Profile".into(), + }), + ..Default::default() + })), + WriteContext::baseline(), + ) + .await + .unwrap(); + let forced = pb::DeleteEntityRequest { + kind: pb::EntityKind::Document as i32, + id: reference(¬e).id, + mode: pb::delete_entity_request::Mode::Force as i32, + operation_id: uuid::Uuid::now_v7().to_string(), + ..Default::default() + }; + let fresh = client + .delete_entity(request(forced.clone(), ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert!(fresh.referrers_incomplete); + assert!(!fresh.operation_receipt.unwrap().replayed); + let replay = client + .delete_entity(request(forced, ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert!(replay.referrers_incomplete); + assert!(replay.operation_receipt.unwrap().replayed); + let complete = client + .delete_entity(request(query, ADMIN, None)) + .await + .unwrap() + .into_inner(); + assert!(!complete.referrers_incomplete); + assert_eq!(complete.referrers, Vec::::new()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_validation_rules_describe_and_enrich_retained_dangling_subjects() { + let mut client = client_with_cap(1_000).await; + let target = put(&mut client, read_model("receipt"), None) + .await + .entity + .unwrap(); + let choice = put( + &mut client, + decision("choice", subject(reference(&target)), false), + None, + ) + .await + .entity + .unwrap(); + delete(&mut client, &target, true, None).await.unwrap(); + let catalog = client + .list_validation_rules(request(pb::ListValidationRulesRequest {}, READER, None)) + .await + .unwrap() + .into_inner(); + let rule = catalog + .rules + .iter() + .find(|rule| rule.code == "CATALOG_REFERENCE_UNRESOLVED") + .expect("emitted Catalog code must have a discoverable rule"); + assert_ne!(rule.title, ""); + assert_ne!(rule.doc, ""); + assert_eq!(rule.category, "architecture"); + assert_eq!( + rule.default_severity, + pb::validation_issue::Severity::Error as i32 + ); + let report = client + .validate_project(request( + pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "catalog".into(), + )), + }, + READER, + None, + )) + .await + .unwrap() + .into_inner(); + let findings: Vec<_> = report + .reports + .iter() + .flat_map(|report| &report.issues) + .filter(|issue| { + issue.code == rule.code && issue.subject.as_ref() == Some(&reference(&choice)) + }) + .collect(); + assert_ne!(findings, Vec::<&pb::ValidationIssue>::new()); + for finding in findings { + assert_eq!(finding.rule_title, rule.title); + assert_eq!(finding.rule_category, rule.category); + assert_eq!(finding.rule_default_severity, rule.default_severity); + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/decision_document_fixture.rs b/rsworkspace/crates/trogon-atlas-server/tests/decision_document_fixture.rs new file mode 100644 index 00000000..a377c410 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/decision_document_fixture.rs @@ -0,0 +1,614 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::{fmt::Write as _, sync::Arc}; + +use pb::event_model_service_server::EventModelService; +use sha2::{Digest, Sha256}; +use tonic::Request; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "responsibility-fixture".into(), + slug: slug.into(), + version: 1, + } +} + +fn title(value: &str) -> pb::CatalogTitle { + pb::CatalogTitle { + value: value.into(), + } +} + +fn text(value: &str) -> pb::CatalogText { + pb::CatalogText { + value: value.into(), + } +} + +fn reference(entity: &pb::Entity) -> pb::EntityRef { + trogon_atlas_server::conv::entity_to_ref(entity).unwrap() +} + +fn uid(entity: &pb::Entity) -> String { + entity.system.as_ref().unwrap().uid.clone() +} + +fn subject(selection: pb::catalog_subject::Selection) -> pb::CatalogSubject { + pb::CatalogSubject { + selection: Some(selection), + } +} + +fn associations(subjects: &[pb::CatalogSubject]) -> Vec { + subjects + .iter() + .map(|value| pb::SubjectAssociation { + subject: Some(value.clone()), + ..Default::default() + }) + .collect() +} + +fn retained(content: &str) -> pb::RetainedDocumentText { + let mut digest = String::with_capacity(64); + for byte in Sha256::digest(content.as_bytes()) { + write!(digest, "{byte:02x}").unwrap(); + } + pb::RetainedDocumentText { + media_type: Some(pb::MediaType { + value: "text/markdown".into(), + }), + content: content.into(), + digest: Some(pb::ContentDigest { sha256: digest }), + } +} + +async fn put(service: &EventModelServiceImpl, kind: pb::entity::Kind) -> pb::PutEntityResponse { + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(pb::Entity { + kind: Some(kind), + ..Default::default() + }), + ..Default::default() + })) + .await + .unwrap() + .into_inner() +} + +async fn native_subjects(service: &EventModelServiceImpl) -> Vec { + let receipt = put( + service, + pb::entity::Kind::ReadModel(pb::ReadModel { + id: Some(id("receipt")), + title: "Receipt".into(), + ..Default::default() + }), + ) + .await + .entity + .unwrap(); + put( + service, + pb::entity::Kind::Processor(pb::Processor { + id: Some(id("notifier")), + title: "Notifier".into(), + ..Default::default() + }), + ) + .await; + let model_ref = || pb::ReadModelRef { + id: Some(id("receipt")), + }; + let observed = put( + service, + pb::entity::Kind::AutomationSlice(pb::AutomationSlice { + id: Some(id("observe")), + title: "Observe receipt".into(), + source_read_models: (0..2) + .map(|_| pb::ReadModelEdge { + read_model: Some(model_ref()), + ..Default::default() + }) + .collect(), + processor: Some(pb::ProcessorEdge { + processor: Some(pb::ProcessorRef { + id: Some(id("notifier")), + }), + ..Default::default() + }), + ..Default::default() + }), + ) + .await + .entity + .unwrap(); + let specification = put( + service, + pb::entity::Kind::Specification(pb::Specification { + id: Some(id("receipt-contract")), + title: "Receipt contract".into(), + messages: vec![pb::ContractMessage { + name: Some(pb::MemberName { + value: "Receipt".into(), + }), + mapping: Some(pb::MessageReadModelMapping { + read_model: Some(model_ref()), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .entity + .unwrap(); + let product = put( + service, + pb::entity::Kind::DataProduct(pb::DataProduct { + id: Some(id("receipts")), + title: "Receipts".into(), + outputs: vec![pb::DataOutputPort { + name: Some(pb::PortName { + value: "Receipts".into(), + }), + data: Some(pb::OutputDataSelection { + read_model: Some(model_ref()), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .entity + .unwrap(); + let Some(pb::entity::Kind::AutomationSlice(slice)) = &observed.kind else { + panic!("slice") + }; + let Some(pb::entity::Kind::Specification(spec)) = &specification.kind else { + panic!("specification") + }; + let Some(pb::entity::Kind::DataProduct(data)) = &product.kind else { + panic!("product") + }; + let mut subjects = vec![subject(pb::catalog_subject::Selection::Entity(reference( + &receipt, + )))]; + for edge in &slice.source_read_models { + subjects.push(subject(pb::catalog_subject::Selection::Connection( + pb::ConnectionRef { + owner: Some(reference(&observed)), + owner_uid: uid(&observed), + id: edge.connection_id.clone(), + }, + ))); + } + subjects.push(subject(pb::catalog_subject::Selection::Member( + pb::ContractMemberRef { + specification: Some(pb::SpecificationRef { + id: Some(id("receipt-contract")), + }), + owner_uid: uid(&specification), + kind: pb::ContractMemberKind::Message as i32, + member_id: spec.messages[0].member_id.clone(), + expected_digest: None, + }, + ))); + subjects.push(subject(pb::catalog_subject::Selection::Port( + pb::DataPortRef { + product: Some(pb::DataProductRef { + id: Some(id("receipts")), + }), + owner_uid: uid(&product), + direction: pb::DataPortDirection::Output as i32, + port_id: data.outputs[0].port_id.clone(), + }, + ))); + assert_ne!(subjects[1], subjects[2]); + subjects +} + +async fn adopt( + service: &EventModelServiceImpl, + subjects: &[pb::CatalogSubject], + documents: Vec, +) -> pb::Entity { + let proposed = put( + service, + pb::entity::Kind::Decision(pb::Decision { + id: Some(id("receipt-delivery-choice")), + title: Some(title("Receipt delivery")), + subjects: associations(subjects), + status: pb::DecisionStatus::Proposed as i32, + context: Some(text("Choose how the receipt is delivered.")), + rationale: Some(text( + "A declared observation preserves the integration boundary.", + )), + options: vec![pb::DecisionOption { + title: Some(title("Observe receipt")), + description: Some(text("Use the existing processor observation.")), + ..Default::default() + }], + documents, + ..Default::default() + }), + ) + .await; + let mut entity = proposed.entity.unwrap(); + let Some(pb::entity::Kind::Decision(decision)) = &mut entity.kind else { + panic!("decision") + }; + decision.status = pb::DecisionStatus::Accepted as i32; + decision.selected_option = decision.options[0].option_id.clone(); + decision.consequences.push(pb::DecisionConsequence { + kind: pb::decision_consequence::Kind::Risk as i32, + text: Some(text("Delivery still depends on runtime processing.")), + }); + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(entity), + if_match: proposed.etag, + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .entity + .unwrap() +} + +#[tokio::test] +async fn decisions_and_documents_preserve_connected_subjects_and_byte_pins() { + let service = EventModelServiceImpl::try_new( + Arc::new(trogon_atlas_testsupport::shared().await.store().await), + ServerMetrics::default(), + ) + .unwrap() + .with_snapshot_cap(0); + let subjects = native_subjects(&service).await; + let document = put( + &service, + pb::entity::Kind::Document(pb::Document { + id: Some(id("receipt-delivery-note")), + title: Some(title("Receipt delivery note")), + subjects: associations(&subjects), + body: Some(pb::document::Body::Retained(retained( + "# Receipt delivery\n", + ))), + anchors: vec![pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: "delivery".into(), + }), + label: Some(title("Delivery")), + ..Default::default() + }], + ..Default::default() + }), + ) + .await + .entity + .unwrap(); + let Some(pb::entity::Kind::Document(value)) = &document.kind else { + panic!("document") + }; + let Some(pb::document::Body::Retained(body)) = &value.body else { + panic!("retained") + }; + let whole = pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Document( + pb::DocumentRef { + id: Some(id("receipt-delivery-note")), + }, + )), + }; + let pinned = pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Anchor( + pb::DocumentAnchorRef { + document: Some(pb::DocumentRef { + id: Some(id("receipt-delivery-note")), + }), + owner_uid: uid(&document), + anchor_id: value.anchors[0].anchor_id.clone(), + expected_digest: body.digest.clone(), + }, + )), + }; + let decision = adopt( + &service, + &subjects, + [whole.clone(), pinned.clone()] + .into_iter() + .map(|selection| pb::SupportingDocument { + document: Some(selection), + ..Default::default() + }) + .collect(), + ) + .await; + let team = put( + &service, + pb::entity::Kind::Team(pb::Team { + id: Some(id("receipts-team")), + title: Some(pb::DirectoryDisplayName { + value: "Receipts team".into(), + }), + responsibilities: [ + (&decision, pb::ResponsibilityKind::Owner), + (&document, pb::ResponsibilityKind::Steward), + ] + .into_iter() + .map(|(entity, kind)| pb::Responsibility { + kind: kind as i32, + subject: Some(pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Entity(reference( + entity, + ))), + }), + ..Default::default() + }) + .collect(), + ..Default::default() + }), + ) + .await + .entity + .unwrap(); + for chosen in &subjects { + let decisions = service + .list_subject_decisions(Request::new(pb::ListSubjectDecisionsRequest { + subject: Some(chosen.clone()), + status: Some(pb::DecisionStatus::Accepted as i32), + })) + .await + .unwrap() + .into_inner(); + assert!(!decisions.incomplete); + assert_eq!( + decisions.subject_state, + pb::CatalogResolutionState::Resolved as i32 + ); + assert_eq!(decisions.declarations.len(), 1); + assert_eq!( + decisions.declarations[0].owner.as_ref().unwrap().uid, + uid(&decision) + ); + let Some(pb::catalog_declaration::Declaration::Subject(association)) = + &decisions.declarations[0].declaration + else { + panic!("subject association") + }; + assert_eq!(association.subject.as_ref(), Some(chosen)); + assert!(association.connection_id.is_some()); + let scope = decisions.scope.unwrap(); + assert_ne!(scope.revision, ""); + assert!(scope.visited_subjects.contains(chosen)); + let documents = service + .list_subject_documents(Request::new(pb::ListSubjectDocumentsRequest { + subject: Some(chosen.clone()), + })) + .await + .unwrap() + .into_inner(); + assert!(!documents.incomplete); + assert_eq!(documents.declarations.len(), 1); + assert_eq!( + documents.declarations[0].owner.as_ref().unwrap().uid, + uid(&document) + ); + } + for selection in [&whole, &pinned] { + let incoming = service + .list_incoming_document_declarations(Request::new( + pb::ListIncomingDocumentDeclarationsRequest { + document: Some(selection.clone()), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(!incoming.incomplete); + assert_eq!(incoming.declarations.len(), 1); + let Some(pb::catalog_declaration::Declaration::Supporting(supporting)) = + &incoming.declarations[0].declaration + else { + panic!("supporting document") + }; + assert_eq!(supporting.document.as_ref(), Some(selection)); + } + let detail = service + .get_document_detail(Request::new(pb::GetDocumentDetailRequest { + document: Some(pinned.clone()), + })) + .await + .unwrap() + .into_inner(); + assert_eq!( + detail.selection_state, + pb::CatalogResolutionState::Resolved as i32 + ); + assert_eq!(detail.anchor.as_ref(), value.anchors.first()); + for entity in [&decision, &document] { + let assignments = service + .list_subject_assignments(Request::new(pb::ListSubjectAssignmentsRequest { + subject: Some(pb::ResponsibilitySubject { + selection: Some(pb::responsibility_subject::Selection::Entity(reference( + entity, + ))), + }), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert_eq!(assignments.assignments.len(), 1); + assert_eq!(assignments.assignments[0].assignee_uid, uid(&team)); + } + let incoming = service + .get_incoming_references(Request::new(pb::GetReferencesRequest { + kind: pb::EntityKind::Document as i32, + id: Some(id("receipt-delivery-note")), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert!(incoming + .references + .iter() + .any(|hit| hit.document_selection.as_ref() == Some(&pinned) + && hit.from_uid == uid(&decision) + && hit.connection_id.is_some())); + let impact = service + .get_impact(Request::new(pb::GetImpactRequest { + root: Some(reference(&document)), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert!(impact + .nodes + .iter() + .any(|node| node.entity.as_ref() == Some(&reference(&decision)) + && node.path.iter().any(|hit| hit.document_selection.is_some()))); + let current = service + .get_entity(Request::new(pb::GetEntityRequest { + kind: pb::EntityKind::AutomationSlice as i32, + id: Some(id("observe")), + })) + .await + .unwrap() + .into_inner(); + let mut reordered = current.entity.unwrap(); + let Some(pb::entity::Kind::AutomationSlice(slice)) = &mut reordered.kind else { + panic!("slice") + }; + slice.source_read_models.reverse(); + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(reordered), + if_match: current.etag, + ..Default::default() + })) + .await + .unwrap(); + for occurrence in &subjects[1..3] { + let result = service + .list_subject_decisions(Request::new(pb::ListSubjectDecisionsRequest { + subject: Some(occurrence.clone()), + status: None, + })) + .await + .unwrap() + .into_inner(); + assert_eq!(result.declarations.len(), 1); + let Some(pb::catalog_declaration::Declaration::Subject(association)) = + &result.declarations[0].declaration + else { + panic!("subject") + }; + assert_eq!(association.subject.as_ref(), Some(occurrence)); + } + let current = service + .get_entity(Request::new(pb::GetEntityRequest { + kind: pb::EntityKind::Document as i32, + id: Some(id("receipt-delivery-note")), + })) + .await + .unwrap() + .into_inner(); + let mut edited = current.entity.unwrap(); + let Some(pb::entity::Kind::Document(doc)) = &mut edited.kind else { + panic!("document") + }; + doc.body = Some(pb::document::Body::Retained(retained( + "# Receipt delivery\nUpdated rationale.\n", + ))); + service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(edited), + if_match: current.etag, + ..Default::default() + })) + .await + .unwrap(); + let detail = service + .get_decision_detail(Request::new(pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: Some(id("receipt-delivery-choice")), + }), + follow_replacement: false, + })) + .await + .unwrap() + .into_inner(); + for (selection, expected) in [ + (&pinned, pb::CatalogResolutionState::Unresolved), + (&whole, pb::CatalogResolutionState::Resolved), + ] { + assert!(detail.declarations.iter().any(|entry| { + matches!(&entry.declaration, Some(pb::catalog_declaration::Declaration::Supporting(value)) if value.document.as_ref() == Some(selection)) + && entry.state == expected as i32 + })); + } + let refusal = service + .delete_entity(Request::new(pb::DeleteEntityRequest { + kind: pb::EntityKind::Document as i32, + id: Some(id("receipt-delivery-note")), + mode: pb::delete_entity_request::Mode::FailIfReferenced as i32, + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(refusal.code(), tonic::Code::FailedPrecondition); + service + .delete_entity(Request::new(pb::DeleteEntityRequest { + kind: pb::EntityKind::Document as i32, + id: Some(id("receipt-delivery-note")), + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + })) + .await + .unwrap(); + let retained = service + .get_decision_detail(Request::new(pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: Some(id("receipt-delivery-choice")), + }), + follow_replacement: false, + })) + .await + .unwrap() + .into_inner(); + assert_eq!( + retained + .declarations + .iter() + .filter(|entry| matches!( + entry.declaration, + Some(pb::catalog_declaration::Declaration::Supporting(_)) + )) + .count(), + 2 + ); + assert!(retained + .declarations + .iter() + .filter(|entry| matches!( + entry.declaration, + Some(pb::catalog_declaration::Declaration::Supporting(_)) + )) + .all(|entry| entry.state == pb::CatalogResolutionState::Unavailable as i32)); + for selection in [&whole, &pinned] { + assert!(retained.declarations.iter().any(|entry| { + matches!(&entry.declaration, Some(pb::catalog_declaration::Declaration::Supporting(value)) if value.document.as_ref() == Some(selection)) + })); + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/decision_document_replacement_diagnostics.rs b/rsworkspace/crates/trogon-atlas-server/tests/decision_document_replacement_diagnostics.rs new file mode 100644 index 00000000..91ac0e9b --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/decision_document_replacement_diagnostics.rs @@ -0,0 +1,529 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::Arc; + +use pb::{ + event_model_service_client::EventModelServiceClient, + event_model_service_server::EventModelServiceServer, +}; +use tokio::net::TcpListener; +use tonic::{service::interceptor::InterceptedService, transport::Channel, Request}; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{ + auth::{AuthzLayer, BearerAuth, TokenRegistry}, + metrics::ServerMetrics, + service::EventModelServiceImpl, +}; + +const ADMIN: &str = "catalog-admin"; +const READER: &str = "catalog-reader"; +const LIMITED: &str = "catalog-limited"; +type Client = EventModelServiceClient; +fn request(body: T, token: &str, branch: Option<&str>) -> Request { + let mut req = Request::new(body); + req.metadata_mut() + .insert("authorization", format!("Bearer {token}").parse().unwrap()); + if let Some(branch) = branch { + req.metadata_mut() + .insert("x-trogon-atlas-branch", branch.parse().unwrap()); + } + req +} +async fn client() -> Client { + client_with_cap(5_000).await +} +async fn client_with_cap(cap: usize) -> Client { + client_with_store( + Arc::new(trogon_atlas_testsupport::shared().await.store().await), + cap, + ) + .await +} +async fn client_with_store(store: Arc, cap: usize) -> Client { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let registry = TokenRegistry::from_file_contents( + toml::from_str(&format!( + r#" +[principals.admin] +role = "admin" +tokens = ["{ADMIN}"] +[principals.reader] +role = "reader" +tokens = ["{READER}"] +[principals.limited] +role = "writer" +parent = "visible" +tokens = ["{LIMITED}"] +"# + )) + .unwrap(), + ) + .unwrap(); + let service = EventModelServiceImpl::try_new(store, ServerMetrics::default()) + .unwrap() + .with_snapshot_cap(cap); + let grpc = InterceptedService::new( + tower::ServiceBuilder::new() + .layer(AuthzLayer) + .service(EventModelServiceServer::new(service)), + BearerAuth::new(Arc::new(registry)), + ); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(grpc) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + Client::connect(format!("http://{addr}")).await.unwrap() +} + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "catalog".into(), + slug: slug.into(), + version: 1, + } +} +fn entity(kind: pb::entity::Kind) -> pb::Entity { + pb::Entity { + kind: Some(kind), + ..Default::default() + } +} +fn reference(value: &pb::Entity) -> pb::EntityRef { + trogon_atlas_server::conv::entity_to_ref(value).unwrap() +} +fn subject(reference: pb::EntityRef) -> pb::CatalogSubject { + pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(reference)), + } +} +fn title(value: &str) -> pb::CatalogTitle { + pb::CatalogTitle { + value: value.into(), + } +} +fn text(value: &str) -> pb::CatalogText { + pb::CatalogText { + value: value.into(), + } +} +fn read_model(slug: &str) -> pb::Entity { + entity(pb::entity::Kind::ReadModel(pb::ReadModel { + id: Some(id(slug)), + title: slug.into(), + ..Default::default() + })) +} +fn decision(slug: &str, selected: pb::CatalogSubject, adopted: bool) -> pb::Entity { + let option_id = pb::LocalValueId { + value: uuid::Uuid::now_v7().to_string(), + }; + entity(pb::entity::Kind::Decision(pb::Decision { + id: Some(id(slug)), + title: Some(title(slug)), + subjects: vec![pb::SubjectAssociation { + subject: Some(selected), + ..Default::default() + }], + status: if adopted { + pb::DecisionStatus::Accepted + } else { + pb::DecisionStatus::Proposed + } as i32, + context: Some(text("Choose delivery")), + rationale: Some(text("Keep delivery explicit")), + options: vec![pb::DecisionOption { + option_id: Some(option_id.clone()), + title: Some(title("Native delivery")), + description: Some(text("Retain the selected choice")), + }], + selected_option: adopted.then_some(option_id), + consequences: if adopted { + vec![pb::DecisionConsequence { + kind: pb::decision_consequence::Kind::Risk as i32, + text: Some(text("Delivery may need repair")), + }] + } else { + Vec::new() + }, + ..Default::default() + })) +} +async fn put( + client: &mut Client, + value: pb::Entity, + branch: Option<&str>, +) -> pb::PutEntityResponse { + client + .put_entity(request( + pb::PutEntityRequest { + entity: Some(value), + ..Default::default() + }, + ADMIN, + branch, + )) + .await + .unwrap() + .into_inner() +} + +fn superseded(slug: &str, selected: pb::CatalogSubject, replacement: &pb::Entity) -> pb::Entity { + let mut owner = decision(slug, selected, true); + let Some(pb::entity::Kind::Decision(value)) = &mut owner.kind else { + unreachable!() + }; + value.status = pb::DecisionStatus::Superseded as i32; + value.replaced_by = Some(pb::DecisionReplacement { + decision: Some(pb::DecisionRef { + id: reference(replacement).id, + }), + ..Default::default() + }); + owner +} +async fn findings(client: &mut Client, branch: Option<&str>) -> Vec { + client + .validate_project(request( + pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "catalog".into(), + )), + }, + READER, + branch, + )) + .await + .unwrap() + .into_inner() + .reports + .into_iter() + .flat_map(|report| report.issues) + .filter(|issue| issue.code == "CATALOG_REFERENCE_UNRESOLVED") + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn retained_replacement_chains_report_changed_terminal_status_in_baseline_and_branch() { + let mut client = client().await; + let model = put(&mut client, read_model("model"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&model)); + let terminal = put( + &mut client, + decision("terminal", selected.clone(), true), + None, + ) + .await + .entity + .unwrap(); + let middle = put( + &mut client, + superseded("middle", selected.clone(), &terminal), + None, + ) + .await + .entity + .unwrap(); + let first = put(&mut client, superseded("first", selected, &middle), None) + .await + .entity + .unwrap(); + assert_eq!(findings(&mut client, None).await, []); + client + .create_branch(request( + pb::CreateBranchRequest { + name: "replacement-change".into(), + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap(); + let mut changed = terminal.clone(); + let Some(pb::entity::Kind::Decision(value)) = &mut changed.kind else { + unreachable!() + }; + value.status = pb::DecisionStatus::Withdrawn as i32; + value.selected_option = None; + value.consequences.clear(); + for branch in [Some("replacement-change"), None] { + put(&mut client, changed.clone(), branch).await; + let issues = findings(&mut client, branch).await; + for owner in [&middle, &first] { + let issue = issues + .iter() + .find(|issue| { + issue.subject.as_ref() == Some(&reference(owner)) + && issue.subject_field == "replaced_by.decision" + }) + .unwrap_or_else(|| { + panic!( + "missing replacement diagnostic for {:?} on {branch:?}: {issues:?}", + reference(owner) + ) + }); + assert_ne!(issue.rule_title, ""); + assert_eq!(issue.severity, pb::validation_issue::Severity::Error as i32); + } + if branch.is_some() { + assert_eq!(findings(&mut client, None).await, []); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn transitive_replacement_hydration_is_exact_beyond_the_snapshot_cap() { + let mut client = client_with_cap(2).await; + let model = put(&mut client, read_model("model"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&model)); + let terminal = put( + &mut client, + decision("z-terminal", selected.clone(), true), + None, + ) + .await + .entity + .unwrap(); + let middle = put( + &mut client, + superseded("y-middle", selected.clone(), &terminal), + None, + ) + .await + .entity + .unwrap(); + let first = put(&mut client, superseded("a-first", selected, &middle), None) + .await + .entity + .unwrap(); + assert_eq!(findings(&mut client, None).await, []); + let mut changed = terminal; + let Some(pb::entity::Kind::Decision(value)) = &mut changed.kind else { + unreachable!() + }; + value.status = pb::DecisionStatus::Withdrawn as i32; + value.selected_option = None; + value.consequences.clear(); + put(&mut client, changed, None).await; + let issues = findings(&mut client, None).await; + assert!( + issues + .iter() + .any(|issue| issue.subject.as_ref() == Some(&reference(&first)) + && issue.subject_field == "replaced_by.decision"), + "{issues:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn hidden_replacement_terminal_remains_generic_for_visible_predecessors() { + let mut client = client().await; + let mut namespaces = Vec::new(); + for (name, parent) in [ + ("catalog-visible", "visible"), + ("catalog-private", "hidden"), + ] { + namespaces.push( + client + .register_namespace(request( + pb::RegisterNamespaceRequest { + name: name.into(), + parent: parent.into(), + }, + ADMIN, + None, + )) + .await + .unwrap() + .into_inner() + .namespace + .unwrap() + .id, + ); + } + let mut model = read_model("model"); + if let Some(pb::entity::Kind::ReadModel(value)) = &mut model.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[0]); + } + let model = put(&mut client, model, None).await.entity.unwrap(); + let selected = subject(reference(&model)); + let mut terminal = decision("private-terminal", selected.clone(), true); + if let Some(pb::entity::Kind::Decision(value)) = &mut terminal.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[1]); + } + let terminal = put(&mut client, terminal, None).await.entity.unwrap(); + let mut middle = superseded("middle", selected.clone(), &terminal); + if let Some(pb::entity::Kind::Decision(value)) = &mut middle.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[0]); + } + let middle = put(&mut client, middle, None).await.entity.unwrap(); + let mut first = superseded("first", selected, &middle); + if let Some(pb::entity::Kind::Decision(value)) = &mut first.kind { + value + .id + .as_mut() + .unwrap() + .namespace + .clone_from(&namespaces[0]); + } + let first = put(&mut client, first, None).await.entity.unwrap(); + let report = client + .validate_project(request( + pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + namespaces[0].clone(), + )), + }, + LIMITED, + None, + )) + .await + .unwrap() + .into_inner(); + for owner in [&first, &middle] { + let issue = report + .reports + .iter() + .flat_map(|report| &report.issues) + .find(|issue| { + issue.code == "CATALOG_REFERENCE_UNRESOLVED" + && issue.subject.as_ref() == Some(&reference(owner)) + && issue.subject_field == "replaced_by.decision" + }) + .expect("hidden terminal prevents confirmation"); + assert_eq!( + issue.message, + "Replacement chain is absent, stale or unavailable" + ); + } + let report_json = format!("{report:?}"); + assert!(!report_json.contains("private-terminal")); + assert!(!report_json.contains(&namespaces[1])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn missing_replacement_emits_one_selection_diagnostic() { + let mut client = client().await; + let model = put(&mut client, read_model("model"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&model)); + let terminal = put( + &mut client, + decision("terminal", selected.clone(), true), + None, + ) + .await + .entity + .unwrap(); + let first = put(&mut client, superseded("first", selected, &terminal), None) + .await + .entity + .unwrap(); + client + .delete_entity(request( + pb::DeleteEntityRequest { + kind: pb::EntityKind::Decision as i32, + id: reference(&terminal).id, + mode: pb::delete_entity_request::Mode::Force as i32, + ..Default::default() + }, + ADMIN, + None, + )) + .await + .unwrap(); + let issues = findings(&mut client, None).await; + assert_eq!( + issues + .iter() + .filter(|issue| issue.subject.as_ref() == Some(&reference(&first)) + && issue.subject_field == "replaced_by.decision") + .count(), + 1, + "{issues:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn retained_replacement_cycle_is_a_bounded_diagnostic() { + use trogon_atlas_store::{Store, WriteContext}; + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + let mut client = client_with_store(store.clone(), 5_000).await; + let model = put(&mut client, read_model("model"), None) + .await + .entity + .unwrap(); + let selected = subject(reference(&model)); + let terminal = put( + &mut client, + decision("terminal", selected.clone(), true), + None, + ) + .await + .entity + .unwrap(); + let first = put(&mut client, superseded("first", selected, &terminal), None) + .await + .entity + .unwrap(); + let mut cyclic = terminal; + let Some(pb::entity::Kind::Decision(value)) = &mut cyclic.kind else { + unreachable!() + }; + value.status = pb::DecisionStatus::Superseded as i32; + value.replaced_by = Some(pb::DecisionReplacement { + decision: Some(pb::DecisionRef { + id: reference(&first).id, + }), + ..Default::default() + }); + store + .put( + pb::EntityKind::Decision, + &cyclic, + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + let issues = tokio::time::timeout( + std::time::Duration::from_secs(5), + findings(&mut client, None), + ) + .await + .unwrap(); + assert!( + issues + .iter() + .any(|issue| issue.subject.as_ref() == Some(&reference(&first)) + && issue.subject_field == "replaced_by.decision"), + "{issues:?}" + ); +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/document_selection_diagnostics.rs b/rsworkspace/crates/trogon-atlas-server/tests/document_selection_diagnostics.rs new file mode 100644 index 00000000..960e70d3 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/document_selection_diagnostics.rs @@ -0,0 +1,262 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::Arc; + +use tokio::net::TcpListener; +use trogon_atlas_proto::{ + self as pb, event_model_service_client::EventModelServiceClient, + event_model_service_server::EventModelServiceServer, +}; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +type Client = EventModelServiceClient; +async fn client() -> Client { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let store = trogon_atlas_testsupport::shared().await.store().await; + let service = + EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(EventModelServiceServer::new(service)) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + Client::connect(format!("http://{addr}")).await.unwrap() +} +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "catalog".into(), + slug: slug.into(), + version: 1, + } +} +fn entity(kind: pb::entity::Kind) -> pb::Entity { + pb::Entity { + kind: Some(kind), + ..Default::default() + } +} +fn reference(value: &pb::Entity) -> pb::EntityRef { + trogon_atlas_server::conv::entity_to_ref(value).unwrap() +} +fn subject(reference: pb::EntityRef) -> pb::CatalogSubject { + pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(reference)), + } +} +fn title(value: &str) -> pb::CatalogTitle { + pb::CatalogTitle { + value: value.into(), + } +} +fn text(value: &str) -> pb::CatalogText { + pb::CatalogText { + value: value.into(), + } +} +fn read_model(slug: &str) -> pb::Entity { + entity(pb::entity::Kind::ReadModel(pb::ReadModel { + id: Some(id(slug)), + title: slug.into(), + ..Default::default() + })) +} +fn document(slug: &str, selected: pb::CatalogSubject) -> pb::Entity { + let content = "# Delivery\nDelivery guidance.\n"; + entity(pb::entity::Kind::Document(pb::Document { + id: Some(id(slug)), + title: Some(title(slug)), + subjects: vec![pb::SubjectAssociation { + subject: Some(selected), + ..Default::default() + }], + body: Some(pb::document::Body::Retained(pb::RetainedDocumentText { + media_type: Some(pb::MediaType { + value: "text/markdown".into(), + }), + content: content.into(), + digest: Some(trogon_atlas_core::specification::content_digest(content)), + })), + anchors: vec![pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: "delivery".into(), + }), + label: Some(title("Delivery")), + ..Default::default() + }], + ..Default::default() + })) +} +fn decision(slug: &str, selected: pb::CatalogSubject) -> pb::Entity { + let option_id = pb::LocalValueId { + value: uuid::Uuid::now_v7().to_string(), + }; + entity(pb::entity::Kind::Decision(pb::Decision { + id: Some(id(slug)), + title: Some(title(slug)), + subjects: vec![pb::SubjectAssociation { + subject: Some(selected), + ..Default::default() + }], + status: pb::DecisionStatus::Proposed as i32, + context: Some(text("Choose delivery")), + rationale: Some(text("Keep delivery explicit")), + options: vec![pb::DecisionOption { + option_id: Some(option_id.clone()), + title: Some(title("Native delivery")), + description: Some(text("Retain the selected choice")), + }], + ..Default::default() + })) +} +async fn put(client: &mut Client, value: pb::Entity) -> pb::PutEntityResponse { + client + .put_entity(pb::PutEntityRequest { + entity: Some(value), + ..Default::default() + }) + .await + .unwrap() + .into_inner() +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_document_diagnostics_identify_each_supporting_and_navigation_occurrence() { + let mut client = client().await; + let target = put(&mut client, read_model("receipt")) + .await + .entity + .unwrap(); + let selected = subject(reference(&target)); + let mut note = document("note", selected.clone()); + if let Some(pb::entity::Kind::Document(value)) = &mut note.kind { + value.anchors.push(pb::DocumentAnchor { + fragment: Some(pb::AnchorFragment { + value: "operations".into(), + }), + label: Some(title("Operations")), + ..Default::default() + }); + } + let note = put(&mut client, note).await.entity.unwrap(); + let Some(pb::entity::Kind::Document(note_value)) = ¬e.kind else { + unreachable!() + }; + let whole = pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Document( + pb::DocumentRef { + id: reference(¬e).id, + }, + )), + }; + let pinned = pb::DocumentSelection { + selection: Some(pb::document_selection::Selection::Anchor( + pb::DocumentAnchorRef { + document: Some(pb::DocumentRef { + id: reference(¬e).id, + }), + owner_uid: note.system.as_ref().unwrap().uid.clone(), + anchor_id: note_value.anchors[0].anchor_id.clone(), + expected_digest: match ¬e_value.body { + Some(pb::document::Body::Retained(body)) => body.digest.clone(), + _ => unreachable!(), + }, + }, + )), + }; + let mut second_pin = pinned.clone(); + if let Some(pb::document_selection::Selection::Anchor(anchor)) = &mut second_pin.selection { + anchor.anchor_id = note_value.anchors[1].anchor_id.clone(); + } + let mut choice = decision("choice", selected.clone()); + if let Some(pb::entity::Kind::Decision(value)) = &mut choice.kind { + value.documents = [whole.clone(), pinned.clone(), second_pin.clone()] + .into_iter() + .map(|document| pb::SupportingDocument { + document: Some(document), + ..Default::default() + }) + .collect(); + } + let choice = put(&mut client, choice).await.entity.unwrap(); + let mut navigation = document("navigation", selected); + if let Some(pb::entity::Kind::Document(value)) = &mut navigation.kind { + value.navigation = [whole, pinned.clone(), second_pin.clone()] + .into_iter() + .map(|document| pb::DocumentNavigation { + document: Some(document), + ..Default::default() + }) + .collect(); + } + let navigation = put(&mut client, navigation).await.entity.unwrap(); + let mut changed = note; + if let Some(pb::entity::Kind::Document(value)) = &mut changed.kind { + let Some(pb::document::Body::Retained(body)) = &mut value.body else { + unreachable!() + }; + body.content.push_str("Changed bytes\n"); + body.digest = Some(trogon_atlas_core::specification::content_digest( + &body.content, + )); + } + put(&mut client, changed).await; + let report = client + .validate_project(pb::ValidateProjectRequest { + scope: Some(pb::validate_project_request::Scope::Namespace( + "catalog".into(), + )), + }) + .await + .unwrap() + .into_inner(); + let mut actual_fields = Vec::new(); + for (owner, prefix) in [(&choice, "documents"), (&navigation, "navigation")] { + let mut fields: Vec<_> = report + .reports + .iter() + .flat_map(|report| &report.issues) + .filter(|issue| { + issue.code == "CATALOG_REFERENCE_UNRESOLVED" + && issue.subject.as_ref() == Some(&reference(owner)) + }) + .map(|issue| issue.subject_field.as_str()) + .collect(); + fields.sort_unstable(); + actual_fields.push((prefix, fields)); + } + assert_eq!( + actual_fields, + vec![ + ( + "documents", + vec!["documents[1].document", "documents[2].document"] + ), + ( + "navigation", + vec!["navigation[1].document", "navigation[2].document"] + ), + ] + ); + for owner in [&choice, &navigation] { + let selections: Vec<_> = match &owner.kind { + Some(pb::entity::Kind::Decision(value)) => value + .documents + .iter() + .map(|association| (&association.connection_id, &association.document)) + .collect(), + Some(pb::entity::Kind::Document(value)) => value + .navigation + .iter() + .map(|association| (&association.connection_id, &association.document)) + .collect(), + _ => unreachable!(), + }; + assert_ne!(selections[1].0, selections[2].0); + assert!(selections[1].0.is_some()); + assert_eq!(selections[1].1.as_ref(), Some(&pinned)); + assert_eq!(selections[2].1.as_ref(), Some(&second_pin)); + } +} diff --git a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs index 24bc744d..a06ba258 100644 --- a/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs +++ b/rsworkspace/crates/trogon-atlas-server/tests/handler_tests.rs @@ -46,6 +46,154 @@ async fn svc() -> EventModelServiceImpl { EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap() } +fn catalog_decision(slug: &str, subjects: Vec) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::Decision(pb::Decision { + id: Some(id("catalog-exact", slug, 1)), + title: Some(pb::CatalogTitle { + value: "Choice".into(), + }), + status: pb::DecisionStatus::Proposed as i32, + context: Some(pb::CatalogText { + value: "Choose an exact subject".into(), + }), + rationale: Some(pb::CatalogText { + value: "Retain the declaration".into(), + }), + options: vec![pb::DecisionOption { + title: Some(pb::CatalogTitle { + value: "Retain".into(), + }), + description: Some(pb::CatalogText { + value: "Keep the selected subject".into(), + }), + ..Default::default() + }], + subjects: subjects + .into_iter() + .map(|subject| pb::SubjectAssociation { + subject: Some(subject), + ..Default::default() + }) + .collect(), + ..Default::default() + })), + ..Default::default() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_entity_subject_does_not_read_unselected_library_dependencies() { + let dependency = id("catalog-exact", "unselected-dependency", 1); + let armed = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let mut backend = FailingBatchStore::with_failure(|| { + StoreError::Backend("unselected dependency must not be hydrated".into()) + }) + .await; + backend.get_failure = Some((dependency.clone(), armed)); + let library = id("catalog-exact", "selected-library", 1); + backend + .inner + .create( + pb::EntityKind::TypeLibrary, + &pb::Entity { + kind: Some(pb::entity::Kind::TypeLibrary(pb::TypeLibrary { + id: Some(library.clone()), + title: "Selected library".into(), + dependencies: vec![pb::TypeLibraryRef { + id: Some(dependency), + }], + ..Default::default() + })), + ..Default::default() + }, + WriteContext::baseline(), + ) + .await + .unwrap(); + let service = EventModelServiceImpl::try_new(Arc::new(backend), ServerMetrics::default()) + .unwrap() + .with_snapshot_cap(0); + let subject = pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(pb::EntityRef { + kind: pb::EntityKind::TypeLibrary as i32, + id: Some(library), + })), + }; + let saved = service + .put_entity(Request::new(pb::PutEntityRequest { + entity: Some(catalog_decision("library-choice", vec![subject.clone()])), + ..Default::default() + })) + .await + .unwrap() + .into_inner() + .entity + .unwrap(); + let detail = service + .get_decision_detail(Request::new(pb::GetDecisionDetailRequest { + decision: Some(pb::DecisionRef { + id: trogon_atlas_server::conv::entity_to_ref(&saved).unwrap().id, + }), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + assert_eq!( + detail.declarations[0].state, + pb::CatalogResolutionState::Resolved as i32 + ); + assert!(detail.scope.unwrap().visited.iter().all(|checked| checked + .owner + .as_ref() + .and_then(|owner| owner.id.as_ref()) + .is_none_or(|id| id.slug != "unselected-dependency"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn catalog_batch_charges_work_before_any_subject_hydration() { + let mut backend = FailingBatchStore::with_failure(|| { + StoreError::Backend("oversized batch must be rejected before subject hydration".into()) + }) + .await; + backend.fail_batches = false; + backend.get_failure = Some(( + id("catalog-exact", "subject-999", 1), + Arc::new(std::sync::atomic::AtomicBool::new(true)), + )); + let service = EventModelServiceImpl::try_new(Arc::new(backend), ServerMetrics::default()) + .unwrap() + .with_snapshot_cap(0); + let subjects: Vec<_> = (0..1_000) + .map(|index| pb::CatalogSubject { + selection: Some(pb::catalog_subject::Selection::Entity(pb::EntityRef { + kind: pb::EntityKind::ReadModel as i32, + id: Some(id("catalog-exact", &format!("subject-{index}"), 1)), + })), + }) + .collect(); + let ops = (0..11) + .map(|index| pb::BatchMutateOp { + op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { + entity: Some(catalog_decision( + &format!("choice-{index}"), + subjects.clone(), + )), + ..Default::default() + })), + }) + .collect(); + let failure = service + .batch_mutate(Request::new(pb::BatchMutateRequest { + ops, + ..Default::default() + })) + .await + .unwrap_err(); + assert_eq!(failure.code(), tonic::Code::ResourceExhausted, "{failure}"); +} + // ===== validate_project tests ===== #[tokio::test(flavor = "multi_thread")] diff --git a/rsworkspace/crates/trogon-atlas-server/tests/reference_pagination.rs b/rsworkspace/crates/trogon-atlas-server/tests/reference_pagination.rs new file mode 100644 index 00000000..dad092dc --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-server/tests/reference_pagination.rs @@ -0,0 +1,193 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::{collections::HashSet, sync::Arc}; + +use pb::{ + event_model_service_client::EventModelServiceClient, + event_model_service_server::EventModelServiceServer, +}; +use tokio::net::TcpListener; +use tonic::{transport::Channel, Request}; +use trogon_atlas_proto as pb; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; +use trogon_atlas_store::{store::WriteContext, Store}; + +const OWNERS: usize = 4_097; +type Client = EventModelServiceClient; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "reference-pages".into(), + slug: slug.into(), + version: 1, + } +} + +fn screen(slug: &str, transitions: usize) -> pb::Entity { + pb::Entity { + kind: Some(pb::entity::Kind::Ui(pb::Ui { + id: Some(id(slug)), + transitions: (0..transitions) + .map(|_| pb::ui::Transition { + to: Some(pb::UiRef { + id: Some(id("target")), + }), + ..Default::default() + }) + .collect(), + ..Default::default() + })), + ..Default::default() + } +} + +fn references(slug: &str, token: String) -> pb::GetReferencesRequest { + pb::GetReferencesRequest { + kind: pb::EntityKind::Ui as i32, + id: Some(id(slug)), + page_size: i32::MAX, + page_token: token, + ..Default::default() + } +} + +fn impact(token: String) -> pb::GetImpactRequest { + pb::GetImpactRequest { + root: Some(pb::EntityRef { + kind: pb::EntityKind::Ui as i32, + id: Some(id("target")), + }), + max_depth: 1, + page_size: i32::MAX, + page_token: token, + ..Default::default() + } +} + +async fn seeded_client() -> Client { + let store = Arc::new(trogon_atlas_testsupport::shared().await.store().await); + store + .put( + pb::EntityKind::Ui, + &screen("target", 0), + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + store + .put( + pb::EntityKind::Ui, + &screen("many-outgoing", OWNERS), + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + for index in 0..OWNERS { + store + .put( + pb::EntityKind::Ui, + &screen(&format!("owner-{index:04}"), 1), + false, + WriteContext::baseline(), + ) + .await + .unwrap(); + } + let service = EventModelServiceImpl::try_new(store, ServerMetrics::default()).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(EventModelServiceServer::new(service)) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + Client::connect(format!("http://{addr}")).await.unwrap() +} + +#[tokio::test] +async fn reference_and_impact_pages_do_not_reject_large_result_totals() { + let mut client = seeded_client().await; + let incoming = client + .get_incoming_references(Request::new(references("target", String::new()))) + .await; + let outgoing = client + .get_outgoing_references(Request::new(references("many-outgoing", String::new()))) + .await; + let impacted = client.get_impact(Request::new(impact(String::new()))).await; + let statuses = [ + incoming.as_ref().err().map(tonic::Status::code), + outgoing.as_ref().err().map(tonic::Status::code), + impacted.as_ref().err().map(tonic::Status::code), + ]; + assert_eq!( + statuses, + [None, None, None], + "bounded pages must not be refused because candidate totals exceed 4096" + ); + + let mut response = incoming.unwrap().into_inner(); + let mut incoming_occurrences = HashSet::new(); + loop { + assert_ne!(response.references, []); + assert!(response.references.len() <= 500); + for reference in response.references { + let owner = reference.from.unwrap().id.unwrap(); + assert!(incoming_occurrences.insert((owner.slug, reference.from_field))); + } + if response.next_page_token.is_empty() { + break; + } + response = client + .get_incoming_references(Request::new(references("target", response.next_page_token))) + .await + .unwrap() + .into_inner(); + } + assert_eq!(incoming_occurrences.len(), OWNERS * 2); + + let mut response = outgoing.unwrap().into_inner(); + let mut outgoing_occurrences = HashSet::new(); + loop { + assert_ne!(response.references, []); + assert!(response.references.len() <= 500); + for reference in response.references { + assert_eq!(reference.to.unwrap().id, Some(id("target"))); + assert!(outgoing_occurrences.insert(reference.from_field)); + } + if response.next_page_token.is_empty() { + break; + } + response = client + .get_outgoing_references(Request::new(references( + "many-outgoing", + response.next_page_token, + ))) + .await + .unwrap() + .into_inner(); + } + assert_eq!(outgoing_occurrences.len(), OWNERS); + + let mut response = impacted.unwrap().into_inner(); + let mut impacted_owners = HashSet::new(); + loop { + assert_ne!(response.nodes, []); + assert!(response.nodes.len() <= 500); + for node in response.nodes { + assert!(impacted_owners.insert(node.entity.unwrap().id.unwrap().slug)); + } + if response.next_page_token.is_empty() { + break; + } + response = client + .get_impact(Request::new(impact(response.next_page_token))) + .await + .unwrap() + .into_inner(); + } + assert_eq!(impacted_owners.len(), OWNERS + 2); +} diff --git a/rsworkspace/crates/trogon-atlas-store/Cargo.toml b/rsworkspace/crates/trogon-atlas-store/Cargo.toml index 0b341b81..9992784f 100644 --- a/rsworkspace/crates/trogon-atlas-store/Cargo.toml +++ b/rsworkspace/crates/trogon-atlas-store/Cargo.toml @@ -25,6 +25,7 @@ uuid = { workspace = true, features = ["v7"] } trogon-atlas-semconv = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } trogon-atlas-testsupport = { path = "../trogon-atlas-testsupport" } async-nats = { workspace = true } proptest = "1" diff --git a/rsworkspace/crates/trogon-atlas-store/src/key.rs b/rsworkspace/crates/trogon-atlas-store/src/key.rs index 5ede3f75..c96e222b 100644 --- a/rsworkspace/crates/trogon-atlas-store/src/key.rs +++ b/rsworkspace/crates/trogon-atlas-store/src/key.rs @@ -350,6 +350,8 @@ fn kind_from_short(s: &str) -> Option { "specification" => EntityKind::Specification, "team" => EntityKind::Team, "person" => EntityKind::Person, + "decision" => EntityKind::Decision, + "document" => EntityKind::Document, _ => return None, }) } @@ -377,6 +379,8 @@ mod tests { EntityKind::Specification, EntityKind::Team, EntityKind::Person, + EntityKind::Decision, + EntityKind::Document, ] { let id = id("shop", "order-feed", 2); assert_eq!( diff --git a/rsworkspace/crates/trogon-atlas-store/src/nats.rs b/rsworkspace/crates/trogon-atlas-store/src/nats.rs index e9be5dc3..853d113c 100644 --- a/rsworkspace/crates/trogon-atlas-store/src/nats.rs +++ b/rsworkspace/crates/trogon-atlas-store/src/nats.rs @@ -57,8 +57,7 @@ use prost::Message as _; use tokio::sync::{broadcast, Notify}; use trogon_atlas_core::{NamespaceId, NamespaceName, OwnerId, WriterRole}; use trogon_atlas_proto::{ - canonical::ALL_KINDS, entity::Kind as EntityOneof, Entity, EntityKey, EntityKind, EntityRef, - Id, IdKey, + canonical::ALL_KINDS, Entity, EntityKey, EntityKind, EntityRef, Id, IdKey, }; use crate::{ @@ -2113,83 +2112,11 @@ fn decode_entity_logged(bytes: &[u8], key: &str) -> StoreResult { } fn kind_of(entity: &Entity) -> Option { - Some(match entity.kind.as_ref()? { - EntityOneof::Event(_) => EntityKind::Event, - EntityOneof::Command(_) => EntityKind::Command, - EntityOneof::ReadModel(_) => EntityKind::ReadModel, - EntityOneof::Processor(_) => EntityKind::Processor, - EntityOneof::Ui(_) => EntityKind::Ui, - EntityOneof::Persona(_) => EntityKind::Persona, - EntityOneof::Swimlane(_) => EntityKind::Swimlane, - EntityOneof::CommandSlice(_) => EntityKind::CommandSlice, - EntityOneof::ReadModelSlice(_) => EntityKind::ReadModelSlice, - EntityOneof::AutomationSlice(_) => EntityKind::AutomationSlice, - EntityOneof::Storyboard(_) => EntityKind::Storyboard, - EntityOneof::EventModel(_) => EntityKind::EventModel, - EntityOneof::Component(_) => EntityKind::Component, - EntityOneof::ExternalSystem(_) => EntityKind::ExternalSystem, - EntityOneof::Tracker(_) => EntityKind::Tracker, - EntityOneof::BoundedContext(_) => EntityKind::BoundedContext, - EntityOneof::Domain(_) => EntityKind::Domain, - EntityOneof::Subdomain(_) => EntityKind::Subdomain, - EntityOneof::Schema(_) => EntityKind::Schema, - EntityOneof::Project(_) => EntityKind::Project, - EntityOneof::Screen(_) => EntityKind::Screen, - EntityOneof::Term(_) => EntityKind::Term, - EntityOneof::Ambiguity(_) => EntityKind::Ambiguity, - EntityOneof::UiSlice(_) => EntityKind::UiSlice, - EntityOneof::ServiceLevelIndicator(_) => EntityKind::ServiceLevelIndicator, - EntityOneof::ServiceLevelObjective(_) => EntityKind::ServiceLevelObjective, - EntityOneof::AlertPolicy(_) => EntityKind::AlertPolicy, - EntityOneof::AlertNotificationTarget(_) => EntityKind::AlertNotificationTarget, - EntityOneof::TypeLibrary(_) => EntityKind::TypeLibrary, - EntityOneof::Channel(_) => EntityKind::Channel, - EntityOneof::DataStore(_) => EntityKind::DataStore, - EntityOneof::DataProduct(_) => EntityKind::DataProduct, - EntityOneof::Specification(_) => EntityKind::Specification, - EntityOneof::Team(_) => EntityKind::Team, - EntityOneof::Person(_) => EntityKind::Person, - }) + trogon_atlas_core::refs::entity_kind(entity) } fn entity_id(entity: &Entity) -> Option<&Id> { - match entity.kind.as_ref()? { - EntityOneof::Event(x) => x.id.as_ref(), - EntityOneof::Command(x) => x.id.as_ref(), - EntityOneof::ReadModel(x) => x.id.as_ref(), - EntityOneof::Processor(x) => x.id.as_ref(), - EntityOneof::Ui(x) => x.id.as_ref(), - EntityOneof::Persona(x) => x.id.as_ref(), - EntityOneof::Swimlane(x) => x.id.as_ref(), - EntityOneof::CommandSlice(x) => x.id.as_ref(), - EntityOneof::ReadModelSlice(x) => x.id.as_ref(), - EntityOneof::AutomationSlice(x) => x.id.as_ref(), - EntityOneof::Storyboard(x) => x.id.as_ref(), - EntityOneof::EventModel(x) => x.id.as_ref(), - EntityOneof::Component(x) => x.id.as_ref(), - EntityOneof::ExternalSystem(x) => x.id.as_ref(), - EntityOneof::Tracker(x) => x.id.as_ref(), - EntityOneof::BoundedContext(x) => x.id.as_ref(), - EntityOneof::Domain(x) => x.id.as_ref(), - EntityOneof::Subdomain(x) => x.id.as_ref(), - EntityOneof::Schema(x) => x.id.as_ref(), - EntityOneof::Project(x) => x.id.as_ref(), - EntityOneof::Screen(x) => x.id.as_ref(), - EntityOneof::Term(x) => x.id.as_ref(), - EntityOneof::Ambiguity(x) => x.id.as_ref(), - EntityOneof::UiSlice(x) => x.id.as_ref(), - EntityOneof::ServiceLevelIndicator(x) => x.id.as_ref(), - EntityOneof::ServiceLevelObjective(x) => x.id.as_ref(), - EntityOneof::AlertPolicy(x) => x.id.as_ref(), - EntityOneof::AlertNotificationTarget(x) => x.id.as_ref(), - EntityOneof::TypeLibrary(x) => x.id.as_ref(), - EntityOneof::Channel(x) => x.id.as_ref(), - EntityOneof::DataStore(x) => x.id.as_ref(), - EntityOneof::DataProduct(x) => x.id.as_ref(), - EntityOneof::Specification(x) => x.id.as_ref(), - EntityOneof::Team(x) => x.id.as_ref(), - EntityOneof::Person(x) => x.id.as_ref(), - } + trogon_atlas_core::refs::entity_id(entity) } // --------------------------------------------------------------------------- diff --git a/rsworkspace/crates/trogon-atlas-store/tests/decision_document_routing.rs b/rsworkspace/crates/trogon-atlas-store/tests/decision_document_routing.rs new file mode 100644 index 00000000..fa9b9df3 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-store/tests/decision_document_routing.rs @@ -0,0 +1,114 @@ +#![allow(clippy::unwrap_used)] + +use serde_json::json; +use trogon_atlas_core::transcode::TranscodePool; +use trogon_atlas_proto as pb; +use trogon_atlas_store::{Store, StoreError, WriteContext}; + +fn id(slug: &str) -> pb::Id { + pb::Id { + namespace: "catalog-routing".into(), + slug: slug.into(), + version: 1, + } +} +fn entity(value: &serde_json::Value) -> pb::Entity { + TranscodePool::builtin() + .unwrap() + .message_from_json_value("trogonatlas.eventmodel.v1alpha1.Entity", value) + .unwrap() +} +#[tokio::test] +async fn catalog_store_routes_kinds_and_complete_nested_owner_references_across_branch_tombstones() +{ + let store = trogon_atlas_testsupport::shared().await.store().await; + let document = entity( + &json!({"document":{"id":{"namespace":"catalog-routing","slug":"note","version":"1"},"title":{"value":"Note"},"subjects":[{"subject":{"entity":{"kind":"ENTITY_KIND_READ_MODEL","id":{"namespace":"catalog-routing","slug":"receipt","version":"1"}}}}],"link":{"value":"https://example.test/note"}}}), + ); + store + .create( + pb::EntityKind::Document, + &document, + WriteContext::baseline(), + ) + .await + .unwrap(); + let decision = entity( + &json!({"decision":{"id":{"namespace":"catalog-routing","slug":"choice","version":"1"},"title":{"value":"Choice"},"status":"DECISION_STATUS_PROPOSED","context":{"value":"Question"},"rationale":{"value":"Reason"},"options":[{"title":{"value":"Option"},"description":{"value":"Description"}}],"subjects":[{"subject":{"connection":{"owner":{"kind":"ENTITY_KIND_AUTOMATION_SLICE","id":{"namespace":"catalog-routing","slug":"observe","version":"1"}},"ownerUid":"01963410-1000-7000-8000-000000000001","id":{"value":"01963410-1000-7000-8000-000000000002"}}}},{"subject":{"member":{"specification":{"id":{"namespace":"catalog-routing","slug":"contract","version":"1"}},"ownerUid":"01963410-1000-7000-8000-000000000003","kind":"CONTRACT_MEMBER_KIND_MESSAGE","memberId":{"value":"01963410-1000-7000-8000-000000000004"}}}},{"subject":{"port":{"product":{"id":{"namespace":"catalog-routing","slug":"product","version":"1"}},"ownerUid":"01963410-1000-7000-8000-000000000005","direction":"DATA_PORT_DIRECTION_OUTPUT","portId":{"value":"01963410-1000-7000-8000-000000000006"}}}}],"documents":[{"document":{"document":{"id":{"namespace":"catalog-routing","slug":"note","version":"1"}}}}]}}), + ); + store + .create( + pb::EntityKind::Decision, + &decision, + WriteContext::baseline(), + ) + .await + .unwrap(); + for (kind, slug) in [ + (pb::EntityKind::Decision, "choice"), + (pb::EntityKind::Document, "note"), + ] { + let stored = store.get(kind, &id(slug), None).await.unwrap(); + assert_eq!( + trogon_atlas_core::refs::entity_kind(&stored.entity), + Some(kind) + ); + assert!(matches!( + store + .get( + if kind == pb::EntityKind::Decision { + pb::EntityKind::Document + } else { + pb::EntityKind::Decision + }, + &id(slug), + None + ) + .await, + Err(StoreError::NotFound) + )); + } + let targets = [ + (pb::EntityKind::AutomationSlice, "observe"), + (pb::EntityKind::Specification, "contract"), + (pb::EntityKind::DataProduct, "product"), + (pb::EntityKind::Document, "note"), + ]; + for (kind, slug) in targets { + assert!( + store + .has_incoming_reference(kind, &id(slug), None) + .await + .unwrap(), + "{kind:?}" + ); + } + assert!(store + .has_incoming_reference(pb::EntityKind::ReadModel, &id("receipt"), None) + .await + .unwrap()); + store.create_branch("catalog-edit", "").await.unwrap(); + store + .delete( + pb::EntityKind::Decision, + &id("choice"), + None, + WriteContext::branch("catalog-edit"), + ) + .await + .unwrap(); + for (kind, slug) in targets { + assert!(!store + .has_incoming_reference(kind, &id(slug), Some("catalog-edit")) + .await + .unwrap()); + assert!(store + .has_incoming_reference(kind, &id(slug), None) + .await + .unwrap()); + } + assert!(store + .get(pb::EntityKind::Document, &id("note"), Some("catalog-edit")) + .await + .is_ok()); +} diff --git a/tsworkspace/webapps/studio/server/decision-document-contract.test.mjs b/tsworkspace/webapps/studio/server/decision-document-contract.test.mjs new file mode 100644 index 00000000..c40435a0 --- /dev/null +++ b/tsworkspace/webapps/studio/server/decision-document-contract.test.mjs @@ -0,0 +1,61 @@ +import path from 'node:path'; +import protobuf from 'protobufjs'; +import protoLoader from '@grpc/proto-loader'; +import { describe, expect, it } from 'vitest'; +import { validateCatalogRequest } from './decision-document-request.mjs'; + +const protoRoot = path.resolve(import.meta.dirname, '../../../../proto'); +const servicePath = path.join(protoRoot, 'trogonatlas/api/eventmodel/v1alpha1/service.proto'); +const root = new protobuf.Root(); +root.resolvePath = (_origin, target) => path.resolve(protoRoot, target); +root.loadSync(servicePath); +const definitions = protoLoader.loadSync(servicePath, { keepCase: false, longs: String, enums: String, defaults: false, oneofs: false, includeDirs: [protoRoot] }); +const id = { namespace: 'catalog', slug: 'note', version: '9007199254740993' }; +const ref = { id }; +const subjects = [ + { entity: { kind: 'ENTITY_KIND_READ_MODEL', id } }, + { connection: { owner: { kind: 'ENTITY_KIND_AUTOMATION_SLICE', id }, ownerUid: 'incarnation', id: { value: 'occurrence-b' } } }, + { member: { specification: ref, ownerUid: 'spec-incarnation', kind: 'CONTRACT_MEMBER_KIND_ARTIFACT', memberId: { value: 'member-b' }, expectedDigest: { sha256: 'a'.repeat(64) } } }, + { port: { product: ref, ownerUid: 'product-incarnation', direction: 'DATA_PORT_DIRECTION_OUTPUT', portId: { value: 'port-b' } } }, +]; +const selections = [{ document: ref }, { anchor: { document: ref, ownerUid: 'doc-incarnation', anchorId: { value: 'anchor-b' } } }, { anchor: { document: ref, ownerUid: 'doc-incarnation', anchorId: { value: 'anchor-b' }, expectedDigest: { sha256: 'b'.repeat(64) } } }]; +function type(rpc) { return root.lookupType(`trogonatlas.api.eventmodel.v1alpha1.${rpc}Request`); } +function roundTrip(rpc, input) { + validateCatalogRequest(type(rpc), input); + const service = definitions['trogonatlas.api.eventmodel.v1alpha1.EventModelService']; + const method = Object.values(service).find(item => item.path.endsWith(`/${rpc}`)); + const codec = definitions[`trogonatlas.api.eventmodel.v1alpha1.${rpc}Request`]; + return codec.deserialize(method.requestSerialize(input)); +} +describe('catalog actual serializer boundary', () => { + it('preserves exact selector alternatives, pins, optional status and explicit false', () => { + for (const subject of subjects) { + expect(roundTrip('ListSubjectDocuments', { subject })).toEqual({ subject }); + for (const status of [undefined, 'DECISION_STATUS_ACCEPTED']) { + const input = { subject, ...(status === undefined ? {} : { status }) }; + expect(roundTrip('ListSubjectDecisions', input)).toEqual(input); + } + } + for (const document of selections) for (const rpc of ['GetDocumentDetail', 'ListIncomingDocumentDeclarations']) expect(roundTrip(rpc, { document })).toEqual({ document }); + for (const followReplacement of [false, true]) expect(roundTrip('GetDecisionDetail', { decision: ref, followReplacement })).toEqual({ decision: ref, followReplacement }); + expect(roundTrip('ListIncomingDecisionDeclarations', { decision: ref })).toEqual({ decision: ref }); + }); + it.each([ + ['ListSubjectDecisions', { subject: subjects[0], status: 'TYPO' }], + ['ListSubjectDecisions', { subject: subjects[0], status: 991 }], + ['GetDecisionDetail', { decision: ref, followReplacement: 'false' }], + ['GetDecisionDetail', { decision: ref, followReplacement: 0 }], + ['GetDecisionDetail', { decision: ref, followReplacement: null }], + ['GetDecisionDetail', { decision: ref, surprise: true }], + ['GetDocumentDetail', { document: {} }], + ['GetDocumentDetail', { document: { document: ref, anchor: selections[1].anchor } }], + ['GetDocumentDetail', { document: { anchor: { ...selections[1].anchor, ownerUid: false } } }], + ['GetDocumentDetail', { document: { anchor: { ...selections[1].anchor, expected_digest: { sha256: 'a'.repeat(64) } } } }], + ['ListSubjectDocuments', { subject: { ...subjects[0], ...subjects[3] } }], + ['ListSubjectDocuments', { subject: { entity: { kind: true, id } } }], + ['ListSubjectDocuments', { subject: { entity: { kind: 'ENTITY_KIND_EVENT', id: { ...id, version: 9007199254740992 } } } }], + ['ListSubjectDocuments', { subject: { entity: { kind: 'ENTITY_KIND_EVENT', id: { ...id, version: '18446744073709551616' } } } }], + ])('rejects malformed %s before coercion can erase intent', (rpc, input) => { + expect(() => roundTrip(rpc, input)).toThrow(/expected|unknown/); + }); +}); diff --git a/tsworkspace/webapps/studio/server/decision-document-request.mjs b/tsworkspace/webapps/studio/server/decision-document-request.mjs new file mode 100644 index 00000000..bafb8523 --- /dev/null +++ b/tsworkspace/webapps/studio/server/decision-document-request.mjs @@ -0,0 +1,6 @@ +import { validateResponsibilityRequest } from "./responsibility-request.mjs"; + +/** @param {import("protobufjs").Type} type @param {unknown} input */ +export function validateCatalogRequest(type, input) { + validateResponsibilityRequest(type, input); +} diff --git a/tsworkspace/webapps/studio/server/index.mjs b/tsworkspace/webapps/studio/server/index.mjs index f8630cec..c20e6015 100644 --- a/tsworkspace/webapps/studio/server/index.mjs +++ b/tsworkspace/webapps/studio/server/index.mjs @@ -17,6 +17,7 @@ import protoLoader from "@grpc/proto-loader"; import express from "express"; import { rateLimit as expressRateLimit } from "express-rate-limit"; import protobuf from "protobufjs"; +import { validateCatalogRequest } from "./decision-document-request.mjs"; import { validateResponsibilityRequest } from "./responsibility-request.mjs"; import { isChangesetId, isSafeBranchName, isSafeNamespace } from "../shared/safe-namespace.mjs"; @@ -327,6 +328,13 @@ const READ_RPCS = new Set([ "listTeamMembers", "listPersonTeams", "getTeamContacts", + "listSubjectDecisions", + "listSubjectDocuments", + "getDecisionDetail", + "getDocumentDetail", + "listIncomingDecisionDeclarations", + "listIncomingDocumentDeclarations", + "getChangeset", "getEntityHistory", "getEventModelProjection", @@ -1864,6 +1872,36 @@ app.get( }), ); +const CATALOG_QUERIES = new Map([ + ["subject-decisions", "listSubjectDecisions"], + ["subject-documents", "listSubjectDocuments"], + ["decision-detail", "getDecisionDetail"], + ["document-detail", "getDocumentDetail"], + ["incoming-decisions", "listIncomingDecisionDeclarations"], + ["incoming-documents", "listIncomingDocumentDeclarations"], +]); + +app.get( + "/api/catalog/:query", + branchHandler((req) => { + const method = CATALOG_QUERIES.get(String(req.params.query)); + /** @param {string} message */ + const reject = (message) => { + const error = /** @type {HttpError} */ (new Error(message)); + error.status = 400; + throw error; + }; + if (!method) return reject("unknown catalog query"); + if (req.query.asOf !== undefined) return reject("historical catalog inspection unavailable"); + let input; + try { input = JSON.parse(String(req.query.request ?? "")); } + catch { return reject("request must be a JSON object"); } + const requestType = protoRoot.lookupType(`trogonatlas.api.eventmodel.v1alpha1.${method[0].toUpperCase()}${method.slice(1)}Request`); + validateCatalogRequest(requestType, input); + return call(method, input); + }), +); + const RESPONSIBILITY_QUERIES = new Map([ ["subject-assignments", "listSubjectAssignments"], ["assignee-responsibilities", "listAssigneeResponsibilities"], diff --git a/tsworkspace/webapps/studio/server/index.test.mjs b/tsworkspace/webapps/studio/server/index.test.mjs index b146e5bc..d77da4b2 100644 --- a/tsworkspace/webapps/studio/server/index.test.mjs +++ b/tsworkspace/webapps/studio/server/index.test.mjs @@ -188,7 +188,7 @@ vi.mock('protobufjs', async (importOriginal) => { resolvePath: () => '', load: () => Promise.resolve(fakeRoot), lookupType: (name) => { - if (/\.(ListSubjectAssignments|ListAssigneeResponsibilities|ListTeamMembers|ListPersonTeams|GetTeamContacts)Request$/.test(name)) return responsibilityRoot.lookupType(name); + if (/\.(ListSubjectAssignments|ListAssigneeResponsibilities|ListTeamMembers|ListPersonTeams|GetTeamContacts|ListSubjectDecisions|ListSubjectDocuments|GetDecisionDetail|GetDocumentDetail|ListIncomingDecisionDeclarations|ListIncomingDocumentDeclarations)Request$/.test(name)) return responsibilityRoot.lookupType(name); return { decode: () => ({}), toObject: () => ({}) }; }, lookupEnum: (name) => { @@ -2516,3 +2516,59 @@ describe('responsibility strict production serializer boundary', () => { }); }); + + +describe('catalog strict gateway serializer boundary', () => { + const id = { namespace: 'catalog', slug: 'note', version: '9007199254740993' }; + const document = { anchor: { document: { id }, ownerUid: 'incarnation', anchorId: { value: 'anchor-b' }, expectedDigest: { sha256: 'a'.repeat(64) } } }; + const subject = { entity: { kind: 'ENTITY_KIND_EVENT', id } }; + it.each([ + ['subject-decisions', 'ListSubjectDecisions', { subject, status: 'TYPO' }], + ['subject-decisions', 'ListSubjectDecisions', { subject, status: 991 }], + ['decision-detail', 'GetDecisionDetail', { decision: { id }, followReplacement: 'false' }], + ['decision-detail', 'GetDecisionDetail', { decision: { id }, followReplacement: null }], + ['document-detail', 'GetDocumentDetail', { document: {} }], + ['document-detail', 'GetDocumentDetail', { document: { ...document, document: { id } } }], + ['document-detail', 'GetDocumentDetail', { document: { anchor: { ...document.anchor, owner_uid: 'lost' } } }], + ['incoming-documents', 'ListIncomingDocumentDeclarations', { document, unknown: true }], + ])('rejects malformed %s before calling serializer', async (route, rpc, input) => { + const name = rpc[0].toLowerCase() + rpc.slice(1); + const invoke = vi.fn(); + fakeClient[name] = invoke; + try { + const response = await request(app).get(`/api/catalog/${route}`).query({ request: JSON.stringify(input) }); + expect(response.status).toBe(400); + expect(invoke).not.toHaveBeenCalled(); + } finally { delete fakeClient[name]; } + }); + it.each([ + ['subject-decisions', 'ListSubjectDecisions', { subject, status: 'DECISION_STATUS_ACCEPTED' }], + ['subject-documents', 'ListSubjectDocuments', { subject }], + ['decision-detail', 'GetDecisionDetail', { decision: { id }, followReplacement: false }], + ['document-detail', 'GetDocumentDetail', { document }], + ['incoming-decisions', 'ListIncomingDecisionDeclarations', { decision: { id } }], + ['incoming-documents', 'ListIncomingDocumentDeclarations', { document }], + ])('preserves valid %s through HTTP and real serializer', async (route, rpc, input) => { + const loader = (await vi.importActual('@grpc/proto-loader')).default; + const protoRoot = path.resolve(here, '../../../../proto'); + const definitions = loader.loadSync(path.join(protoRoot, 'trogonatlas/api/eventmodel/v1alpha1/service.proto'), { keepCase: false, longs: String, enums: String, defaults: false, oneofs: false, includeDirs: [protoRoot] }); + const method = Object.values(definitions['trogonatlas.api.eventmodel.v1alpha1.EventModelService']).find(value => value.path.endsWith(`/${rpc}`)); + const codec = definitions[`trogonatlas.api.eventmodel.v1alpha1.${rpc}Request`]; + const invoke = vi.fn((req, meta, _opts, cb) => cb(null, { received: codec.deserialize(method.requestSerialize(req)), branch: meta.get('x-trogon-atlas-branch')[0], incomplete: true, failures: [{ code: 14, message: 'Unavailable' }] })); + const name = rpc[0].toLowerCase() + rpc.slice(1); + fakeClient[name] = invoke; + try { + const response = await request(app).get(`/api/catalog/${route}`).query({ request: JSON.stringify(input), branch: 'review' }); + expect(response.status).toBe(200); + expect(response.body.received).toEqual(input); + expect(response.body.branch).toBe('review'); + expect(response.body.incomplete).toBe(true); + expect(response.body.failures[0].code).toBe(14); + } finally { delete fakeClient[name]; } + }); + it('refuses unknown routes, mutations and historical hydration', async () => { + expect((await request(app).get('/api/catalog/putEntity').query({ request: '{}' })).status).toBe(400); + expect((await request(app).get('/api/catalog/document-detail').query({ request: '{}', asOf: 'historical' })).status).toBe(400); + expect((await request(app).post('/api/catalog/document-detail').send({ document })).status).toBe(403); + }); +}); diff --git a/tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.test.tsx b/tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.test.tsx new file mode 100644 index 00000000..230aefbc --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.test.tsx @@ -0,0 +1,168 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as api from '@/lib/api'; +import type { CatalogInspection } from '@/lib/decision-document'; +import { buildModel } from '@/lib/model'; +import { DecisionDocumentInspector } from './DecisionDocumentInspector'; + +const id = { namespace: 'catalog', slug: 'note', version: '7' }; +const subject = { + port: { + product: { id: { ...id, slug: 'product', version: '9' } }, + ownerUid: 'product-uid', + direction: 'DATA_PORT_DIRECTION_OUTPUT', + portId: { value: 'port-b' }, + }, +}; +const digest = { sha256: 'a'.repeat(64) }; +const text = '# Note\n\n[go](javascript:alert(1))\n'; +function entity(kind = 'document') { + return buildModel([ + { + [kind]: { + id, + title: { value: 'Retained title' }, + subjects: [{ subject, connectionId: { value: 'subject-b' } }], + retained: { mediaType: { value: 'text/markdown' }, content: text, digest }, + anchors: [{ anchorId: { value: 'anchor-b' }, fragment: { value: 'Delivery' }, label: { value: 'Delivery' } }], + }, + system: { uid: 'document-incarnation' }, + }, + ]).entities[0]; +} +beforeEach(() => { + for (const method of [ + 'getDecisionDetail', + 'getDocumentDetail', + 'listIncomingDecisionDeclarations', + 'listIncomingDocumentDeclarations', + 'listSubjectDecisions', + 'listSubjectDocuments', + ] as const) + vi.spyOn(api, method).mockResolvedValue({ + scope: { branch: 'review', revision: 'checked-revision' }, + incomplete: true, + failures: [{ field: 'subjects', message: 'Unavailable target', code: 14 }], + }); + window.history.replaceState({}, '', '/?branch=review'); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.history.replaceState({}, '', '/'); +}); +describe('native catalog inspector', () => { + it('preserves pending and completed queries across same-context parent renders', async () => { + let resolveDetail: ((value: CatalogInspection) => void) | undefined; + vi.mocked(api.getDocumentDetail).mockImplementation( + () => + new Promise((resolve) => { + resolveDetail = resolve; + }), + ); + const note = entity(); + const { rerender } = render(); + const signal = vi.mocked(api.getDocumentDetail).mock.calls[0][1]?.signal; + rerender(); + expect(api.getDocumentDetail).toHaveBeenCalledTimes(1); + expect(signal?.aborted).toBe(false); + resolveDetail?.({ scope: { revision: 'retained-result' } }); + await screen.findAllByText(/retained-result/); + rerender(); + for (const method of [ + api.getDocumentDetail, + api.listIncomingDocumentDeclarations, + api.listSubjectDecisions, + api.listSubjectDocuments, + ]) + expect(method).toHaveBeenCalledTimes(1); + expect(signal?.aborted).toBe(false); + expect(screen.getAllByText(/retained-result/).length).toBeGreaterThan(0); + }); + it('aborts old queries and reloads on changed context, and suspends hydration for history', async () => { + const note = entity(); + const { rerender } = render(); + let count = 1; + for (const changed of [ + { ...note, uid: 'new-incarnation' }, + { ...note, raw: { ...note.raw, title: { value: 'Edited' } } }, + { ...note, id: { ...id, version: '8' } }, + ]) { + const signal = vi.mocked(api.getDocumentDetail).mock.calls.at(-1)?.[1]?.signal; + rerender(); + expect(signal?.aborted).toBe(true); + expect(api.getDocumentDetail).toHaveBeenCalledTimes(++count); + } + const signal = vi.mocked(api.getDocumentDetail).mock.calls.at(-1)?.[1]?.signal; + window.history.replaceState({}, '', '/?branch=other'); + rerender(); + expect(signal?.aborted).toBe(true); + expect(api.getDocumentDetail).toHaveBeenLastCalledWith( + { document: { id } }, + expect.objectContaining({ branch: 'other' }), + ); + expect(api.getDocumentDetail).toHaveBeenCalledTimes(++count); + const currentSignal = vi.mocked(api.getDocumentDetail).mock.calls.at(-1)?.[1]?.signal; + rerender(); + expect(currentSignal?.aborted).toBe(true); + expect(api.getDocumentDetail).toHaveBeenCalledTimes(count); + rerender(); + await waitFor(() => expect(api.getDocumentDetail).toHaveBeenCalledTimes(count + 1)); + }); + it('recognizes kind and title, escapes retained Markdown and preserves exact branch/subject selectors', async () => { + const note = entity(); + expect(note.kind).toBe('document'); + expect(note.title).toBe('Retained title'); + const { container } = render(); + await waitFor(() => expect(api.getDocumentDetail).toHaveBeenCalled()); + expect(api.getDocumentDetail).toHaveBeenCalledWith( + { document: { id } }, + expect.objectContaining({ branch: 'review', signal: expect.any(AbortSignal) }), + ); + expect(api.listSubjectDocuments).toHaveBeenCalledWith(subject, expect.objectContaining({ branch: 'review' })); + expect(container.querySelector('img,script,iframe,a')).toBeNull(); + expect([...container.querySelectorAll('pre')].some((node) => node.textContent === text)).toBe(true); + expect(screen.getAllByText(/Incomplete inspection/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Backend resolution failures/).length).toBeGreaterThan(0); + }); + it('distinguishes whole, unpinned anchor and pinned anchor requests', async () => { + const { unmount } = render(); + await waitFor(() => expect(api.getDocumentDetail).toHaveBeenCalledTimes(1)); + const select = screen.getByLabelText('Exact document selection') as HTMLSelectElement; + fireEvent.change(select, { target: { value: select.options[1].value } }); + const anchor = { document: { id }, ownerUid: 'document-incarnation', anchorId: { value: 'anchor-b' } }; + await waitFor(() => expect(api.getDocumentDetail).toHaveBeenLastCalledWith({ anchor }, expect.anything())); + fireEvent.change(select, { target: { value: select.options[2].value } }); + await waitFor(() => + expect(api.listIncomingDocumentDeclarations).toHaveBeenLastCalledWith( + { anchor: { ...anchor, expectedDigest: digest } }, + expect.anything(), + ), + ); + const signal = vi.mocked(api.getDocumentDetail).mock.calls.at(-1)?.[1]?.signal; + unmount(); + expect(signal?.aborted).toBe(true); + }); + it('shows historical declarations without requesting current hydration', () => { + render(); + expect(screen.getByText(/Historical catalog hydration is unavailable/)).toBeTruthy(); + expect(api.getDocumentDetail).not.toHaveBeenCalled(); + expect(api.listSubjectDocuments).not.toHaveBeenCalled(); + }); + it('reports errors explicitly and keeps link-only content inert', async () => { + vi.mocked(api.getDocumentDetail).mockRejectedValue(new Error('Not found')); + const note = entity(); + delete note.raw.retained; + note.raw.link = { value: 'https://example.test/note' }; + const { container } = render(); + await waitFor(() => expect(screen.getByText(/Catalog inspection unavailable: Not found/)).toBeTruthy()); + expect(screen.getByText(/External link: unverified/)).toBeTruthy(); + expect(container.querySelector('a')).toBeNull(); + }); + it('does not recursively follow replacement chains by default', async () => { + render(); + await waitFor(() => + expect(api.getDecisionDetail).toHaveBeenCalledWith({ id }, false, expect.objectContaining({ branch: 'review' })), + ); + }); +}); diff --git a/tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.tsx b/tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.tsx new file mode 100644 index 00000000..81fa6fb8 --- /dev/null +++ b/tsworkspace/webapps/studio/src/components/DecisionDocumentInspector.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; +import { + getDecisionDetail, + getDocumentDetail, + listIncomingDecisionDeclarations, + listIncomingDocumentDeclarations, + listSubjectDecisions, + listSubjectDocuments, +} from '@/lib/api'; +import { readAsOfFromUrl, readBranchFromUrl } from '@/lib/branch'; +import type { CatalogInspection, CatalogSubject, DocumentSelection } from '@/lib/decision-document'; +import type { Entity } from '@/lib/model'; +import { responsibilitySubjects } from '@/lib/responsibility'; + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} +function Exact({ value }: { value: unknown }) { + return ( +
{JSON.stringify(value, null, 2) ?? 'Undeclared'}
+ ); +} +function Query({ title, load }: { title: string; load: (signal: AbortSignal) => Promise }) { + // The keyed context owns this query's lifetime, including its initial loader. + const [loadContext] = useState(() => load); + const [result, setResult] = useState<{ data?: CatalogInspection; error?: string }>(); + useEffect(() => { + const controller = new AbortController(); + loadContext(controller.signal).then( + (data) => { + if (!controller.signal.aborted) setResult({ data }); + }, + (error: unknown) => { + if (!controller.signal.aborted) setResult({ error: error instanceof Error ? error.message : String(error) }); + }, + ); + return () => controller.abort(); + }, [loadContext]); + return ( +
+

{title}

+ {!result &&

Checking backend context…

} + {result?.error &&

Catalog inspection unavailable: {result.error}. Resolution remains unknown.

} + {result?.data && ( + <> + + {!result.data.scope &&

Checked scope unavailable; complete absence remains unproven.

} + {result.data.incomplete &&

Incomplete inspection; missing records do not prove absence.

} + {!!result.data.failures?.length && ( + <> +

Backend resolution failures

+ + + )} + + {!result.data.declarations?.length && ( +

No declarations returned. Interpret this only within the reported checked scope and completeness.

+ )} + + )} +
+ ); +} +function Authored({ entity }: { entity: Entity }) { + const retained = record(entity.raw.retained); + const link = record(entity.raw.link).value; + return ( +
+

Loaded authored declarations

+ + {typeof retained.content === 'string' && ( + <> +

+ Retained text, displayed inertly. Markdown links and markup are not activated. Declared anchors do not + verify rendered headings. +

+
{retained.content}
+ + )} + {typeof link === 'string' && ( + <> +

External link: unverified content and reachability.

+
{link}
+ + )} +
+ ); +} +function selections(entity: Entity): DocumentSelection[] { + const document = { id: entity.id }; + const values: DocumentSelection[] = [{ document }]; + const digest = record(record(entity.raw.retained).digest).sha256; + if (entity.uid && Array.isArray(entity.raw.anchors)) + for (const raw of entity.raw.anchors) { + const anchorId = record(record(raw).anchorId).value; + if (typeof anchorId !== 'string' || !anchorId) continue; + const anchor = { document, ownerUid: entity.uid, anchorId: { value: anchorId } }; + values.push({ anchor }); + if (typeof digest === 'string') values.push({ anchor: { ...anchor, expectedDigest: { sha256: digest } } }); + } + return values; +} +export function DecisionDocumentInspector({ entity, asOf: historicalAsOf }: { entity: Entity; asOf?: string }) { + const asOf = historicalAsOf ?? readAsOfFromUrl(); + const branch = asOf ? undefined : readBranchFromUrl(); + const catalog = entity.kind === 'decision' || entity.kind === 'document'; + const subjects = catalog + ? Array.isArray(entity.raw.subjects) + ? entity.raw.subjects.flatMap((value) => + record(value).subject + ? [{ label: 'Authored exact subject', subject: record(value).subject as CatalogSubject }] + : [], + ) + : [] + : responsibilitySubjects(entity); + const [selected, setSelected] = useState(); + const [documentChoice, setDocumentChoice] = useState(); + const subject = subjects.find((item) => JSON.stringify(item.subject) === selected)?.subject ?? subjects[0]?.subject; + const documents = selections(entity); + const document = documents.find((item) => JSON.stringify(item) === documentChoice) ?? documents[0]; + const contextKey = JSON.stringify({ + key: entity.key, + raw: entity.raw, + uid: entity.uid, + branch, + asOf, + subject, + document, + }); + return ( +
+

Decision and Document inspection

+

+ Catalog decisions record assertions, not approval authority or implementation evidence. Document selections + preserve exact identity and byte pins. +

+ {catalog && } + {subject && ( + + )} + {entity.kind === 'document' && ( + + )} + {asOf ? ( +

+ Retained historical declarations only. Historical catalog hydration is unavailable; current resolution is not + historical evidence. +

+ ) : ( +
+ {entity.kind === 'decision' && ( + <> + getDecisionDetail({ id: entity.id }, false, { branch, signal })} + /> + listIncomingDecisionDeclarations({ id: entity.id }, { branch, signal })} + /> + + )} + {entity.kind === 'document' && ( + <> + getDocumentDetail(document, { branch, signal })} /> + listIncomingDocumentDeclarations(document, { branch, signal })} + /> + + )} + {subject && ( + <> + listSubjectDecisions(subject, undefined, { branch, signal })} + /> + listSubjectDocuments(subject, { branch, signal })} + /> + + )} +
+ )} +
+ ); +} diff --git a/tsworkspace/webapps/studio/src/components/Inspector.tsx b/tsworkspace/webapps/studio/src/components/Inspector.tsx index ce66550c..0da9bfee 100644 --- a/tsworkspace/webapps/studio/src/components/Inspector.tsx +++ b/tsworkspace/webapps/studio/src/components/Inspector.tsx @@ -1,3 +1,4 @@ +import { DecisionDocumentInspector } from '@/components/DecisionDocumentInspector'; import { ExternalLink, FileCode2, Split, TriangleAlert, X } from 'lucide-react'; import { useMemo, useState } from 'react'; import { ResponsibilityInspector } from '@/components/ResponsibilityInspector'; @@ -634,6 +635,7 @@ export function Inspector({ ) : null} + {entity.kind === 'dataProduct' && } diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.hotkeys.test.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.hotkeys.test.tsx index bc6a6578..56c092c8 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.hotkeys.test.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.hotkeys.test.tsx @@ -3,6 +3,8 @@ import { withNuqsTestingAdapter } from 'nuqs/adapters/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/lib/api', () => ({ + listSubjectDecisions: vi.fn().mockResolvedValue({ scope: { revision: 'hotkey-fixture' }, declarations: [] }), + listSubjectDocuments: vi.fn().mockResolvedValue({ scope: { revision: 'hotkey-fixture' }, declarations: [] }), listSubjectAssignments: vi.fn().mockResolvedValue({ scope: { revision: 'hotkey-fixture' }, assignments: [] }), api: { info: vi.fn(), diff --git a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx index 1d645b25..02a1eedd 100644 --- a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx +++ b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx @@ -84,6 +84,8 @@ const KIND_SHORT: Record = { channel: 'channel', dataStore: 'data_store', team: 'team', + decision: 'decision', + document: 'document', person: 'person', dataProduct: 'data_product', specification: 'specification', diff --git a/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts b/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts index fbcb8735..4fbfce4b 100644 --- a/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts +++ b/tsworkspace/webapps/studio/src/components/canvas/three/palette.test.ts @@ -38,6 +38,8 @@ const KINDS: Record = { dataStore: true, team: true, person: true, + decision: true, + document: true, dataProduct: true, specification: true, }; diff --git a/tsworkspace/webapps/studio/src/lib/api.ts b/tsworkspace/webapps/studio/src/lib/api.ts index 6b22baad..39206349 100644 --- a/tsworkspace/webapps/studio/src/lib/api.ts +++ b/tsworkspace/webapps/studio/src/lib/api.ts @@ -1,3 +1,4 @@ +import type { CatalogInspection, CatalogSubject, DocumentSelection } from '@/lib/decision-document'; import { authHeaders, onUnauthenticated } from '@/lib/credential'; import type { EntityId } from '@/lib/model'; import type { @@ -479,3 +480,25 @@ export function listPersonTeams(person: DirectoryRef, opts: FetchOptions = {}) { export function getTeamContacts(team: DirectoryRef, includeMembers = false, opts: FetchOptions = {}) { return responsibilityQuery('team-contacts', { team, includeMembers }, opts); } + +function catalogQuery(query: string, request: unknown, opts: FetchOptions): Promise { + return getJson(`/api/catalog/${query}?request=${encodeURIComponent(JSON.stringify(request))}`, opts); +} +export function listSubjectDecisions(subject: CatalogSubject, status?: string, opts: FetchOptions = {}) { + return catalogQuery('subject-decisions', { subject, ...(status === undefined ? {} : { status }) }, opts); +} +export function listSubjectDocuments(subject: CatalogSubject, opts: FetchOptions = {}) { + return catalogQuery('subject-documents', { subject }, opts); +} +export function getDecisionDetail(decision: { id: EntityId }, followReplacement = false, opts: FetchOptions = {}) { + return catalogQuery('decision-detail', { decision, followReplacement }, opts); +} +export function getDocumentDetail(document: DocumentSelection, opts: FetchOptions = {}) { + return catalogQuery('document-detail', { document }, opts); +} +export function listIncomingDecisionDeclarations(decision: { id: EntityId }, opts: FetchOptions = {}) { + return catalogQuery('incoming-decisions', { decision }, opts); +} +export function listIncomingDocumentDeclarations(document: DocumentSelection, opts: FetchOptions = {}) { + return catalogQuery('incoming-documents', { document }, opts); +} diff --git a/tsworkspace/webapps/studio/src/lib/decision-document-api.test.ts b/tsworkspace/webapps/studio/src/lib/decision-document-api.test.ts new file mode 100644 index 00000000..8f1dbd13 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/decision-document-api.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getDecisionDetail, + getDocumentDetail, + listIncomingDecisionDeclarations, + listIncomingDocumentDeclarations, + listSubjectDecisions, + listSubjectDocuments, +} from './api'; + +const id = { namespace: 'catalog', slug: 'note', version: '9007199254740993' }; +const subject = { + member: { + specification: { id }, + ownerUid: 'spec-uid', + kind: 'CONTRACT_MEMBER_KIND_ARTIFACT', + memberId: { value: 'artifact-b' }, + expectedDigest: { sha256: 'a'.repeat(64) }, + }, +}; +const document = { + anchor: { + document: { id }, + ownerUid: 'doc-uid', + anchorId: { value: 'anchor-b' }, + expectedDigest: { sha256: 'b'.repeat(64) }, + }, +}; +afterEach(() => vi.unstubAllGlobals()); +describe('typed native catalog HTTP requests', () => { + it('preserves selector/pin presence and checked results for every read-only query', async () => { + const result = { + incomplete: true, + scope: { revision: 'checked', visitedDocuments: [document] }, + failures: [{ code: 14, message: 'Unavailable' }], + }; + const fetch = vi + .fn() + .mockImplementation(() => Promise.resolve(new Response(JSON.stringify(result), { status: 200 }))); + vi.stubGlobal('fetch', fetch); + const opts = { branch: 'review' }; + for (const call of [ + () => listSubjectDecisions(subject, undefined, opts), + () => listSubjectDocuments(subject, opts), + () => getDecisionDetail({ id }, false, opts), + () => getDocumentDetail(document, opts), + () => listIncomingDecisionDeclarations({ id }, opts), + () => listIncomingDocumentDeclarations(document, opts), + ]) + await expect(call()).resolves.toEqual(result); + const inputs = fetch.mock.calls.map(([url, options]) => { + const parsed = new URL(url, 'http://localhost'); + expect(parsed.searchParams.get('branch')).toBe('review'); + expect(options.headers['x-trogon-atlas-branch']).toBe('review'); + expect(options.method).toBeUndefined(); + return JSON.parse(parsed.searchParams.get('request') ?? ''); + }); + expect(inputs).toEqual([ + { subject }, + { subject }, + { decision: { id }, followReplacement: false }, + { document }, + { decision: { id } }, + { document }, + ]); + }); + it('retains backend failure rather than manufacturing complete empty declarations', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'budget exhausted' }), { status: 429 })), + ); + await expect(getDocumentDetail(document)).rejects.toThrow('budget exhausted'); + }); +}); diff --git a/tsworkspace/webapps/studio/src/lib/decision-document.ts b/tsworkspace/webapps/studio/src/lib/decision-document.ts new file mode 100644 index 00000000..e9abebb5 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/decision-document.ts @@ -0,0 +1,38 @@ +import type { EntityId } from './model'; +import type { NativeEntityRef, ResponsibilitySubject } from './responsibility'; + +export type CatalogSubject = ResponsibilitySubject; +export type DocumentSelection = + | { document: { id: EntityId } } + | { + anchor: { + document: { id: EntityId }; + ownerUid: string; + anchorId: { value: string }; + expectedDigest?: { sha256: string }; + }; + }; +export interface CatalogCheckedOwner { + owner?: NativeEntityRef; + uid?: string; + etag?: string; +} +export interface CatalogInspection { + entity?: Record; + etag?: string; + anchor?: Record; + declarations?: Record[]; + scope?: { + branch?: string; + revision?: string; + authorizedNamespaces?: string[]; + visited?: CatalogCheckedOwner[]; + visitedSubjects?: CatalogSubject[]; + visitedDocuments?: DocumentSelection[]; + }; + incomplete?: boolean; + failures?: { field?: string; connectionId?: { value: string }; code?: number; message?: string }[]; + selectionState?: string; + subjectState?: string; + replacementChain?: CatalogCheckedOwner[]; +} diff --git a/tsworkspace/webapps/studio/src/lib/model.ts b/tsworkspace/webapps/studio/src/lib/model.ts index ed8abf33..2f2b7cee 100644 --- a/tsworkspace/webapps/studio/src/lib/model.ts +++ b/tsworkspace/webapps/studio/src/lib/model.ts @@ -56,6 +56,8 @@ export type EntityKind = | 'dataStore' | 'team' | 'person' + | 'decision' + | 'document' | 'dataProduct' | 'specification'; @@ -436,6 +438,8 @@ const KIND_SET: ReadonlySet = new Set([ 'dataStore', 'team', 'person', + 'decision', + 'document', 'dataProduct', 'specification', ]); @@ -474,6 +478,8 @@ const WIRE_KIND: Record = { ENTITY_KIND_DATA_STORE: 'dataStore', ENTITY_KIND_TEAM: 'team', ENTITY_KIND_PERSON: 'person', + ENTITY_KIND_DECISION: 'decision', + ENTITY_KIND_DOCUMENT: 'document', ENTITY_KIND_DATA_PRODUCT: 'dataProduct', ENTITY_KIND_SPECIFICATION: 'specification', }; @@ -639,7 +645,7 @@ function normalizeEntity(wire: WireEntity): Entity | undefined { id, key: entityKey(kind, id), title: String( - (kind === 'team' || kind === 'person' + (kind === 'team' || kind === 'person' || kind === 'decision' || kind === 'document' ? (bodyObj.title as { value?: string } | undefined)?.value : bodyObj.title) ?? id.slug, ),