From 4284d24b29cbb852db1944b604806a28e89d38e8 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 7 Oct 2026 14:25:39 +0600 Subject: [PATCH] evaluations: tolerate malformed evaluator hydration --- .../hypercerts-api/references/endpoint-map.md | 4 +- .../hypercerts-api/references/openapi.json | 10 +- .../hypercerts-api/references/reliability.md | 2 + ...olerate-malformed-evaluation-evaluators.md | 5 + .../org.hypercerts.context.getEvaluation.json | 10 +- ...rg.hypercerts.context.listEvaluations.json | 2 +- api/lua/endpoints/getEvaluation.lua | 55 ++--- api/lua/endpoints/listEvaluations.lua | 55 ++--- api/lua/shared/evaluation.lua | 55 ++--- .../http/context-evaluations.http.test.js | 49 ++++- .../fixtures/context-evaluations.fixture.js | 29 +++ api/tests/unit/context/evaluation.test.js | 204 ++++++++++++++++-- docs/openapi.json | 12 +- .../org.hypercerts.context.getEvaluation.json | 10 +- ...rg.hypercerts.context.listEvaluations.json | 2 +- 15 files changed, 388 insertions(+), 116 deletions(-) create mode 100644 .changeset/tolerate-malformed-evaluation-evaluators.md diff --git a/.agents/skills/hypercerts-api/references/endpoint-map.md b/.agents/skills/hypercerts-api/references/endpoint-map.md index 3572d22..68eb970 100644 --- a/.agents/skills/hypercerts-api/references/endpoint-map.md +++ b/.agents/skills/hypercerts-api/references/endpoint-map.md @@ -90,8 +90,8 @@ Do not confuse actor follows with entity follows; their identifiers and list out | List acknowledgements | `org.hypercerts.context.listAcknowledgements` | Filter by publisher or subject as documented. | | Load one attachment | `org.hypercerts.context.getAttachment` | Exact record AT-URI in `uri`. | | List attachments | `org.hypercerts.context.listAttachments` | Filter by publisher, record URI, subject, or content type. | -| Load one evaluation | `org.hypercerts.context.getEvaluation` | Exact record AT-URI in `uri`; includes publisher/evaluator projections. | -| List evaluations | `org.hypercerts.context.listEvaluations` | Filter by publisher, evaluator, or subject. | +| Load one evaluation | `org.hypercerts.context.getEvaluation` | Exact record AT-URI in `uri`; includes publisher/evaluator projections and preserves raw records when malformed evaluator entries are omitted. | +| List evaluations | `org.hypercerts.context.listEvaluations` | Filter by publisher, evaluator, or subject; malformed evaluator entries do not hide otherwise valid records. | | Load one measurement | `org.hypercerts.context.getMeasurement` | Exact record AT-URI in `uri`. | | List measurements | `org.hypercerts.context.listMeasurements` | Filter by publisher or subject. | diff --git a/.agents/skills/hypercerts-api/references/openapi.json b/.agents/skills/hypercerts-api/references/openapi.json index aadcd5a..72f0dd1 100644 --- a/.agents/skills/hypercerts-api/references/openapi.json +++ b/.agents/skills/hypercerts-api/references/openapi.json @@ -45,11 +45,11 @@ "/xrpc/org.hypercerts.collection.searchCollections": {"get":{"operationId":"org_hypercerts_collection_searchCollections","tags":["org.hypercerts.collection"],"description":"Searches collection title or shortDescription for the complete trimmed text as a case-insensitive literal substring; blank text adds no search restriction. Other filters combine with search using AND, with OR within arrays except tagUris, which requires every supplied URI. Authentication is not required.","parameters":[{"name":"search","in":"query","required":true,"description":"Case-insensitive literal substring in title or shortDescription; blank trimmed text adds no search restriction.","schema":{"type":"string"}},{"name":"authors","in":"query","required":false,"description":"Repository-owner DID to match.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"hasOrganizationRecord","in":"query","required":false,"description":"Filter by the author's organization/self record; false matches absence regardless of profile presence.","schema":{"type":"boolean"}},{"name":"types","in":"query","required":false,"description":"Exact collection type to match.","schema":{"type":"array","maxItems":100,"items":{"type":"string","maxLength":64}},"style":"form","explode":true},{"name":"uris","in":"query","required":false,"description":"Match by collection record AT-URI; this does not select a CID version.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"itemUris","in":"query","required":false,"description":"Match when an embedded item's itemIdentifier.uri equals a supplied AT-URI; matching ignores CID.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"tagUris","in":"query","required":false,"description":"Require every supplied vocabulary-tag AT-URI to occur in record.tags; matching ignores CID and tag records.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Direction for (createdAt, uri) ordering; defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum collections per page; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from a previous response; use the same sortDirection.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.collection.searchCollections.output"}}}}},"x-lexicon-id":"org.hypercerts.collection.searchCollections","x-lexicon-errors":[{"name":"InvalidRequest","description":"Search text or another query parameter is invalid or unsupported."}]}}, "/xrpc/org.hypercerts.context.getAcknowledgement": {"get":{"operationId":"org_hypercerts_context_getAcknowledgement","tags":["org.hypercerts.context"],"description":"Gets one public acknowledgement by exact AT-URI; authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"Acknowledgement record AT-URI with a DID authority.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.getAcknowledgement.output"}}}}},"x-lexicon-id":"org.hypercerts.context.getAcknowledgement","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or does not identify an acknowledgement record."},{"name":"RecordNotFound","description":"No acknowledgement is indexed at this AT-URI."},{"name":"AcknowledgementQueryFailed","description":"The acknowledgement or publisher lookup failed."}]}}, "/xrpc/org.hypercerts.context.getAttachment": {"get":{"operationId":"org_hypercerts_context_getAttachment","tags":["org.hypercerts.context"],"description":"Returns the current indexed attachment at the given AT-URI with its publisher view. Authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"AT-URI of the attachment record, using a DID authority; lookup is by URI only, not a specific CID/version.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.getAttachment.output"}}}}},"x-lexicon-id":"org.hypercerts.context.getAttachment","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or does not identify an attachment record."},{"name":"RecordNotFound","description":"No indexed attachment was found at this AT-URI."},{"name":"AttachmentQueryFailed","description":"The attachment lookup or required publisher hydration failed."}]}}, - "/xrpc/org.hypercerts.context.getEvaluation": {"get":{"operationId":"org_hypercerts_context_getEvaluation","tags":["org.hypercerts.context"],"description":"Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"AT-URI of the evaluation record, using a DID authority; lookup is by URI only, not a specific CID/version.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.output"}}}}},"x-lexicon-id":"org.hypercerts.context.getEvaluation","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or does not identify an evaluation record."},{"name":"RecordNotFound","description":"No indexed evaluation was found at this AT-URI."}]}}, + "/xrpc/org.hypercerts.context.getEvaluation": {"get":{"operationId":"org_hypercerts_context_getEvaluation","tags":["org.hypercerts.context"],"description":"Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Invalid evaluator entries are omitted from the hydrated projection while the original record is preserved. Authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"AT-URI of the evaluation record, using a DID authority; lookup is by URI only, not a specific CID/version.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.output"}}}}},"x-lexicon-id":"org.hypercerts.context.getEvaluation","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or does not identify an evaluation record."},{"name":"RecordNotFound","description":"No indexed evaluation was found at this AT-URI."}]}}, "/xrpc/org.hypercerts.context.getMeasurement": {"get":{"operationId":"org_hypercerts_context_getMeasurement","tags":["org.hypercerts.context"],"description":"Gets an indexed measurement by exact AT-URI and hydrates its publisher. Authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"Full measurement record AT-URI using a DID authority.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.getMeasurement.output"}}}}},"x-lexicon-id":"org.hypercerts.context.getMeasurement","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or is not a measurement record AT-URI."},{"name":"RecordNotFound","description":"No indexed measurement exists at this AT-URI."}]}}, "/xrpc/org.hypercerts.context.listAcknowledgements": {"get":{"operationId":"org_hypercerts_context_listAcknowledgements","tags":["org.hypercerts.context"],"description":"Lists public acknowledgements by publisher or subject; authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Publisher DIDs; OR within this filter.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"subjects","in":"query","required":false,"description":"Subject record AT-URIs; matching ignores CID.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Sort direction for createdAt and URI; default desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum records per page.","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque next-page cursor bound to sortDirection; keep other parameters unchanged.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.listAcknowledgements.output"}}}}},"x-lexicon-id":"org.hypercerts.context.listAcknowledgements","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter value, sort direction, page bound, cursor, repeated scalar, or query parameter is invalid."},{"name":"AcknowledgementQueryFailed","description":"The acknowledgement or publisher lookup failed."}]}}, "/xrpc/org.hypercerts.context.listAttachments": {"get":{"operationId":"org_hypercerts_context_listAttachments","tags":["org.hypercerts.context"],"description":"Lists indexed attachments with optional publisher, record URI, subject URI, and content-type filters. Values within each array filter use OR; distinct filters use AND, and duplicate values are ignored. Authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Publisher repository DIDs to match.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"uris","in":"query","required":false,"description":"Attachment AT-URIs with DID authorities to include; matching uses URI only, not CID/version. Results follow sort order, not input order.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"subjects","in":"query","required":false,"description":"Match any record.subjects[].uri against these AT-URIs with DID authorities; CID values are ignored.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"contentTypes","in":"query","required":false,"description":"Exact matches on record.contentType; this is not a file MIME-type filter.","schema":{"type":"array","maxItems":100,"items":{"type":"string"}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Sort direction by (createdAt, uri); defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum page size; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor tied to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.listAttachments.output"}}}}},"x-lexicon-id":"org.hypercerts.context.listAttachments","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter value, cursor, sort direction, page size, or query parameter is invalid."},{"name":"AttachmentQueryFailed","description":"The attachment query or required publisher hydration failed."}]}}, - "/xrpc/org.hypercerts.context.listEvaluations": {"get":{"operationId":"org_hypercerts_context_listEvaluations","tags":["org.hypercerts.context"],"description":"Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Publisher repository DIDs to match.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"evaluators","in":"query","required":false,"description":"DIDs in any record.evaluators[].did entry, including entries beyond the hydration cap.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"subjects","in":"query","required":false,"description":"Match record.subject.uri by AT-URI with a DID authority; the reference CID is ignored.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Sort direction by (createdAt, uri); defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum page size; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor tied to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.listEvaluations.output"}}}}},"x-lexicon-id":"org.hypercerts.context.listEvaluations","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter value, cursor, sort direction, page size, or query parameter is invalid."}]}}, + "/xrpc/org.hypercerts.context.listEvaluations": {"get":{"operationId":"org_hypercerts_context_listEvaluations","tags":["org.hypercerts.context"],"description":"Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Records remain listed when evaluator entries cannot be projected; malformed entries are omitted from hydrated views while the original record is preserved. Authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Publisher repository DIDs to match.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"evaluators","in":"query","required":false,"description":"DIDs in any record.evaluators[].did entry, including entries beyond the hydration cap.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"subjects","in":"query","required":false,"description":"Match record.subject.uri by AT-URI with a DID authority; the reference CID is ignored.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Sort direction by (createdAt, uri); defaults to desc.","schema":{"type":"string","enum":["asc","desc"]}},{"name":"limit","in":"query","required":false,"description":"Maximum page size; defaults to 25.","schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor tied to sortDirection; keep all other parameters unchanged between pages.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.listEvaluations.output"}}}}},"x-lexicon-id":"org.hypercerts.context.listEvaluations","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter value, cursor, sort direction, page size, or query parameter is invalid."}]}}, "/xrpc/org.hypercerts.context.listMeasurements": {"get":{"operationId":"org_hypercerts_context_listMeasurements","tags":["org.hypercerts.context"],"description":"Lists measurements with optional publisher and subject filters. Authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Publisher repository DIDs, not record.measurers.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"subjects","in":"query","required":false,"description":"Subject AT-URIs matched against record.subjects[].uri, ignoring CID.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"at-uri"}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Sort by createdAt and URI; default desc.","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"limit","in":"query","required":false,"description":"Maximum page size; default 25.","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor tied to sortDirection; retain other filters between pages.","schema":{"type":"string","maxLength":8192}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.context.listMeasurements.output"}}}}},"x-lexicon-id":"org.hypercerts.context.listMeasurements","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter value, sort direction, page bound, cursor, repeated scalar, or query parameter is invalid."}]}}, "/xrpc/org.hypercerts.entity.getFeature": {"get":{"operationId":"org_hypercerts_entity_getFeature","tags":["org.hypercerts.entity"],"description":"Looks up one indexed feature by exact AT-URI with author hydration; authentication is not required.","parameters":[{"name":"uri","in":"query","required":true,"description":"Full feature record AT-URI using a DID authority.","schema":{"type":"string","format":"at-uri"}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.entity.getFeature.output"}}}}},"x-lexicon-id":"org.hypercerts.entity.getFeature","x-lexicon-errors":[{"name":"InvalidRequest","description":"The URI is invalid or is not a feature record AT-URI."},{"name":"RecordNotFound","description":"No indexed feature exists at this AT-URI."}]}}, "/xrpc/org.hypercerts.entity.listFeatures": {"get":{"operationId":"org_hypercerts_entity_listFeatures","tags":["org.hypercerts.entity"],"description":"Lists indexed features with filters and direction-bound pagination; authentication is not required.","parameters":[{"name":"authors","in":"query","required":false,"description":"Repository-owner DIDs; values combine with OR.","schema":{"type":"array","maxItems":100,"items":{"type":"string","format":"did"}},"style":"form","explode":true},{"name":"hasOrganizationRecord","in":"query","required":false,"description":"Whether the author has an organization self record; false is independent of profile presence.","schema":{"type":"boolean"}},{"name":"types","in":"query","required":false,"description":"Exact open-string feature types; values combine with OR.","schema":{"type":"array","maxItems":100,"items":{"type":"string","description":"Exact case-sensitive feature type.","maxLength":64}},"style":"form","explode":true},{"name":"sortDirection","in":"query","required":false,"description":"Direction for sorting by createdAt and URI.","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"limit","in":"query","required":false,"description":"Maximum number of features in the page.","schema":{"type":"integer","default":25,"minimum":1,"maximum":100}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page, bound to sortDirection.","schema":{"type":"string","maxLength":32768}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/org.hypercerts.entity.listFeatures.output"}}}}},"x-lexicon-id":"org.hypercerts.entity.listFeatures","x-lexicon-errors":[{"name":"InvalidRequest","description":"A filter, page bound, cursor, repeated scalar, or query parameter is invalid."}]}}, @@ -165,9 +165,9 @@ "org.hypercerts.context.getAttachment.attachmentView": {"type":"object","description":"Attachment with its hydrated publisher and unchanged source record; subject, content, and location references are not expanded, and external files are not fetched. Missing publisher sidecars are null; query or hydration failures fail the request.","required":["uri","cid","indexedAt","did","author","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"author":{"$ref":"#/components/schemas/org.hypercerts.api.defs.actorView"},"record":{"$ref":"#/components/schemas/org.hypercerts.context.attachment.main"}}}, "org.hypercerts.context.getAttachment.output": {"type":"object","required":["attachment"],"properties":{"attachment":{"$ref":"#/components/schemas/org.hypercerts.context.getAttachment.attachmentView"}}}, "org.hypercerts.context.evaluation.main": {"type":"record","description":"An evaluation of a hypercert record (e.g. an activity and its impact)."}, - "org.hypercerts.context.getEvaluation.hydratedEvaluationEvaluatorView": {"type":"object","description":"An original evaluator entry with its profile and organization sidecars looked up.","required":["did","hydrationStatus","profile","organization"],"properties":{"did":{"type":"string","format":"did"},"hydrationStatus":{"type":"string","const":"hydrated"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"},{"type":"null"}]}}}, - "org.hypercerts.context.getEvaluation.omittedEvaluationEvaluatorView": {"type":"object","description":"An original evaluator entry not hydrated.","required":["did","hydrationStatus"],"properties":{"did":{"type":"string","format":"did"},"hydrationStatus":{"type":"string","const":"omitted"}}}, - "org.hypercerts.context.getEvaluation.evaluationView": {"type":"object","description":"Evaluation with the complete original record, hydrated publisher, and evaluator views. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request.","required":["uri","cid","indexedAt","did","author","record","evaluators"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"type":"string","format":"datetime"},"did":{"type":"string","format":"did"},"author":{"$ref":"#/components/schemas/org.hypercerts.api.defs.actorView"},"record":{"$ref":"#/components/schemas/org.hypercerts.context.evaluation.main"},"evaluators":{"type":"array","description":"One-for-one projection of record.evaluators, preserving source order, duplicates, and entry fields. Entries after position 100 remain present with hydrationStatus \"omitted\" and no profile or organization.","maxItems":1000,"items":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.hydratedEvaluationEvaluatorView"},{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.omittedEvaluationEvaluatorView"}]}}}}, + "org.hypercerts.context.getEvaluation.hydratedEvaluationEvaluatorView": {"type":"object","description":"A valid original evaluator entry with its profile and organization sidecars looked up.","required":["did","hydrationStatus","profile","organization"],"properties":{"did":{"type":"string","format":"did"},"hydrationStatus":{"type":"string","const":"hydrated"},"profile":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.profileView"},{"type":"null"}]},"organization":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.api.defs.organizationView"},{"type":"null"}]}}}, + "org.hypercerts.context.getEvaluation.omittedEvaluationEvaluatorView": {"type":"object","description":"A valid original evaluator entry beyond the hydration position limit.","required":["did","hydrationStatus"],"properties":{"did":{"type":"string","format":"did"},"hydrationStatus":{"type":"string","const":"omitted"}}}, + "org.hypercerts.context.getEvaluation.evaluationView": {"type":"object","description":"Evaluation with the original record, hydrated publisher, and evaluator projections. The record is not rewritten when evaluator data is malformed. A missing or non-array evaluator container yields an empty hydrated array; malformed entries are omitted. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request. Evaluator arrays over 1000 entries still fail exact lookup and are skipped by lists.","required":["uri","cid","indexedAt","did","author","record","evaluators"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"type":"string","format":"datetime"},"did":{"type":"string","format":"did"},"author":{"$ref":"#/components/schemas/org.hypercerts.api.defs.actorView"},"record":{"$ref":"#/components/schemas/org.hypercerts.context.evaluation.main"},"evaluators":{"type":"array","description":"Dense projection of valid record.evaluators object entries with valid DIDs, preserving source order and duplicates. Entries with malformed shapes or invalid DIDs are omitted without changing the original record; a missing or non-array source yields an empty array. Valid entries at source positions after 100 remain present with hydrationStatus \"omitted\" and no profile or organization. The raw evaluator array is limited to 1000 entries; over-limit records fail exact lookup and are skipped by lists.","maxItems":1000,"items":{"anyOf":[{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.hydratedEvaluationEvaluatorView"},{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.omittedEvaluationEvaluatorView"}]}}}}, "org.hypercerts.context.getEvaluation.output": {"type":"object","required":["evaluation"],"properties":{"evaluation":{"$ref":"#/components/schemas/org.hypercerts.context.getEvaluation.evaluationView"}}}, "org.hypercerts.context.measurement.main": {"type":"record","description":"Measurement data related to one or more records (e.g. activities, projects, etc.)."}, "org.hypercerts.context.getMeasurement.measurementView": {"type":"object","description":"Indexed measurement with its publisher actor and original record.","required":["uri","cid","indexedAt","did","author","record"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"indexedAt":{"anyOf":[{"type":"string","format":"datetime"},{"type":"null"}]},"did":{"type":"string","format":"did"},"author":{"$ref":"#/components/schemas/org.hypercerts.api.defs.actorView"},"record":{"$ref":"#/components/schemas/org.hypercerts.context.measurement.main"}}}, diff --git a/.agents/skills/hypercerts-api/references/reliability.md b/.agents/skills/hypercerts-api/references/reliability.md index 5536f82..9f29f67 100644 --- a/.agents/skills/hypercerts-api/references/reliability.md +++ b/.agents/skills/hypercerts-api/references/reliability.md @@ -34,6 +34,8 @@ For a potentially fresh record that is absent: A nullable actor profile does not imply an invalid DID; a missing organization sidecar does not prove the actor is missing; an unavailable exact CID does not prove another version at that URI is absent. +For `org.hypercerts.context.getEvaluation` and `listEvaluations`, the top-level `record` remains the indexed value. The separate hydrated `evaluators` array is a dense projection of entries shaped as objects with valid DIDs; malformed entries are omitted, and a missing or non-array source yields an empty projection. Do not treat an empty projection as proof that the raw record had no evaluator data. More than 1,000 raw entries remains outside the supported limit: exact lookup fails and lists skip that record. + ## Diagnose failures without overgeneralizing 1. Record HTTP status and parse the JSON XRPC error code/message when present; preserve a fallback for empty or non-JSON bodies. See [request-patterns.md](request-patterns.md#handle-http-and-xrpc-errors). diff --git a/.changeset/tolerate-malformed-evaluation-evaluators.md b/.changeset/tolerate-malformed-evaluation-evaluators.md new file mode 100644 index 0000000..267ede2 --- /dev/null +++ b/.changeset/tolerate-malformed-evaluation-evaluators.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Evaluation lookups and lists now retain records with malformed evaluator entries. The original record is preserved, while invalid entries are omitted from the hydrated evaluator projection. Records with more than 1,000 raw evaluator entries remain outside the supported limit. diff --git a/api/lexicons/org.hypercerts.context.getEvaluation.json b/api/lexicons/org.hypercerts.context.getEvaluation.json index b48ec22..934cbcf 100644 --- a/api/lexicons/org.hypercerts.context.getEvaluation.json +++ b/api/lexicons/org.hypercerts.context.getEvaluation.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Authentication is not required.", + "description": "Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Invalid evaluator entries are omitted from the hydrated projection while the original record is preserved. Authentication is not required.", "parameters": { "type": "params", "required": ["uri"], @@ -27,7 +27,7 @@ }, "hydratedEvaluationEvaluatorView": { "type": "object", - "description": "An original evaluator entry with its profile and organization sidecars looked up.", + "description": "A valid original evaluator entry with its profile and organization sidecars looked up.", "required": ["did", "hydrationStatus", "profile", "organization"], "nullable": ["profile", "organization"], "properties": { @@ -39,7 +39,7 @@ }, "omittedEvaluationEvaluatorView": { "type": "object", - "description": "An original evaluator entry not hydrated.", + "description": "A valid original evaluator entry beyond the hydration position limit.", "required": ["did", "hydrationStatus"], "properties": { "did": { "type": "string", "format": "did" }, @@ -48,7 +48,7 @@ }, "evaluationView": { "type": "object", - "description": "Evaluation with the complete original record, hydrated publisher, and evaluator views. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request.", + "description": "Evaluation with the original record, hydrated publisher, and evaluator projections. The record is not rewritten when evaluator data is malformed. A missing or non-array evaluator container yields an empty hydrated array; malformed entries are omitted. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request. Evaluator arrays over 1000 entries still fail exact lookup and are skipped by lists.", "required": ["uri", "cid", "indexedAt", "did", "author", "record", "evaluators"], "properties": { "uri": { "type": "string", "format": "at-uri" }, @@ -60,7 +60,7 @@ "evaluators": { "type": "array", "maxLength": 1000, - "description": "One-for-one projection of record.evaluators, preserving source order, duplicates, and entry fields. Entries after position 100 remain present with hydrationStatus \"omitted\" and no profile or organization.", + "description": "Dense projection of valid record.evaluators object entries with valid DIDs, preserving their source order and duplicates. Entries with malformed shapes or invalid DIDs are omitted without changing the original record; a missing or non-array source yields an empty array. Valid entries at source positions after 100 remain present with hydrationStatus \"omitted\" and no profile or organization. The raw evaluator array is limited to 1000 entries; over-limit records fail exact lookup and are skipped by lists.", "items": { "type": "union", "refs": ["#hydratedEvaluationEvaluatorView", "#omittedEvaluationEvaluatorView"] diff --git a/api/lexicons/org.hypercerts.context.listEvaluations.json b/api/lexicons/org.hypercerts.context.listEvaluations.json index 7dffa0a..70f9975 100644 --- a/api/lexicons/org.hypercerts.context.listEvaluations.json +++ b/api/lexicons/org.hypercerts.context.listEvaluations.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Authentication is not required.", + "description": "Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Records remain listed when evaluator entries cannot be projected; malformed entries are omitted from hydrated views while the original record is preserved. Authentication is not required.", "parameters": { "type": "params", "properties": { diff --git a/api/lua/endpoints/getEvaluation.lua b/api/lua/endpoints/getEvaluation.lua index b824ef3..bcc667a 100644 --- a/api/lua/endpoints/getEvaluation.lua +++ b/api/lua/endpoints/getEvaluation.lua @@ -95,22 +95,28 @@ local function evaluation_query(sql, values) return result end +local function evaluation_evaluator_array(value) + if type(value) ~= "table" then return nil end + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then return nil end + count = count + 1 + end + if count ~= #value then return nil end + return value +end + local function evaluation_view(row, skip_invalid) local view = record_view(row) - if type(view.record) ~= "table" or type(view.record.evaluators) ~= "table" then + if type(view.record) ~= "table" then if skip_invalid then return nil end error("EvaluationQueryFailed: indexed evaluation has no evaluator array", 0) end - if #view.record.evaluators > 1000 then + local evaluators = evaluation_evaluator_array(view.record.evaluators) + if evaluators and #evaluators > 1000 then if skip_invalid then return nil end error("EvaluationQueryFailed: indexed evaluation exceeds the evaluator limit", 0) end - for _, evaluator in ipairs(view.record.evaluators) do - if type(evaluator) ~= "table" or not valid_did(evaluator.did) then - if skip_invalid then return nil end - error("EvaluationQueryFailed: indexed evaluation contains an invalid evaluator DID", 0) - end - end view.author = { did = row.did } return view end @@ -123,23 +129,24 @@ local function hydrate_evaluation_views(views) for view_index, view in ipairs(views) do actors[#actors + 1] = view.author local projections, hydrated_actors = {}, {} - for position, source in ipairs(view.record.evaluators) do - if type(source) ~= "table" or not valid_did(source.did) then - error("EvaluationQueryFailed: indexed evaluation contains an invalid evaluator DID", 0) - end - local evaluator = {} - for key, value in pairs(source) do evaluator[key] = value end - if position <= EVALUATOR_HYDRATION_LIMIT then - evaluator.hydrationStatus = "hydrated" - local actor = { did = source.did } - actors[#actors + 1] = actor - hydrated_actors[position] = actor - else - evaluator.hydrationStatus = "omitted" - evaluator.profile = nil - evaluator.organization = nil + local sources = evaluation_evaluator_array(view.record.evaluators) or {} + for source_position, source in ipairs(sources) do + if type(source) == "table" and valid_did(source.did) then + local projection_position = #projections + 1 + local evaluator = {} + for key, value in pairs(source) do evaluator[key] = value end + if source_position <= EVALUATOR_HYDRATION_LIMIT then + evaluator.hydrationStatus = "hydrated" + local actor = { did = source.did } + actors[#actors + 1] = actor + hydrated_actors[projection_position] = actor + else + evaluator.hydrationStatus = "omitted" + evaluator.profile = nil + evaluator.organization = nil + end + projections[projection_position] = evaluator end - projections[position] = evaluator end evaluator_projections[view_index] = projections evaluator_actors[view_index] = hydrated_actors diff --git a/api/lua/endpoints/listEvaluations.lua b/api/lua/endpoints/listEvaluations.lua index 28b2103..9012646 100644 --- a/api/lua/endpoints/listEvaluations.lua +++ b/api/lua/endpoints/listEvaluations.lua @@ -137,22 +137,28 @@ local function evaluation_query(sql, values) return result end +local function evaluation_evaluator_array(value) + if type(value) ~= "table" then return nil end + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then return nil end + count = count + 1 + end + if count ~= #value then return nil end + return value +end + local function evaluation_view(row, skip_invalid) local view = record_view(row) - if type(view.record) ~= "table" or type(view.record.evaluators) ~= "table" then + if type(view.record) ~= "table" then if skip_invalid then return nil end error("EvaluationQueryFailed: indexed evaluation has no evaluator array", 0) end - if #view.record.evaluators > 1000 then + local evaluators = evaluation_evaluator_array(view.record.evaluators) + if evaluators and #evaluators > 1000 then if skip_invalid then return nil end error("EvaluationQueryFailed: indexed evaluation exceeds the evaluator limit", 0) end - for _, evaluator in ipairs(view.record.evaluators) do - if type(evaluator) ~= "table" or not valid_did(evaluator.did) then - if skip_invalid then return nil end - error("EvaluationQueryFailed: indexed evaluation contains an invalid evaluator DID", 0) - end - end view.author = { did = row.did } return view end @@ -165,23 +171,24 @@ local function hydrate_evaluation_views(views) for view_index, view in ipairs(views) do actors[#actors + 1] = view.author local projections, hydrated_actors = {}, {} - for position, source in ipairs(view.record.evaluators) do - if type(source) ~= "table" or not valid_did(source.did) then - error("EvaluationQueryFailed: indexed evaluation contains an invalid evaluator DID", 0) - end - local evaluator = {} - for key, value in pairs(source) do evaluator[key] = value end - if position <= EVALUATOR_HYDRATION_LIMIT then - evaluator.hydrationStatus = "hydrated" - local actor = { did = source.did } - actors[#actors + 1] = actor - hydrated_actors[position] = actor - else - evaluator.hydrationStatus = "omitted" - evaluator.profile = nil - evaluator.organization = nil + local sources = evaluation_evaluator_array(view.record.evaluators) or {} + for source_position, source in ipairs(sources) do + if type(source) == "table" and valid_did(source.did) then + local projection_position = #projections + 1 + local evaluator = {} + for key, value in pairs(source) do evaluator[key] = value end + if source_position <= EVALUATOR_HYDRATION_LIMIT then + evaluator.hydrationStatus = "hydrated" + local actor = { did = source.did } + actors[#actors + 1] = actor + hydrated_actors[projection_position] = actor + else + evaluator.hydrationStatus = "omitted" + evaluator.profile = nil + evaluator.organization = nil + end + projections[projection_position] = evaluator end - projections[position] = evaluator end evaluator_projections[view_index] = projections evaluator_actors[view_index] = hydrated_actors diff --git a/api/lua/shared/evaluation.lua b/api/lua/shared/evaluation.lua index b54e8a5..6da8e66 100644 --- a/api/lua/shared/evaluation.lua +++ b/api/lua/shared/evaluation.lua @@ -12,22 +12,28 @@ local function evaluation_query(sql, values) return result end +local function evaluation_evaluator_array(value) + if type(value) ~= "table" then return nil end + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then return nil end + count = count + 1 + end + if count ~= #value then return nil end + return value +end + local function evaluation_view(row, skip_invalid) local view = record_view(row) - if type(view.record) ~= "table" or type(view.record.evaluators) ~= "table" then + if type(view.record) ~= "table" then if skip_invalid then return nil end error("EvaluationQueryFailed: indexed evaluation has no evaluator array", 0) end - if #view.record.evaluators > 1000 then + local evaluators = evaluation_evaluator_array(view.record.evaluators) + if evaluators and #evaluators > 1000 then if skip_invalid then return nil end error("EvaluationQueryFailed: indexed evaluation exceeds the evaluator limit", 0) end - for _, evaluator in ipairs(view.record.evaluators) do - if type(evaluator) ~= "table" or not valid_did(evaluator.did) then - if skip_invalid then return nil end - error("EvaluationQueryFailed: indexed evaluation contains an invalid evaluator DID", 0) - end - end view.author = { did = row.did } return view end @@ -40,23 +46,24 @@ local function hydrate_evaluation_views(views) for view_index, view in ipairs(views) do actors[#actors + 1] = view.author local projections, hydrated_actors = {}, {} - for position, source in ipairs(view.record.evaluators) do - if type(source) ~= "table" or not valid_did(source.did) then - error("EvaluationQueryFailed: indexed evaluation contains an invalid evaluator DID", 0) - end - local evaluator = {} - for key, value in pairs(source) do evaluator[key] = value end - if position <= EVALUATOR_HYDRATION_LIMIT then - evaluator.hydrationStatus = "hydrated" - local actor = { did = source.did } - actors[#actors + 1] = actor - hydrated_actors[position] = actor - else - evaluator.hydrationStatus = "omitted" - evaluator.profile = nil - evaluator.organization = nil + local sources = evaluation_evaluator_array(view.record.evaluators) or {} + for source_position, source in ipairs(sources) do + if type(source) == "table" and valid_did(source.did) then + local projection_position = #projections + 1 + local evaluator = {} + for key, value in pairs(source) do evaluator[key] = value end + if source_position <= EVALUATOR_HYDRATION_LIMIT then + evaluator.hydrationStatus = "hydrated" + local actor = { did = source.did } + actors[#actors + 1] = actor + hydrated_actors[projection_position] = actor + else + evaluator.hydrationStatus = "omitted" + evaluator.profile = nil + evaluator.organization = nil + end + projections[projection_position] = evaluator end - projections[position] = evaluator end evaluator_projections[view_index] = projections evaluator_actors[view_index] = hydrated_actors diff --git a/api/tests/http/context-evaluations.http.test.js b/api/tests/http/context-evaluations.http.test.js index 3c9c8cb..f3890ad 100644 --- a/api/tests/http/context-evaluations.http.test.js +++ b/api/tests/http/context-evaluations.http.test.js @@ -5,12 +5,17 @@ import { contractUrl, requireContractTarget } from './helpers.js'; const getEndpoint = 'org.hypercerts.context.getEvaluation'; const listEndpoint = 'org.hypercerts.context.listEvaluations'; const authorA = 'did:web:context-evaluation-a.invalid'; +const malformedAuthor = 'did:web:context-evaluation-malformed.invalid'; const evaluatorA = 'did:web:context-evaluator-a.invalid'; +const evaluatorD = 'did:web:context-evaluator-d.invalid'; +const legacyEvaluatorDid = 'did:web:context-evaluator-legacy.invalid'; const evaluationUris = { a: `at://${authorA}/org.hypercerts.context.evaluation/3jzfcijpj2z2a`, b: `at://${authorA}/org.hypercerts.context.evaluation/3jzfcijpj2z2b`, c: 'at://did:web:context-evaluation-b.invalid/org.hypercerts.context.evaluation/3jzfcijpj2z2c', d: 'at://did:web:context-evaluation-b.invalid/org.hypercerts.context.evaluation/3jzfcijpj2z2d', + e: `at://${malformedAuthor}/org.hypercerts.context.evaluation/3jzfcijpj2z2e`, + f: `at://${malformedAuthor}/org.hypercerts.context.evaluation/3jzfcijpj2z2f`, }; async function request(endpoint, params = {}) { @@ -61,6 +66,23 @@ test('getEvaluation preserves the CBOR-addressed record and hydrates publisher a assert.equal(evaluation.evaluators[2].organization, null); }); +test('getEvaluation preserves malformed raw evaluator entries and densely hydrates valid DID objects', async () => { + const { response, body } = await request(getEndpoint, { uri: evaluationUris.e }); + assert.equal(response.status, 200, JSON.stringify(body)); + + const { evaluation } = body; + assert.deepEqual(evaluation.record.evaluators, [ + { did: evaluatorD }, legacyEvaluatorDid, { did: 'reviewer.example' }, { did: evaluatorD }, + ]); + assert.deepEqual(evaluation.evaluators.map(({ did, hydrationStatus }) => ({ did, hydrationStatus })), [ + { did: evaluatorD, hydrationStatus: 'hydrated' }, + { did: evaluatorD, hydrationStatus: 'hydrated' }, + ]); + assert.equal(evaluation.evaluators[0].profile.record.displayName, 'Tolerated projection evaluator'); + assert.equal(evaluation.evaluators[1].profile.record.displayName, 'Tolerated projection evaluator'); + assert.equal(evaluation.author.did, malformedAuthor); +}); + test('getEvaluation exposes RecordNotFound and InvalidRequest through the pinned runtime error response', async () => { const missing = await request(getEndpoint, { uri: `at://${authorA}/org.hypercerts.context.evaluation/not-indexed`, @@ -102,19 +124,42 @@ test('listEvaluations applies each filter, ORs repeated values, and ANDs distinc test('listEvaluations paginates the complete feed across tied createdAt timestamps', async () => { const expected = [evaluationUris.a, evaluationUris.b, evaluationUris.c, evaluationUris.d]; - const first = await request(listEndpoint, { sortDirection: 'asc', limit: 2 }); + const authors = [authorA, 'did:web:context-evaluation-b.invalid']; + const first = await request(listEndpoint, { authors, sortDirection: 'asc', limit: 2 }); assert.equal(first.response.status, 200, JSON.stringify(first.body)); assert.deepEqual(first.body.evaluations.map(({ uri }) => uri), expected.slice(0, 2)); assert.equal(typeof first.body.cursor, 'string'); const second = await request(listEndpoint, { - sortDirection: 'asc', limit: 2, cursor: first.body.cursor, + authors, sortDirection: 'asc', limit: 2, cursor: first.body.cursor, }); assert.equal(second.response.status, 200, JSON.stringify(second.body)); assert.deepEqual(second.body.evaluations.map(({ uri }) => uri), expected.slice(2)); assert.equal(Object.hasOwn(second.body, 'cursor'), false); }); +test('listEvaluations retains malformed evaluator records and paginates without dropping them', async () => { + const params = { authors: [malformedAuthor], sortDirection: 'asc', limit: 1 }; + const first = await request(listEndpoint, params); + assert.equal(first.response.status, 200, JSON.stringify(first.body)); + assert.deepEqual(first.body.evaluations.map(({ uri }) => uri), [evaluationUris.e]); + assert.deepEqual(first.body.evaluations[0].record.evaluators, [ + { did: evaluatorD }, legacyEvaluatorDid, { did: 'reviewer.example' }, { did: evaluatorD }, + ]); + assert.deepEqual(first.body.evaluations[0].evaluators.map(({ did, hydrationStatus }) => ({ did, hydrationStatus })), [ + { did: evaluatorD, hydrationStatus: 'hydrated' }, + { did: evaluatorD, hydrationStatus: 'hydrated' }, + ]); + assert.equal(typeof first.body.cursor, 'string'); + + const second = await request(listEndpoint, { ...params, cursor: first.body.cursor }); + assert.equal(second.response.status, 200, JSON.stringify(second.body)); + assert.deepEqual(second.body.evaluations.map(({ uri }) => uri), [evaluationUris.f]); + assert.equal(second.body.evaluations[0].evaluators[0].did, 'did:web:context-evaluator-c.invalid'); + assert.equal(second.body.evaluations[0].evaluators[0].hydrationStatus, 'hydrated'); + assert.equal(Object.hasOwn(second.body, 'cursor'), false); +}); + test('listEvaluations exposes InvalidRequest through the pinned runtime error response', async () => { const { response, body } = await request(listEndpoint, { limit: 101 }); assert.equal(response.status, 500, JSON.stringify(body)); diff --git a/api/tests/http/fixtures/context-evaluations.fixture.js b/api/tests/http/fixtures/context-evaluations.fixture.js index 8c7d9e5..70e600c 100644 --- a/api/tests/http/fixtures/context-evaluations.fixture.js +++ b/api/tests/http/fixtures/context-evaluations.fixture.js @@ -9,6 +9,9 @@ const authorB = 'did:web:context-evaluation-b.invalid'; const evaluatorA = 'did:web:context-evaluator-a.invalid'; const evaluatorB = 'did:web:context-evaluator-b.invalid'; const evaluatorC = 'did:web:context-evaluator-c.invalid'; +const evaluatorD = 'did:web:context-evaluator-d.invalid'; +const legacyEvaluatorDid = 'did:web:context-evaluator-legacy.invalid'; +const malformedAuthor = 'did:web:context-evaluation-malformed.invalid'; const subjectA = 'at://did:web:context-eval-subject-a.invalid/org.hypercerts.claim.activity/subject-a'; const subjectB = 'at://did:web:context-eval-subject-b.invalid/org.hypercerts.claim.activity/subject-b'; const subjectC = 'at://did:web:context-eval-subject-c.invalid/org.hypercerts.claim.activity/subject-c'; @@ -61,6 +64,28 @@ const evaluationSpecs = [ score: { min: '0', max: '5', value: '1' }, }, }, + { + did: malformedAuthor, + rkey: '3jzfcijpj2z2e', + record: { + evaluators: [ + { did: evaluatorD }, legacyEvaluatorDid, { did: 'reviewer.example' }, { did: evaluatorD }, + ], + summary: 'Evaluation with malformed evaluator entries', + createdAt: tiedCreatedAt, + subject: { uri: subjectA, cid: subjectCid }, + }, + }, + { + did: malformedAuthor, + rkey: '3jzfcijpj2z2f', + record: { + evaluators: [{ did: evaluatorC }], + summary: 'Evaluation after malformed evaluator entries', + createdAt: tiedCreatedAt, + subject: { uri: subjectA, cid: subjectCid }, + }, + }, ]; async function seedRow(collection, did, rkey, fields) { @@ -86,6 +111,10 @@ export const seedRows = await Promise.all([ displayName: 'Named context evaluator', createdAt: indexedAt, }), + seedRow(profileCollection, evaluatorD, 'self', { + displayName: 'Tolerated projection evaluator', + createdAt: indexedAt, + }), seedRow(organizationCollection, authorB, 'self', { organizationType: ['nonprofit'], visibility: 'public', diff --git a/api/tests/unit/context/evaluation.test.js b/api/tests/unit/context/evaluation.test.js index e59fa59..b6c9c90 100644 --- a/api/tests/unit/context/evaluation.test.js +++ b/api/tests/unit/context/evaluation.test.js @@ -151,10 +151,130 @@ assert(calls[1].values[1] == '${EVALUATION}' and calls[1].values[2] == '${evalua assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); }); -test('listEvaluations skips an invalid evaluator row and paginates through later valid records', () => { - const bad = { - ...evaluationRow, uri: `at://${authorDid}/${EVALUATION}/bad`, record: 'bad-evaluation', - record_json: { ...evaluationRecord, evaluators: [{ did: 'not-a-did' }] }, +test('getEvaluation preserves raw entries and densely hydrates valid evaluator objects only', () => { + const legacyDid = 'did:plc:ffffffffffffffffffffffff'; + const mixedRecord = { + ...evaluationRecord, + evaluators: [ + { did: evaluatorDid }, legacyDid, { did: 'reviewer.example' }, { did: missingDid }, { did: evaluatorDid }, + ], + }; + const row = { ...evaluationRow, record: 'mixed-evaluation', record_json: mixedRecord }; + const result = runLua({ + endpoint: 'getEvaluation', + params: { uri: evaluationUri }, + queryResults: [[row], [authorProfile, evaluatorProfile], [evaluatorOrganization]], + assertions: ` +local view = result.evaluation +assert(view.record.evaluators[2] == '${legacyDid}' and view.record.evaluators[3].did == 'reviewer.example') +assert(view.record.evaluators[4].did == '${missingDid}' and view.record.evaluators[5].did == '${evaluatorDid}') +assert(view.record.evaluators[1].hydrationStatus == nil) +assert(#view.evaluators == 3, 'invalid entries are skipped and the projected array is dense') +assert(view.evaluators[1].did == '${evaluatorDid}' and view.evaluators[1].hydrationStatus == 'hydrated') +assert(view.evaluators[1].profile.record.displayName == 'Named evaluator') +assert(view.evaluators[1].organization.record.organizationType[1] == 'nonprofit') +assert(view.evaluators[2].did == '${missingDid}' and view.evaluators[2].hydrationStatus == 'hydrated') +assert(view.evaluators[2].profile == NULL and view.evaluators[2].organization == NULL) +assert(view.evaluators[3].did == '${evaluatorDid}' and view.evaluators[3].hydrationStatus == 'hydrated') +local profile_values = table.concat(calls[2].values, '|') +assert(profile_values:find('${authorDid}', 1, true) and profile_values:find('${evaluatorDid}', 1, true)) +assert(profile_values:find('${missingDid}', 1, true)) +assert(not profile_values:find('${legacyDid}', 1, true) and not profile_values:find('reviewer.example', 1, true)) +assert(#calls == 3) +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('getEvaluation keeps the 100-entry hydration boundary tied to raw source positions', () => { + const legacyDid = 'did:plc:ffffffffffffffffffffffff'; + const row = { + ...evaluationRow, + record: 'invalid-prefix-evaluation', + record_json: { + ...evaluationRecord, + evaluators: [...Array(100).fill(legacyDid), { did: omittedDid }], + }, + }; + const result = runLua({ + endpoint: 'getEvaluation', + params: { uri: evaluationUri }, + queryResults: [[row], [authorProfile], []], + assertions: ` +local view = result.evaluation +assert(#view.record.evaluators == 101 and view.record.evaluators[1] == '${legacyDid}') +assert(#view.evaluators == 1 and view.evaluators[1].did == '${omittedDid}') +assert(view.evaluators[1].hydrationStatus == 'omitted') +assert(view.evaluators[1].profile == nil and view.evaluators[1].organization == nil) +assert(not table.concat(calls[2].values, '|'):find('${omittedDid}', 1, true)) +assert(#calls == 3) +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('getEvaluation returns empty evaluator projections for all-invalid, non-array, and absent containers', () => { + const legacyDid = 'did:plc:ffffffffffffffffffffffff'; + const cases = [ + { + name: 'all-invalid', + record_json: { ...evaluationRecord, evaluators: [legacyDid, { did: 'reviewer.example' }] }, + rawAssertion: `assert(view.record.evaluators[1] == '${legacyDid}' and view.record.evaluators[2].did == 'reviewer.example')`, + }, + { + name: 'non-array', + record_json: { ...evaluationRecord, evaluators: { legacy: { did: evaluatorDid } } }, + rawAssertion: `assert(view.record.evaluators.legacy.did == '${evaluatorDid}')`, + }, + { + name: 'absent', + record_json: Object.fromEntries(Object.entries(evaluationRecord).filter(([key]) => key !== 'evaluators')), + rawAssertion: 'assert(view.record.evaluators == nil)', + }, + ]; + + for (const { name, record_json, rawAssertion } of cases) { + const row = { ...evaluationRow, record: `${name}-evaluation`, record_json }; + const result = runLua({ + endpoint: 'getEvaluation', + params: { uri: evaluationUri }, + queryResults: [[row], [authorProfile], []], + assertions: ` +local view = result.evaluation +assert(view.record.summary == 'Original evaluation summary') +${rawAssertion} +assert(type(view.evaluators) == 'table' and #view.evaluators == 0) +assert(view.author.profile.record.displayName == 'Evaluation publisher') +assert(#calls == 3, 'only the publisher is sent for sidecar hydration') +`, + }); + assert.equal(result.status, 0, `${name}\n${result.stderr}${result.stdout}`); + } +}); + +test('getEvaluation rejects evaluator arrays over the retained 1000-entry resource limit', () => { + const row = { + ...evaluationRow, + record: 'over-limit-evaluation', + record_json: { ...evaluationRecord, evaluators: Array(1001).fill('did:plc:ffffffffffffffffffffffff') }, + }; + const result = runLua({ + endpoint: 'getEvaluation', + params: { uri: evaluationUri }, + queryResults: [[row]], + expectError: 'exceeds the evaluator limit', + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('listEvaluations retains malformed evaluator records and paginates without dropping them', () => { + const legacyDid = 'did:plc:ffffffffffffffffffffffff'; + const malformed = { + ...evaluationRow, uri: `at://${authorDid}/${EVALUATION}/malformed`, record: 'malformed-evaluation', + record_json: { + ...evaluationRecord, + evaluators: [legacyDid, { did: 'reviewer.example' }, { did: evaluatorDid }], + }, sort_timestamp: '2025-01-03T00:00:00.000000Z', }; const good = { @@ -167,28 +287,78 @@ test('listEvaluations skips an invalid evaluator row and paginates through later }; const result = runLua({ endpoint: 'listEvaluations', params: { limit: '1' }, - queryResults: [[bad, good], [good, last], [authorProfile, evaluatorProfile], [evaluatorOrganization], [last], [authorProfile, evaluatorProfile], [evaluatorOrganization]], + queryResults: [ + [malformed, good], [authorProfile, evaluatorProfile], [evaluatorOrganization], + [good, last], [authorProfile, evaluatorProfile], [evaluatorOrganization], + [last], [authorProfile, evaluatorProfile], [evaluatorOrganization], + ], assertions: ` -assert(#result.evaluations == 1 and result.evaluations[1].uri == '${good.uri}') -assert(result.cursor ~= nil, 'cursor continues after the valid row') +assert(#result.evaluations == 1 and result.evaluations[1].uri == '${malformed.uri}') +local first = result.evaluations[1] +assert(first.record.evaluators[1] == '${legacyDid}' and first.record.evaluators[2].did == 'reviewer.example') +assert(#first.evaluators == 1 and first.evaluators[1].did == '${evaluatorDid}') +assert(first.evaluators[1].hydrationStatus == 'hydrated' and first.evaluators[1].profile.record.displayName == 'Named evaluator') local cursor = json.decode(result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end)) -assert(cursor.u == '${good.uri}') +assert(cursor.u == '${malformed.uri}') params.cursor = result.cursor -local next_page = handle() -assert(#next_page.evaluations == 1 and next_page.evaluations[1].uri == '${last.uri}' and next_page.cursor == nil) -assert(calls[2].values[2] == '${bad.sort_timestamp}' and calls[2].values[3] == '${bad.uri}') -assert(calls[5].values[2] == '${good.sort_timestamp}' and calls[5].values[3] == '${good.uri}') +local second_page = handle() +assert(#second_page.evaluations == 1 and second_page.evaluations[1].uri == '${good.uri}') +assert(second_page.cursor ~= nil) +local second_cursor = json.decode(second_page.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end)) +assert(second_cursor.u == '${good.uri}') +params.cursor = second_page.cursor +local third_page = handle() +assert(#third_page.evaluations == 1 and third_page.evaluations[1].uri == '${last.uri}' and third_page.cursor == nil) +assert(calls[4].values[2] == '${malformed.sort_timestamp}' and calls[4].values[3] == '${malformed.uri}') +assert(calls[7].values[2] == '${good.sort_timestamp}' and calls[7].values[3] == '${good.uri}') +`, + }); + assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); +}); + +test('listEvaluations returns all-invalid, non-array, and absent evaluator containers with empty projections', () => { + const legacyDid = 'did:plc:ffffffffffffffffffffffff'; + const cases = [ + { name: 'all-invalid', evaluators: [legacyDid, { did: 'reviewer.example' }] }, + { name: 'non-array', evaluators: { legacy: { did: evaluatorDid } } }, + { name: 'absent' }, + ]; + const rows = cases.map(({ name, evaluators }) => { + const record_json = { $type: EVALUATION, summary: name, createdAt }; + if (evaluators !== undefined) record_json.evaluators = evaluators; + return { + ...evaluationRow, + uri: `at://${authorDid}/${EVALUATION}/${name}`, + record: `${name}-evaluation`, + record_json, + sort_timestamp: '2025-01-02T00:00:00.000000Z', + }; + }); + const result = runLua({ + endpoint: 'listEvaluations', params: {}, + queryResults: [[...rows], [authorProfile], []], + assertions: ` +assert(#result.evaluations == 3) +assert(result.evaluations[1].uri == '${rows[0].uri}') +assert(result.evaluations[2].uri == '${rows[1].uri}') +assert(result.evaluations[3].uri == '${rows[2].uri}') +for _, evaluation in ipairs(result.evaluations) do assert(#evaluation.evaluators == 0) end +assert(result.evaluations[1].record.evaluators[1] == '${legacyDid}') +assert(result.evaluations[2].record.evaluators.legacy.did == '${evaluatorDid}') +assert(result.evaluations[3].record.evaluators == nil) +assert(#calls == 3, 'authors still hydrate when evaluator projections are empty') `, }); assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); }); -test('listEvaluations bounds scans of consecutive invalid rows and returns a continuation cursor', () => { +test('listEvaluations bounds scans of over-limit evaluator arrays and returns a continuation cursor', () => { + const tooManyEvaluators = Array(1001).fill('did:plc:ffffffffffffffffffffffff'); const rows = Array.from({ length: 11 }, (_, index) => ({ ...evaluationRow, - uri: `at://${authorDid}/${EVALUATION}/bad-${index}`, - record: `bad-evaluation-${index}`, - record_json: { ...evaluationRecord, evaluators: [{ did: 'not-a-did' }] }, + uri: `at://${authorDid}/${EVALUATION}/over-limit-${index}`, + record: 'over-limit-evaluation', + record_json: { ...evaluationRecord, evaluators: tooManyEvaluators }, sort_timestamp: `2025-01-01T00:00:${String(59 - index).padStart(2, '0')}.000000Z`, })); const queryResults = Array.from({ length: 10 }, (_, index) => [rows[index], rows[index + 1]]); @@ -198,7 +368,7 @@ test('listEvaluations bounds scans of consecutive invalid rows and returns a con assert(#result.evaluations == 0 and result.cursor ~= nil) assert(#calls == 10, 'stop after ten query batches instead of scanning indefinitely') local token = json.decode(result.cursor:gsub('..', function(pair) return string.char(tonumber(pair, 16)) end)) -assert(token.u == '${rows[9].uri}', 'continuation cursor must advance past the last inspected row') +assert(token.u == '${rows[9].uri}', 'continuation cursor must advance past the last inspected over-limit row') `, }); assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); diff --git a/docs/openapi.json b/docs/openapi.json index 54d2e4c..8c92ca7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3358,7 +3358,7 @@ "tags": [ "org.hypercerts.context" ], - "description": "Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Authentication is not required.", + "description": "Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Invalid evaluator entries are omitted from the hydrated projection while the original record is preserved. Authentication is not required.", "parameters": [ { "name": "uri", @@ -3692,7 +3692,7 @@ "tags": [ "org.hypercerts.context" ], - "description": "Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Authentication is not required.", + "description": "Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Records remain listed when evaluator entries cannot be projected; malformed entries are omitted from hydrated views while the original record is preserved. Authentication is not required.", "parameters": [ { "name": "authors", @@ -6944,7 +6944,7 @@ }, "org.hypercerts.context.getEvaluation.hydratedEvaluationEvaluatorView": { "type": "object", - "description": "An original evaluator entry with its profile and organization sidecars looked up.", + "description": "A valid original evaluator entry with its profile and organization sidecars looked up.", "required": [ "did", "hydrationStatus", @@ -6984,7 +6984,7 @@ }, "org.hypercerts.context.getEvaluation.omittedEvaluationEvaluatorView": { "type": "object", - "description": "An original evaluator entry not hydrated.", + "description": "A valid original evaluator entry beyond the hydration position limit.", "required": [ "did", "hydrationStatus" @@ -7002,7 +7002,7 @@ }, "org.hypercerts.context.getEvaluation.evaluationView": { "type": "object", - "description": "Evaluation with the complete original record, hydrated publisher, and evaluator views. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request.", + "description": "Evaluation with the original record, hydrated publisher, and evaluator projections. The record is not rewritten when evaluator data is malformed. A missing or non-array evaluator container yields an empty hydrated array; malformed entries are omitted. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request. Evaluator arrays over 1000 entries still fail exact lookup and are skipped by lists.", "required": [ "uri", "cid", @@ -7037,7 +7037,7 @@ }, "evaluators": { "type": "array", - "description": "One-for-one projection of record.evaluators, preserving source order, duplicates, and entry fields. Entries after position 100 remain present with hydrationStatus \"omitted\" and no profile or organization.", + "description": "Dense projection of valid record.evaluators object entries with valid DIDs, preserving their source order and duplicates. Entries with malformed shapes or invalid DIDs are omitted without changing the original record; a missing or non-array source yields an empty array. Valid entries at source positions after 100 remain present with hydrationStatus \"omitted\" and no profile or organization. The raw evaluator array is limited to 1000 entries; over-limit records fail exact lookup and are skipped by lists.", "maxItems": 1000, "items": { "anyOf": [ diff --git a/docs/sources/lexicons/org.hypercerts.context.getEvaluation.json b/docs/sources/lexicons/org.hypercerts.context.getEvaluation.json index 891c481..b2a51cb 100644 --- a/docs/sources/lexicons/org.hypercerts.context.getEvaluation.json +++ b/docs/sources/lexicons/org.hypercerts.context.getEvaluation.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Authentication is not required.", + "description": "Returns the indexed evaluation at the given AT-URI with its publisher and evaluator views. Invalid evaluator entries are omitted from the hydrated projection while the original record is preserved. Authentication is not required.", "parameters": { "type": "params", "required": [ @@ -38,7 +38,7 @@ }, "hydratedEvaluationEvaluatorView": { "type": "object", - "description": "An original evaluator entry with its profile and organization sidecars looked up.", + "description": "A valid original evaluator entry with its profile and organization sidecars looked up.", "required": [ "did", "hydrationStatus", @@ -70,7 +70,7 @@ }, "omittedEvaluationEvaluatorView": { "type": "object", - "description": "An original evaluator entry not hydrated.", + "description": "A valid original evaluator entry beyond the hydration position limit.", "required": [ "did", "hydrationStatus" @@ -88,7 +88,7 @@ }, "evaluationView": { "type": "object", - "description": "Evaluation with the complete original record, hydrated publisher, and evaluator views. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request.", + "description": "Evaluation with the original record, hydrated publisher, and evaluator projections. The record is not rewritten when evaluator data is malformed. A missing or non-array evaluator container yields an empty hydrated array; malformed entries are omitted. For hydrated actors, missing profile or organization sidecars are null; query or hydration failures fail the request. Evaluator arrays over 1000 entries still fail exact lookup and are skipped by lists.", "required": [ "uri", "cid", @@ -126,7 +126,7 @@ "evaluators": { "type": "array", "maxLength": 1000, - "description": "One-for-one projection of record.evaluators, preserving source order, duplicates, and entry fields. Entries after position 100 remain present with hydrationStatus \"omitted\" and no profile or organization.", + "description": "Dense projection of valid record.evaluators object entries with valid DIDs, preserving their source order and duplicates. Entries with malformed shapes or invalid DIDs are omitted without changing the original record; a missing or non-array source yields an empty array. Valid entries at source positions after 100 remain present with hydrationStatus \"omitted\" and no profile or organization. The raw evaluator array is limited to 1000 entries; over-limit records fail exact lookup and are skipped by lists.", "items": { "type": "union", "refs": [ diff --git a/docs/sources/lexicons/org.hypercerts.context.listEvaluations.json b/docs/sources/lexicons/org.hypercerts.context.listEvaluations.json index 5e70695..d865371 100644 --- a/docs/sources/lexicons/org.hypercerts.context.listEvaluations.json +++ b/docs/sources/lexicons/org.hypercerts.context.listEvaluations.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Authentication is not required.", + "description": "Lists indexed evaluations with optional publisher, evaluator, and subject filters. Values within each array filter use OR; distinct filters use AND. Records remain listed when evaluator entries cannot be projected; malformed entries are omitted from hydrated views while the original record is preserved. Authentication is not required.", "parameters": { "type": "params", "properties": {