@crvouga/atlas 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/dist/reports/src/review-collection-store.d.ts +47 -0
  3. package/dist/reports/src/review-collection-store.js +214 -0
  4. package/dist/reports/src/review-collection.d.ts +88 -0
  5. package/dist/reports/src/review-collection.js +144 -0
  6. package/dist/reports/src/test-catalog.d.ts +2 -0
  7. package/dist/reports/src/test-catalog.js +1 -1
  8. package/dist/schema/src/api.d.ts +574 -0
  9. package/dist/schema/src/api.js +152 -1
  10. package/dist/server/src/app.js +4 -1
  11. package/dist/server/src/openapi.js +46 -1
  12. package/dist/server/src/review-collections.d.ts +39 -0
  13. package/dist/server/src/review-collections.js +179 -0
  14. package/docs/extending.md +1 -1
  15. package/docs/reference/http-api.md +122 -0
  16. package/docs/reference/reports.md +294 -0
  17. package/docs/reference/schema.md +131 -0
  18. package/docs/reference/server.md +44 -0
  19. package/docs/rich-evidence.md +38 -0
  20. package/docs/server.md +41 -0
  21. package/docs/ui.md +45 -0
  22. package/llms-full.txt +247 -1
  23. package/package.json +1 -1
  24. package/packages/schema/schemas/review-collection.schema.json +398 -0
  25. package/packages/schema/schemas/review-manifest.schema.json +475 -0
  26. package/packages/server/openapi.json +6729 -4084
  27. package/packages/visualizer/src/app/router.tsx +6 -1
  28. package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
  29. package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
  30. package/packages/visualizer/src/evidence/applicability.ts +241 -0
  31. package/packages/visualizer/src/evidence/artifact-tags.tsx +2 -1
  32. package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
  33. package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
  34. package/packages/visualizer/src/explorer/ArtifactsView.tsx +19 -5
  35. package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
  36. package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
  37. package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
  38. package/packages/visualizer/src/explorer/collection-model.ts +45 -0
  39. package/packages/visualizer/src/explorer/collections.tsx +513 -0
  40. package/packages/visualizer/src/explorer/playlist-entries.ts +29 -0
  41. package/packages/visualizer/src/explorer/playlist.tsx +75 -16
  42. package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
  43. package/packages/visualizer/src/explorer/recording-text.ts +490 -0
  44. package/packages/visualizer/src/explorer/search.ts +10 -2
@@ -690,6 +690,128 @@ Scoped to one project. The same project may be selected on the unprefixed API us
690
690
 
691
691
  Responses: `201` The recorded entry.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `413` Request too large.; `422` Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.
692
692
 
693
+ ### Review collections: curated sets of recordings, newest first
694
+
695
+ `GET /api/reports/review-collections` · role: read
696
+
697
+ The newest revision of every review collection, in brief: id, revision and how many exist, title, purpose, audience, tags, how many entries and gaps it has, who published it and when. A collection is curation kept beside the report; it is not a layer, a check or a run, and it has no status. Open one with `/api/reports/review-collections/detail?id=`. Its share URL is the viewer's `?tab=collections&collection=<id>` and needs the same read access as the rest of the report.
698
+
699
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
700
+
701
+ Responses: `200` The collections.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
702
+
703
+ ### Review collections: curated sets of recordings, newest first
704
+
705
+ `GET /api/projects/{project}/reports/review-collections` · role: read
706
+
707
+ The newest revision of every review collection, in brief: id, revision and how many exist, title, purpose, audience, tags, how many entries and gaps it has, who published it and when. A collection is curation kept beside the report; it is not a layer, a check or a run, and it has no status. Open one with `/api/reports/review-collections/detail?id=`. Its share URL is the viewer's `?tab=collections&collection=<id>` and needs the same read access as the rest of the report.
708
+
709
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
710
+
711
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
712
+
713
+ Responses: `200` The collections.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
714
+
715
+ ### Publish an immutable review collection (or a new revision of one)
716
+
717
+ `POST /api/reports/review-collections` · role: publish
718
+
719
+ Freezes an ordered set of references to recorded results as one named collection, recorded under the name of the publish (or admin) token that sends it. Take each entry's `layer` and `check` (and `kind`) from `GET /api/reports/artifacts`, and optionally one of its recordings (`videos[].recording`). The server resolves every entry to the result the report holds and records what the report says about it (status, time, run, commit, environment, tries, videos): the request does not claim them. An entry it cannot resolve refuses the whole request (`422`, naming the entry). The order is yours and is never re-sorted; failed, rejected and incomplete results stay in it beside a later passing replay.
720
+
721
+ Entries from different runs are never one execution: each keeps its own layer, run and status. A collection has no status of its own, adds no check and cannot change any result or winner. A revision is immutable. To change a collection, publish a new revision with `basedOn: { id, revision }` naming the newest revision (`409` otherwise); the earlier ones stay readable. Notes and reviewed marks are separate (`/reviews`). A reader-only token, a browser sign-in box and an anonymous reader are refused; nothing is shared publicly by default.
722
+
723
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
724
+
725
+ Responses: `201` The recorded revision.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `404` `basedOn` names no collection.; `409` `basedOn.revision` is not the newest.; `413` Request too large.; `422` Not a collection, or an entry the report does not hold.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
726
+
727
+ ### Publish an immutable review collection (or a new revision of one)
728
+
729
+ `POST /api/projects/{project}/reports/review-collections` · role: publish
730
+
731
+ Freezes an ordered set of references to recorded results as one named collection, recorded under the name of the publish (or admin) token that sends it. Take each entry's `layer` and `check` (and `kind`) from `GET /api/reports/artifacts`, and optionally one of its recordings (`videos[].recording`). The server resolves every entry to the result the report holds and records what the report says about it (status, time, run, commit, environment, tries, videos): the request does not claim them. An entry it cannot resolve refuses the whole request (`422`, naming the entry). The order is yours and is never re-sorted; failed, rejected and incomplete results stay in it beside a later passing replay.
732
+
733
+ Entries from different runs are never one execution: each keeps its own layer, run and status. A collection has no status of its own, adds no check and cannot change any result or winner. A revision is immutable. To change a collection, publish a new revision with `basedOn: { id, revision }` naming the newest revision (`409` otherwise); the earlier ones stay readable. Notes and reviewed marks are separate (`/reviews`). A reader-only token, a browser sign-in box and an anonymous reader are refused; nothing is shared publicly by default.
734
+
735
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
736
+
737
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
738
+
739
+ Responses: `201` The recorded revision.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `404` `basedOn` names no collection.; `409` `basedOn.revision` is not the newest.; `413` Request too large.; `422` Not a collection, or an entry the report does not hold.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
740
+
741
+ ### One review collection revision with how its sources stand now
742
+
743
+ `GET /api/reports/review-collections/detail` · role: read
744
+
745
+ The revision (the newest without `revision`), every revision number, the review notes and reviewed marks of that revision, and for each entry `availability`: `available` (its layer is held and the report lists the result), `unverified` (the layer is held but the report no longer lists the result or recording) or `unavailable` (the layer is gone, for example deleted by garbage collection), with the reason. An entry whose source is not available stays in its place with its frozen snapshot; it is never removed or replaced. Entries keep the status each layer recorded, with their own times, in the order the curator chose. A reviewed mark is curation: it changes no outcome and is not an approval.
746
+
747
+ - `id` (query, required): A collection id from the list.
748
+ - `revision` (query): A revision number. Default: the newest.
749
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
750
+
751
+ Responses: `200` The revision.; `400` No `id`, or a bad `revision`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No such collection or revision.
752
+
753
+ ### One review collection revision with how its sources stand now
754
+
755
+ `GET /api/projects/{project}/reports/review-collections/detail` · role: read
756
+
757
+ The revision (the newest without `revision`), every revision number, the review notes and reviewed marks of that revision, and for each entry `availability`: `available` (its layer is held and the report lists the result), `unverified` (the layer is held but the report no longer lists the result or recording) or `unavailable` (the layer is gone, for example deleted by garbage collection), with the reason. An entry whose source is not available stays in its place with its frozen snapshot; it is never removed or replaced. Entries keep the status each layer recorded, with their own times, in the order the curator chose. A reviewed mark is curation: it changes no outcome and is not an approval.
758
+
759
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
760
+
761
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
762
+ - `id` (query, required): A collection id from the list.
763
+ - `revision` (query): A revision number. Default: the newest.
764
+
765
+ Responses: `200` The revision.; `400` No `id`, or a bad `revision`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No such collection or revision.
766
+
767
+ ### Add a note, a time-coded gap note or a reviewed mark to a collection entry
768
+
769
+ `POST /api/reports/review-collections/reviews` · role: publish
770
+
771
+ Records one immutable entry, by the name of the publish (or admin) token and the server's time: a `note` or a `gap` (text required; with `entry` and optionally `timecodeMs`, a moment in that entry's recording), or a `reviewed` / `unreviewed` mark for one entry. It changes no check outcome, no layer, no collection revision and no report winner; a mark is not an acceptance sign-off. The notes of a revision are in its `detail`.
772
+
773
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
774
+
775
+ Responses: `201` The recorded note.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `404` No such collection or revision.; `413` Request too large.; `422` Not a note: missing text, an unknown entry, or a mark without an entry.
776
+
777
+ ### Add a note, a time-coded gap note or a reviewed mark to a collection entry
778
+
779
+ `POST /api/projects/{project}/reports/review-collections/reviews` · role: publish
780
+
781
+ Records one immutable entry, by the name of the publish (or admin) token and the server's time: a `note` or a `gap` (text required; with `entry` and optionally `timecodeMs`, a moment in that entry's recording), or a `reviewed` / `unreviewed` mark for one entry. It changes no check outcome, no layer, no collection revision and no report winner; a mark is not an acceptance sign-off. The notes of a revision are in its `detail`.
782
+
783
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
784
+
785
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
786
+
787
+ Responses: `201` The recorded note.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `404` No such collection or revision.; `413` Request too large.; `422` Not a note: missing text, an unknown entry, or a mark without an entry.
788
+
789
+ ### Portable index (manifest) of one review collection revision
790
+
791
+ `GET /api/reports/review-collections/export` · role: read
792
+
793
+ A manifest an archive can be built from: the collection, each entry with its exact source (layer, check, kind, recording), the status and run it was recorded with, how it stands now, whether it is marked reviewed, and the files it references with their name as recorded, a safe name and path (`files/<position>-<safeName>`), and the recorded `sha256` when the report holds one (`null` is unknown, not verified). A source that cannot be read now is listed under `missing` and its files are marked `missing`; nothing is left out silently. The manifest contains no bytes; recordings are fetched from the attachment routes, which keep their own access control. Sent as an attachment.
794
+
795
+ - `id` (query, required): A collection id.
796
+ - `revision` (query): A revision number. Default: the newest.
797
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
798
+
799
+ Responses: `200` The manifest.; `400` No `id`, or a bad `revision`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No such collection or revision.
800
+
801
+ ### Portable index (manifest) of one review collection revision
802
+
803
+ `GET /api/projects/{project}/reports/review-collections/export` · role: read
804
+
805
+ A manifest an archive can be built from: the collection, each entry with its exact source (layer, check, kind, recording), the status and run it was recorded with, how it stands now, whether it is marked reviewed, and the files it references with their name as recorded, a safe name and path (`files/<position>-<safeName>`), and the recorded `sha256` when the report holds one (`null` is unknown, not verified). A source that cannot be read now is listed under `missing` and its files are marked `missing`; nothing is left out silently. The manifest contains no bytes; recordings are fetched from the attachment routes, which keep their own access control. Sent as an attachment.
806
+
807
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
808
+
809
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
810
+ - `id` (query, required): A collection id.
811
+ - `revision` (query): A revision number. Default: the newest.
812
+
813
+ Responses: `200` The manifest.; `400` No `id`, or a bad `revision`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No such collection or revision.
814
+
693
815
  ### The runs that recorded the report
694
816
 
695
817
  `GET /api/reports/runs` · role: read
@@ -7784,6 +7784,298 @@ export async function pruneReports(store: ObjectStore): Promise<ReportRetentionP
7784
7784
 
7785
7785
  Same as `planReportRetention`. Despite the name it deletes nothing, and `deleteKeys` is always empty.
7786
7786
 
7787
+ ### Review collections in a store
7788
+
7789
+ Source: `packages/reports/src/review-collection-store.ts`. Import: `@crvouga/atlas/reports/review-collection-store`
7790
+
7791
+ Where review collections are kept: one immutable object per revision under `curation/review-collections/<id>/`, and
7792
+ one small immutable object per reviewer's note under `curation/review-notes/<id>/`, named by time and a random id so
7793
+ editors never overwrite one another. Nothing here is ever changed or deleted by the server, and nothing here touches
7794
+ the layers the entries point at: a collection is curation beside the report, not a layer and not a test run.
7795
+
7796
+ Publishing refuses; it never alters. A request the server cannot resolve to exact, held results is refused with
7797
+ the entry that failed, and what is recorded is what was asked for, plus what the report said about each source.
7798
+
7799
+ See docs/server.md#review-collections
7800
+
7801
+ #### `ReviewCollectionError`
7802
+
7803
+ ```ts
7804
+ export class ReviewCollectionError extends Error
7805
+ ```
7806
+
7807
+ The request was not acceptable. `status` is the HTTP status to answer: 404 (no such collection), 409 (not the newest revision) or 422.
7808
+
7809
+ #### `ResultFinder`
7810
+
7811
+ ```ts
7812
+ export type ResultFinder = (layer: string, check: string, kind: string | undefined) => readonly ArtifactResult[];
7813
+ ```
7814
+
7815
+ Finds the recorded results of a layer's check (all kinds when `kind` is undefined).
7816
+
7817
+ #### `reviewRevisions`
7818
+
7819
+ ```ts
7820
+ export async function reviewRevisions(store: ObjectStore, id: string): Promise<number[]>
7821
+ ```
7822
+
7823
+ The revisions that exist for a collection id, oldest first. Empty when there is none. Transport errors throw.
7824
+
7825
+ #### `readReviewCollection`
7826
+
7827
+ ```ts
7828
+ export async function readReviewCollection(store: ObjectStore, id: string, revision?: number): Promise<{ readonly collection: ReviewCollection; readonly revisions: number[] } | null>
7829
+ ```
7830
+
7831
+ One revision of a collection (the newest when `revision` is absent), or `null` when there is none or it cannot be read
7832
+ as a collection (unknown, never guessed at). Transport errors throw.
7833
+
7834
+ #### `recordReviewCollection`
7835
+
7836
+ ```ts
7837
+ export async function recordReviewCollection(store: ObjectStore, input: unknown, by: string, atMs: number, find: ResultFinder): Promise<ReviewCollection>
7838
+ ```
7839
+
7840
+ Validates a request (`ReviewCollectionRequestSchema`) and records it as a new immutable revision, by `by` at `atMs`.
7841
+ Without `basedOn` it is a new collection with a new id; with it, revision `basedOn.revision + 1` of that collection,
7842
+ and `basedOn.revision` must be the newest (409 otherwise) so two curators never silently overwrite each other.
7843
+
7844
+ Every entry is resolved with `find`: it must name a result the report holds (and, when it names a recording, one of
7845
+ that result's recordings) or the whole request is refused with 422 naming the entry. The snapshot recorded for an entry
7846
+ is what the report says about that result, not what the request claims. Entries are kept in the order given and a
7847
+ source may appear once. Throws {@link ReviewCollectionError}; a revision that already exists is never overwritten.
7848
+
7849
+ #### `listReviewCollections`
7850
+
7851
+ ```ts
7852
+ export async function listReviewCollections(store: ObjectStore): Promise<{ readonly collection: ReviewCollection; readonly revisions: number }[]>
7853
+ ```
7854
+
7855
+ The newest revision of every collection in `store`, newest first. One that cannot be read as a collection is skipped. Transport errors throw.
7856
+
7857
+ #### `recordReviewNote`
7858
+
7859
+ ```ts
7860
+ export async function recordReviewNote(store: ObjectStore, input: unknown, by: string, atMs: number): Promise<ReviewNote>
7861
+ ```
7862
+
7863
+ Validates a note (`ReviewNoteInputSchema`) against the revision it is about and records it as a new object, by `by` at
7864
+ `atMs`. `note` and `gap` need text; `reviewed` and `unreviewed` need an entry; an entry must be one of the revision's;
7865
+ a timecode needs an entry. Throws {@link ReviewCollectionError} (404 for no such collection or revision). It records
7866
+ curation only: no layer, result or collection revision changes.
7867
+
7868
+ #### `readReviewNotes`
7869
+
7870
+ ```ts
7871
+ export async function readReviewNotes(store: ObjectStore, id: string): Promise<ReviewNote[]>
7872
+ ```
7873
+
7874
+ Every recorded note of a collection (all revisions), oldest first. One that cannot be read as a note is skipped. Transport errors throw.
7875
+
7876
+ ### Review collections
7877
+
7878
+ Source: `packages/reports/src/review-collection.ts`. Import: `@crvouga/atlas/reports/review-collection`
7879
+
7880
+ A review collection is a named, curated, ordered set of references to results that other runs recorded: a few
7881
+ recordings from several executions, a note on what each is for, the gaps nobody recorded yet. It is curation, not a
7882
+ test run. It has no status of its own, carries no check, never decides a result or a winner, and never claims its
7883
+ entries are one execution: each entry keeps the layer, run, revision and status it was recorded with. A revision is
7884
+ immutable; changing a collection is publishing a new revision, explicitly. Reviewers' notes and "reviewed" marks live
7885
+ apart, as an append-only list with who and when, and are not outcomes.
7886
+
7887
+ This module is pure and safe in a browser: how an entry is frozen from the result it points at, how an entry stands
7888
+ now (available, unverified, unavailable), how marks fold, and the portable manifest. The store is
7889
+ `review-collection-store.ts`.
7890
+
7891
+ See docs/server.md#review-collections
7892
+
7893
+ #### `ReviewSource`
7894
+
7895
+ ```ts
7896
+ export type ReviewSource = z.infer<typeof ReviewSourceSchema>;
7897
+ ```
7898
+
7899
+ The exact result (and optionally one recording of it) an entry points at.
7900
+
7901
+ #### `ReviewSnapshot`
7902
+
7903
+ ```ts
7904
+ export type ReviewSnapshot = z.infer<typeof ReviewSnapshotSchema>;
7905
+ ```
7906
+
7907
+ What was recorded about a source when it was frozen.
7908
+
7909
+ #### `ReviewEntry`
7910
+
7911
+ ```ts
7912
+ export type ReviewEntry = z.infer<typeof ReviewEntrySchema>;
7913
+ ```
7914
+
7915
+ One entry of a collection revision.
7916
+
7917
+ #### `ReviewCollection`
7918
+
7919
+ ```ts
7920
+ export type ReviewCollection = z.infer<typeof ReviewCollectionSchema>;
7921
+ ```
7922
+
7923
+ One immutable revision of a collection.
7924
+
7925
+ #### `ReviewNote`
7926
+
7927
+ ```ts
7928
+ export type ReviewNote = z.infer<typeof ReviewNoteSchema>;
7929
+ ```
7930
+
7931
+ A recorded note, time-coded gap note or reviewed mark.
7932
+
7933
+ #### `ReviewAvailability`
7934
+
7935
+ ```ts
7936
+ export type ReviewAvailability = z.infer<typeof ReviewAvailabilitySchema>;
7937
+ ```
7938
+
7939
+ How an entry's source stands now.
7940
+
7941
+ #### `ReviewCollectionDetail`
7942
+
7943
+ ```ts
7944
+ export type ReviewCollectionDetail = z.infer<typeof ReviewCollectionDetailSchema>;
7945
+ ```
7946
+
7947
+ `GET /api/reports/review-collections/detail`: one revision, how its sources stand now, and its notes.
7948
+
7949
+ #### `ReviewCollectionList`
7950
+
7951
+ ```ts
7952
+ export type ReviewCollectionList = z.infer<typeof ReviewCollectionListSchema>;
7953
+ ```
7954
+
7955
+ `GET /api/reports/review-collections`: the newest revision of every collection.
7956
+
7957
+ #### `ReviewManifest`
7958
+
7959
+ ```ts
7960
+ export type ReviewManifest = z.infer<typeof ReviewManifestSchema>;
7961
+ ```
7962
+
7963
+ The portable index of one revision.
7964
+
7965
+ #### `REVIEW_COLLECTIONS_PREFIX`
7966
+
7967
+ ```ts
7968
+ export const REVIEW_COLLECTIONS_PREFIX = …
7969
+ ```
7970
+
7971
+ Where the revisions of collections are kept in a store: `<prefix><id>/r<revision, six digits>.json`.
7972
+
7973
+ #### `REVIEW_NOTES_PREFIX`
7974
+
7975
+ ```ts
7976
+ export const REVIEW_NOTES_PREFIX = …
7977
+ ```
7978
+
7979
+ Where reviewers' notes are kept: `<prefix><collection id>/<time>-<random>.json`, one object each.
7980
+
7981
+ #### `REVIEW_ID`
7982
+
7983
+ ```ts
7984
+ export const REVIEW_ID = …
7985
+ ```
7986
+
7987
+ A collection id as the server mints it and the address carries it. Anything else is not an id (and never reaches a store key).
7988
+
7989
+ #### `reviewCollectionKey`
7990
+
7991
+ ```ts
7992
+ export function reviewCollectionKey(id: string, revision: number): string
7993
+ ```
7994
+
7995
+ The store key of one revision of a collection.
7996
+
7997
+ #### `sameSource`
7998
+
7999
+ ```ts
8000
+ export function sameSource(a: ReviewSource, b: ReviewSource): boolean
8001
+ ```
8002
+
8003
+ Whether two sources name the same result and recording. `kind` counts only when both have one.
8004
+
8005
+ #### `snapshotOf`
8006
+
8007
+ ```ts
8008
+ export function snapshotOf(result: ArtifactResult): ReviewSnapshot
8009
+ ```
8010
+
8011
+ What is recorded about a result when an entry is frozen: its own status, time, run, commit and videos, as the layer
8012
+ recorded them. Nothing here is the collection's opinion, and none of it is ever updated: a later run does not change it.
8013
+
8014
+ #### `availabilityOf`
8015
+
8016
+ ```ts
8017
+ export function availabilityOf(
8018
+ collection: Pick<ReviewCollection, 'entries'>,
8019
+ layerHeld: (layer: string) => boolean,
8020
+ results: (layer: string, check: string, kind: string | undefined) => readonly ArtifactResult[]
8021
+ ): ReviewAvailability[]
8022
+ ```
8023
+
8024
+ How the sources of a collection stand now. `layerHeld` says whether a layer is still in a backend; `results` finds the
8025
+ results the report holds. A layer that is gone makes its entries `unavailable`; a layer that is held while the report
8026
+ no longer lists the result (or the recording) is `unverified`, not available and not gone. Every entry keeps its place
8027
+ and its explanation: nothing is removed or replaced.
8028
+
8029
+ #### `foldReviewed`
8030
+
8031
+ ```ts
8032
+ export function foldReviewed(notes: readonly ReviewNote[]): Record<string, { readonly by: string; readonly atMs: number }>
8033
+ ```
8034
+
8035
+ Folds the notes into the reviewed marks that stand now: for each entry the newest `reviewed` or `unreviewed` decides
8036
+ (ties in time go to the later id). Only entries currently `reviewed` are returned, with who and when. A mark is
8037
+ curation: it is not a check outcome and does not mean anyone accepted the behavior.
8038
+
8039
+ #### `safeFileName`
8040
+
8041
+ ```ts
8042
+ export function safeFileName(name: string): string
8043
+ ```
8044
+
8045
+ A name that is safe to write to any file system: the last path segment only, letters, digits, dot, dash and
8046
+ underscore (everything else becomes `_`), no leading dot, at most 100 characters, never empty.
8047
+
8048
+ **Example**
8049
+
8050
+ ```ts
8051
+ safeFileName('../../etc/pass wd.webm'); // 'pass_wd.webm'
8052
+ ```
8053
+
8054
+ #### `digestOf`
8055
+
8056
+ ```ts
8057
+ export function digestOf(recording: string | undefined): string | null
8058
+ ```
8059
+
8060
+ The digest in a recording identity (`sha256:<64 hex>`), or null when the identity is a bundle key or address: the bytes' hash is then not known.
8061
+
8062
+ #### `manifestOf`
8063
+
8064
+ ```ts
8065
+ export function manifestOf(
8066
+ collection: ReviewCollection,
8067
+ availability: readonly ReviewAvailability[],
8068
+ reviewed: Readonly<Record<string, unknown>>,
8069
+ generatedAtMs: number
8070
+ ): ReviewManifest
8071
+ ```
8072
+
8073
+ The portable index of one revision: every entry with where its result came from, how it stands now, whether it was
8074
+ marked reviewed, and the files (recordings) it references, each with its recorded digest when there is one and a name
8075
+ that is safe to write. A file whose source cannot be read now is listed as `missing`, with the reason under `missing`
8076
+ of the manifest, never left out. The manifest has no bytes; it is what a packer needs to build an archive that says
8077
+ what it lacks.
8078
+
7787
8079
  ### Run bundles
7788
8080
 
7789
8081
  Source: `packages/reports/src/run-bundle.ts`. Import: `@crvouga/atlas/reports/run-bundle`
@@ -9391,6 +9683,8 @@ export type ArtifactResult = {
9391
9683
  readonly types: readonly string[];
9392
9684
  readonly labels: Readonly<Record<string, string>>;
9393
9685
  readonly videos?: readonly VideoRef[];
9686
+ /** The status of each recorded try in order, when the check was retried. Absent when no try was recorded: a first try is then unknown, never assumed to have passed. */
9687
+ readonly attemptStatuses?: readonly EvidenceStatus[];
9394
9688
  };
9395
9689
  ```
9396
9690
 
@@ -233,6 +233,137 @@ export const ArtifactTagsSchema = …
233
233
 
234
234
  `GET /api/reports/artifact-tags`: every recorded edit, oldest first. Fold them with `foldArtifactTags`.
235
235
 
236
+ #### `ReviewSourceSchema`
237
+
238
+ ```ts
239
+ export const ReviewSourceSchema = …
240
+ ```
241
+
242
+ A source a review collection points at: one recorded result of one archived layer, and optionally one of its recordings. Exact and immutable: a layer never changes.
243
+
244
+ #### `ReviewEntryInputSchema`
245
+
246
+ ```ts
247
+ export const ReviewEntryInputSchema = …
248
+ ```
249
+
250
+ One entry of a request to publish a collection: a source and the curator\'s caption for it.
251
+
252
+ #### `ReviewGapInputSchema`
253
+
254
+ ```ts
255
+ export const ReviewGapInputSchema = …
256
+ ```
257
+
258
+ A gap the curator names: something the review should show and no recording does. It sits after the first `after` entries (0 or absent: before the first).
259
+
260
+ #### `ReviewCollectionRequestSchema`
261
+
262
+ ```ts
263
+ export const ReviewCollectionRequestSchema = …
264
+ ```
265
+
266
+ The body of `POST /api/reports/review-collections`: a new collection, or, with `basedOn`, an explicit new revision of
267
+ one. At least one entry or gap. The server records which sources the entries are now; it never alters the request.
268
+
269
+ #### `ReviewSnapshotSchema`
270
+
271
+ ```ts
272
+ export const ReviewSnapshotSchema = …
273
+ ```
274
+
275
+ What the server recorded about the source of an entry at the moment it was frozen.
276
+
277
+ #### `ReviewEntrySchema`
278
+
279
+ ```ts
280
+ export const ReviewEntrySchema = …
281
+ ```
282
+
283
+ One entry of a frozen collection.
284
+
285
+ #### `ReviewGapSchema`
286
+
287
+ ```ts
288
+ export const ReviewGapSchema = …
289
+ ```
290
+
291
+ A gap item of a collection.
292
+
293
+ #### `ReviewCollectionSchema`
294
+
295
+ ```ts
296
+ export const ReviewCollectionSchema = …
297
+ ```
298
+
299
+ One revision of a review collection, as stored: immutable. It is curation, not a test run: it has no status of its own,
300
+ carries no check, and its entries are references to results that other runs recorded. Entries from different
301
+ runs are never one execution.
302
+
303
+ #### `ReviewCollectionSummarySchema`
304
+
305
+ ```ts
306
+ export const ReviewCollectionSummarySchema = …
307
+ ```
308
+
309
+ One collection in the list: its newest revision in brief.
310
+
311
+ #### `ReviewCollectionListSchema`
312
+
313
+ ```ts
314
+ export const ReviewCollectionListSchema = …
315
+ ```
316
+
317
+ `GET /api/reports/review-collections`: the newest revision of every collection, newest first.
318
+
319
+ #### `ReviewNoteInputSchema`
320
+
321
+ ```ts
322
+ export const ReviewNoteInputSchema = …
323
+ ```
324
+
325
+ The body of `POST /api/reports/review-collections/reviews`: a note, a time-coded gap note, or a reviewed marker on one entry. Curation only: it changes no outcome.
326
+
327
+ #### `ReviewNoteSchema`
328
+
329
+ ```ts
330
+ export const ReviewNoteSchema = …
331
+ ```
332
+
333
+ One recorded review note. Entries are never changed or removed: they are the audit trail.
334
+
335
+ #### `ReviewAvailabilitySchema`
336
+
337
+ ```ts
338
+ export const ReviewAvailabilitySchema = …
339
+ ```
340
+
341
+ How an entry\'s source stands now: `available` (the layer and the result are in the report), `unverified` (the layer is held but the result is outside the report\'s history window) or `unavailable` (the layer is gone).
342
+
343
+ #### `ReviewCollectionDetailSchema`
344
+
345
+ ```ts
346
+ export const ReviewCollectionDetailSchema = …
347
+ ```
348
+
349
+ `GET /api/reports/review-collections/detail`: one revision, how each source stands now, and the review notes.
350
+
351
+ #### `ReviewManifestFileSchema`
352
+
353
+ ```ts
354
+ export const ReviewManifestFileSchema = …
355
+ ```
356
+
357
+ One file in an export manifest.
358
+
359
+ #### `ReviewManifestSchema`
360
+
361
+ ```ts
362
+ export const ReviewManifestSchema = …
363
+ ```
364
+
365
+ `GET /api/reports/review-collections/export`: a portable index of one revision. It lists sources and files; it contains no bytes.
366
+
236
367
  #### `HelloEventSchema`
237
368
 
238
369
  ```ts
@@ -625,6 +625,50 @@ export function projectServer(options: AtlasServerOptions, create: (options: Atl
625
625
 
626
626
  Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical.
627
627
 
628
+ ### Review collection routes
629
+
630
+ Source: `packages/server/src/review-collections.ts`
631
+
632
+ `/api/reports/review-collections`: publish an immutable, named, ordered collection of references to recorded results
633
+ (with notes and gaps), read it back with how every source stands now, add reviewers' notes and "reviewed" marks, and
634
+ export a portable manifest. A collection is curation kept beside the report under `curation/`: it is never a layer, a
635
+ check or a run, changes no status, slot or winner, and never alters what is uploaded. Reading needs a reader (the
636
+ same as the rest of the report, so nothing becomes public by sharing a link); publishing and noting need a Bearer
637
+ token with the `publish` role, recorded under that token's name. Recordings are still served only by the attachment
638
+ routes, under their own access control.
639
+
640
+ See docs/server.md#review-collections
641
+
642
+ #### `ReviewCollectionDeps`
643
+
644
+ ```ts
645
+ export type ReviewCollectionDeps = {
646
+ /** Where collections and notes are kept: the write backend's store. */
647
+ readonly store: ObjectStore;
648
+ /** Every backend's store, to tell whether a layer is still held anywhere. */
649
+ readonly stores: readonly ObjectStore[];
650
+ readonly auth: Auth;
651
+ readonly index: ReportIndex;
652
+ readonly now: () => number;
653
+ /** The largest request body, in bytes. */
654
+ readonly maxBytes: number;
655
+ readonly unauthorized: (verdict: Extract<ReturnType<Auth['check']>, { ok: false }>) => Response;
656
+ };
657
+ ```
658
+
659
+ What the review collection routes need from the server around them.
660
+
661
+ #### `mountReviewCollections`
662
+
663
+ ```ts
664
+ export function mountReviewCollections(app: Hono, deps: ReviewCollectionDeps): void
665
+ ```
666
+
667
+ Registers the review collection routes on `app`:
668
+ `GET /api/reports/review-collections`, `POST /api/reports/review-collections`,
669
+ `GET /api/reports/review-collections/detail`, `POST /api/reports/review-collections/reviews` and
670
+ `GET /api/reports/review-collections/export`.
671
+
628
672
  ### settings-page.ts
629
673
 
630
674
  Source: `packages/server/src/settings-page.ts`
@@ -183,6 +183,44 @@ curl "$ATLAS_SERVER_URL/api/reports/artifacts?artifactTag=demo&type=video&domain
183
183
  **live query**: opening it asks again and shows what matches the newest report. It is not a snapshot; an immutable
184
184
  review collection is a separate feature. *Copy link* gives the address, which holds the whole query.
185
185
 
186
+ ## Words in recordings
187
+
188
+ A reviewer who remembers a phrase that was spoken or shown in a video can search for it on the **Artifacts** tab
189
+ (**Search inside recordings**, the `inside` address parameter). Atlas never transcribes video or reads a screen: it
190
+ searches text you attach, and nothing is read from a recording until words are typed. Attach it beside the video, at the
191
+ same level (the check, or the retry attempt that recorded the video):
192
+
193
+ ```ts
194
+ attachments: [
195
+ { id: 'video', name: 'checkout.webm', contentType: 'video/webm', data: video },
196
+ // Spoken words: WebVTT, SubRip, JSON segments or plain text.
197
+ { name: 'checkout.vtt', role: 'transcript', contentType: 'text/vtt', data: transcript,
198
+ extensions: { 'atlas.text': { recording: 'video', generated: true, generator: 'my-speech-tool' } } },
199
+ // Text shown on screen, with its own timing.
200
+ { name: 'screen.json', role: 'ocr', contentType: 'application/json', data: JSON.stringify({ segments: [{ start: 2, end: 4, text: 'Order total', confidence: 0.9 }] }) }
201
+ ]
202
+ ```
203
+
204
+ - **Which attachment is text.** One with `extensions["atlas.text"]`, a `role` of `transcript`, `ocr`, `screen-text`,
205
+ `captions` or `subtitles`, or a WebVTT or SubRip type. `extensions["atlas.text"].kind` (`speech`, `screen`, `caption`)
206
+ says what it is; otherwise the role does. It belongs to the video that lists its `id` in `captions`, or that
207
+ `extensions["atlas.text"].recording` names by `id`, else to the only video at the same level.
208
+ - **Segments.** WebVTT and SubRip cues; JSON as `{ "segments": [...] }` (or `cues`, `results`, `lines`, or a bare list) of
209
+ `{ text, start, end }` in seconds or `{ text, startMs, endMs }`, with an optional `confidence` between 0 and 1. A
210
+ segment whose time is missing or invalid is still searched and is shown with no time; the viewer offers the recording
211
+ and says seeking is unavailable, and never guesses a moment.
212
+ - **Honesty about the text.** `generated: true` (with `generator`) marks text a tool produced; results show it with its
213
+ confidence and say it may contain errors. A result always shows the indexed words, never a reworded quotation. A
214
+ match finds a moment; it is not a new assertion, and the result's own outcome and the retry attempt stay beside it.
215
+ - **States are different things.** Searched with no match; not indexed (no text was supplied); unsupported (the video is
216
+ silent by its `scope.audioCaptured: false`, or the text is in a form that is not read, or sits at a `uri`); failed
217
+ (`extensions["atlas.text"].state: "failed"` with a `reason`, a file left out with `omitted`, or text that did not
218
+ parse). The ones not searched are listed apart and never counted as "no match".
219
+ - **Access.** The text is read through the same report API, and so the same access rules, that play the recording. A file
220
+ the viewer may not read is not indexed and appears in no snippet or count. The index exists only in the open page: there
221
+ is no stored or cross-project index. Sending evidence to a transcription or OCR service is the producer's explicit
222
+ decision and happens outside Atlas.
223
+
186
224
  ## Labels describe, parameters split
187
225
 
188
226
  A result's **slot** is its producer, check id, part kind, spec, environment and **parameters**. The newest completed