@anthusai/papyrus 1.0.0-next.61 → 1.0.0-next.63

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.
@@ -893,9 +893,11 @@ const schema = a.schema({
893
893
  createdAt: a.datetime().required(),
894
894
  updatedAt: a.datetime().required(),
895
895
  newsroomFeedKey: a.string(),
896
+ itemLineageId: a.id(),
896
897
  })
897
898
  .secondaryIndexes((index) => [
898
899
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
900
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
899
901
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
900
902
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
901
903
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1632,6 +1634,7 @@ const schema = a.schema({
1632
1634
  type: a.string().required(),
1633
1635
  status: a.string().required(),
1634
1636
  typeStatus: a.string().required(),
1637
+ stage: a.string(),
1635
1638
  slug: a.string().required(),
1636
1639
  shortSlug: a.string(),
1637
1640
  section: a.string(),
@@ -1663,6 +1666,7 @@ const schema = a.schema({
1663
1666
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1664
1667
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1665
1668
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1669
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1666
1670
  ])
1667
1671
  .authorization((allow) => [
1668
1672
  allow.groups(contentWriteGroups),
@@ -970,9 +970,11 @@ const schema = a.schema({
970
970
  createdAt: a.datetime().required(),
971
971
  updatedAt: a.datetime().required(),
972
972
  newsroomFeedKey: a.string(),
973
+ itemLineageId: a.id(),
973
974
  })
974
975
  .secondaryIndexes((index) => [
975
976
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
977
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
976
978
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
977
979
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
978
980
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1733,6 +1735,7 @@ const schema = a.schema({
1733
1735
  type: a.string().required(),
1734
1736
  status: a.string().required(),
1735
1737
  typeStatus: a.string().required(),
1738
+ stage: a.string(),
1736
1739
  slug: a.string().required(),
1737
1740
  shortSlug: a.string(),
1738
1741
  section: a.string(),
@@ -1764,6 +1767,7 @@ const schema = a.schema({
1764
1767
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1765
1768
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1766
1769
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1770
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1767
1771
  ])
1768
1772
  .authorization((allow) => [
1769
1773
  allow.groups(contentWriteGroups),
@@ -799,9 +799,11 @@ const baseDefinitions = {
799
799
  createdAt: a.datetime().required(),
800
800
  updatedAt: a.datetime().required(),
801
801
  newsroomFeedKey: a.string(),
802
+ itemLineageId: a.id(),
802
803
  })
803
804
  .secondaryIndexes((index) => [
804
805
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
806
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
805
807
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
806
808
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
807
809
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1511,6 +1513,7 @@ const baseDefinitions = {
1511
1513
  changeReason: a.string(),
1512
1514
  contentHash: a.string(),
1513
1515
  type: a.string().required(),
1516
+ stage: a.string(),
1514
1517
  status: a.string().required(),
1515
1518
  typeStatus: a.string().required(),
1516
1519
  slug: a.string().required(),
@@ -1544,6 +1547,7 @@ const baseDefinitions = {
1544
1547
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1545
1548
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1546
1549
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1550
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1547
1551
  ])
1548
1552
  .authorization((allow) => [
1549
1553
  allow.groups(contentWriteGroups),
@@ -870,9 +870,11 @@ const baseDefinitions = {
870
870
  createdAt: a.datetime().required(),
871
871
  updatedAt: a.datetime().required(),
872
872
  newsroomFeedKey: a.string(),
873
+ itemLineageId: a.id(),
873
874
  })
874
875
  .secondaryIndexes((index) => [
875
876
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
877
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
876
878
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
877
879
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
878
880
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1606,6 +1608,7 @@ const baseDefinitions = {
1606
1608
  changeReason: a.string(),
1607
1609
  contentHash: a.string(),
1608
1610
  type: a.string().required(),
1611
+ stage: a.string(),
1609
1612
  status: a.string().required(),
1610
1613
  typeStatus: a.string().required(),
1611
1614
  slug: a.string().required(),
@@ -1639,6 +1642,7 @@ const baseDefinitions = {
1639
1642
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1640
1643
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1641
1644
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1645
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1642
1646
  ])
1643
1647
  .authorization((allow) => [
1644
1648
  allow.groups(contentWriteGroups),
@@ -0,0 +1,11 @@
1
+ import { NextResponse } from "next/server";
2
+ import { approvedPrimingProofReport } from "../../../../lib/priming-proof-report";
3
+ import { SITE_BRAND } from "../../../../lib/site-brand";
4
+
5
+ export function GET() {
6
+ const report = approvedPrimingProofReport(SITE_BRAND.id);
7
+ if (report === null) {
8
+ return NextResponse.json({ error: "No approved priming proof report for this publication yet." }, { status: 404 });
9
+ }
10
+ return NextResponse.json(report);
11
+ }
@@ -0,0 +1,13 @@
1
+ import type { Metadata } from "next";
2
+ import { PrimingProofPage } from "../../components/priming-proof-page";
3
+ import { approvedPrimingProofReport } from "../../lib/priming-proof-report";
4
+ import { SITE_BRAND } from "../../lib/site-brand";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Cyclotron proof | ${SITE_BRAND.articleTitleSuffix}`,
8
+ description: "How often the relevance classifier agrees with editors on a fixed held-out set, before and after editor labels.",
9
+ };
10
+
11
+ export default function CyclotronProof() {
12
+ return <PrimingProofPage report={approvedPrimingProofReport(SITE_BRAND.id)} />;
13
+ }
@@ -39,6 +39,7 @@ import type {
39
39
  UserDirectoryEntry,
40
40
  } from "../lib/category-repository";
41
41
  import { relationTypeKey } from "../lib/semantic-graph";
42
+ import { CONTENT_ITEM_STAGES, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
42
43
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
43
44
  import { configureAmplifyClient } from "./amplify-client-provider";
44
45
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
@@ -253,7 +254,7 @@ const UPDATE_MESSAGE_MUTATION = `
253
254
  const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
254
255
  query ListAssignmentsByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelAssignmentFilterInput) {
255
256
  listAssignmentsByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
256
- items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey }
257
+ items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey itemLineageId }
257
258
  nextToken
258
259
  }
259
260
  }
@@ -2157,6 +2158,41 @@ export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPage
2157
2158
  });
2158
2159
  }
2159
2160
 
2161
+ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2162
+ query ListItemsByStageAndUpdatedAt($stage: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
2163
+ listItemsByStageAndUpdatedAt(stage: $stage, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
2164
+ items { id lineageId versionNumber stage type status title headline slug section updatedAt }
2165
+ nextToken
2166
+ }
2167
+ }
2168
+ `;
2169
+
2170
+ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2171
+ configureAmplifyClient();
2172
+ const client = generateClient<Schema>() as unknown as {
2173
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2174
+ };
2175
+ const field = "listItemsByStageAndUpdatedAt";
2176
+ const versionsPerStage = await Promise.all(CONTENT_ITEM_STAGES.map(async (stage) => {
2177
+ const versions: ContentItemVersionRecord[] = [];
2178
+ let nextToken: string | null = null;
2179
+ do {
2180
+ const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2181
+ query: CONTENT_ITEMS_BY_STAGE_QUERY,
2182
+ variables: { stage, sortDirection: "DESC", limit: NEWSROOM_PAGE_LIMIT, nextToken },
2183
+ authMode: USER_POOL_AUTH_MODE,
2184
+ });
2185
+ assertNoGraphQLErrors(response.errors);
2186
+ const connection = response.data?.[field];
2187
+ if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2188
+ versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
2189
+ nextToken = connection.nextToken ?? null;
2190
+ } while (nextToken);
2191
+ return versions;
2192
+ }));
2193
+ return versionsPerStage.flat();
2194
+ }
2195
+
2160
2196
  export async function loadModelPayloadsForOwner(
2161
2197
  ownerKind: string,
2162
2198
  ownerId: string,
@@ -6,10 +6,9 @@ import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
8
  import { canReadNewsroomBoard, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
9
- import { resolveArticlesBackend } from "../lib/newsroom-articles";
10
- import { SITE_BRAND } from "../lib/site-brand";
9
+ import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
11
10
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
12
- import { loadNewsroomAssignmentPage, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
11
+ import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
13
12
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
14
13
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
15
14
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -45,12 +44,12 @@ export function NewsroomBoardView() {
45
44
  if (!allowed) { setLoading(false); return; }
46
45
  setLoading(true); busy.current = true;
47
46
  Promise.all([
48
- resolveArticlesBackend(false).listRows(),
47
+ loadNewsroomContentItemVersions(),
49
48
  loadNewsroomReferencePage({ limit: 50 }),
50
49
  loadNewsroomAssignmentPage({ limit: 50 }),
51
- ]).then(([articles, references, assignments]) => {
50
+ ]).then(([contentItemVersions, references, assignments]) => {
52
51
  if (generation.current !== request) return;
53
- setIssues(projectNewsroomBoard({ articles, references: references.items, assignments: assignments.items }));
52
+ setIssues(projectNewsroomBoard({ contentItemVersions, references: references.items, assignments: assignments.items }));
54
53
  setTokens({ reference: references.nextToken, assignment: assignments.nextToken });
55
54
  }).catch((reason: unknown) => {
56
55
  if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
@@ -81,7 +80,7 @@ export function NewsroomBoardView() {
81
80
  <IssueTypeFilter config={config} value={filter} onChange={value => { setFilter(value); setSelected(null); }} name="Record type" />
82
81
  <div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button><button type="button" disabled={loading} onClick={() => setRevision(current => current + 1)}>Refresh records</button></div>
83
82
  </div>
84
- <p className="organizer-board-note">Native CMS records and statuses. Open a record to use its existing editor or workflow controls. Story linking is not configured.</p>
83
+ <p className="organizer-board-note">{`Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open a record to use its existing editor or workflow controls.`}</p>
85
84
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
86
85
  <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded. References and assignments load in pages.`}</p>
87
86
  <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
@@ -91,7 +90,7 @@ export function NewsroomBoardView() {
91
90
  selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel="Newsroom records"
92
91
  emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
93
92
  {issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
94
- <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type} workspace</Link>
93
+ <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>
95
94
  </IssueDetailPanel>}
96
95
  </div>
97
96
  <div className="organizer-board-pages">{tokens.reference && <button disabled={loading} type="button" onClick={() => void loadMore("reference")}>Load more references</button>}{tokens.assignment && <button disabled={loading} type="button" onClick={() => void loadMore("assignment")}>Load more assignments</button>}</div>
@@ -0,0 +1,94 @@
1
+ import {
2
+ formatProofPercentage,
3
+ primingProofHeadline,
4
+ type PrimingProofReport,
5
+ } from "../lib/priming-proof-report";
6
+
7
+ function ProofMethod({ heldOutSize, teachingSource }: { heldOutSize: number | null; teachingSource: string | null }) {
8
+ return (
9
+ <section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
10
+ <h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
11
+ <p>
12
+ Editors already decided which candidate sources belong in this publication. A fixed, balanced sample of those
13
+ decisions{heldOutSize === null ? "" : ` (${heldOutSize} sources)`} is frozen as the held-out set and never used
14
+ for teaching. A classifier that always gives the same answer agrees on half of it.
15
+ </p>
16
+ <p>
17
+ Editors teach the classifier with labels: their other reviews of candidate sources, and sources they paste,
18
+ check after Papyrus extracts them, and mark publish or reject.
19
+ {teachingSource === null ? "" : ` This run taught from ${teachingSource}.`} Each point below starts a fresh
20
+ classifier that sees only the first N of those labels, then decides
21
+ every held-out source once. Nothing else changes between points: the same decision model, the publication&apos;s
22
+ mission and policies as the only rubric, and no automatic rubric rewriting.
23
+ </p>
24
+ </section>
25
+ );
26
+ }
27
+
28
+ export function PrimingProofPage({ report }: { report: PrimingProofReport | null }) {
29
+ if (report === null) {
30
+ return (
31
+ <main className="mx-auto max-w-3xl space-y-6 px-4 py-10" data-priming-proof="pending">
32
+ <header className="space-y-2 border-b pb-4">
33
+ <h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
34
+ <p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
35
+ </header>
36
+ <ProofMethod heldOutSize={null} teachingSource={null} />
37
+ </main>
38
+ );
39
+ }
40
+ const { baseline, latest, agreementChangeInPoints } = primingProofHeadline(report);
41
+ const [positiveLabel, negativeLabel] = [report.positiveLabel, report.labels.find((label) => label !== report.positiveLabel)];
42
+ return (
43
+ <main className="mx-auto max-w-3xl space-y-8 px-4 py-10" data-priming-proof="published">
44
+ <header className="space-y-3 border-b pb-4">
45
+ <h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
46
+ <p className="text-lg" data-priming-proof-headline>
47
+ Agreement with editors went from <strong>{formatProofPercentage(baseline.agreement)}</strong> with no labels to{" "}
48
+ <strong>{formatProofPercentage(latest.agreement)}</strong> after {latest.labels} labels
49
+ {agreementChangeInPoints === null ? "" : ` (${agreementChangeInPoints >= 0 ? "+" : ""}${agreementChangeInPoints} points)`}.
50
+ </p>
51
+ <p className="text-sm text-muted-foreground">
52
+ {report.publication} · question: “{report.question}” · held-out set of {report.heldOut.size} sources frozen{" "}
53
+ {report.heldOut.frozenAt.slice(0, 10)} · run {report.ranAt.slice(0, 10)}
54
+ {report.decisionModel ? ` · decision model ${report.decisionModel}` : ""}
55
+ </p>
56
+ </header>
57
+ <section aria-labelledby="priming-proof-points" className="space-y-2">
58
+ <h2 id="priming-proof-points" className="text-base font-semibold uppercase tracking-wide">Before and after</h2>
59
+ <div className="overflow-x-auto">
60
+ <table className="w-full text-left text-sm tabular-nums">
61
+ <thead>
62
+ <tr className="border-b">
63
+ <th scope="col" className="py-2 pr-4">Editor labels</th>
64
+ <th scope="col" className="py-2 pr-4">Agreement</th>
65
+ <th scope="col" className="py-2 pr-4">Precision ({positiveLabel})</th>
66
+ <th scope="col" className="py-2 pr-4">Recall ({positiveLabel})</th>
67
+ <th scope="col" className="py-2">Labels used</th>
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ {report.points.map((point) => (
72
+ <tr key={point.labels} className="border-b" data-priming-proof-point={point.labels}>
73
+ <td className="py-2 pr-4">{point.labels}</td>
74
+ <td className="py-2 pr-4">{formatProofPercentage(point.agreement)}</td>
75
+ <td className="py-2 pr-4">{formatProofPercentage(point.precision)}</td>
76
+ <td className="py-2 pr-4">{formatProofPercentage(point.recall)}</td>
77
+ <td className="py-2">
78
+ {point.labelsByClass[positiveLabel] ?? 0} {positiveLabel}
79
+ {negativeLabel ? `, ${point.labelsByClass[negativeLabel] ?? 0} ${negativeLabel}` : ""}
80
+ </td>
81
+ </tr>
82
+ ))}
83
+ </tbody>
84
+ </table>
85
+ </div>
86
+ </section>
87
+ <ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} />
88
+ <p className="break-all text-xs text-muted-foreground">
89
+ Held-out fingerprint {report.heldOut.fingerprint}
90
+ {report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
91
+ </p>
92
+ </main>
93
+ );
94
+ }
@@ -15042,6 +15042,9 @@ function buildUiCopywritingAssignment({ actorLabel, assignment, decision, messag
15042
15042
  message: MessageRecord;
15043
15043
  now: string;
15044
15044
  }): AssignmentRecord {
15045
+ if (!assignment.itemLineageId) {
15046
+ throw new Error(`Assignment ${assignment.id} has no content item lineage; copywriting must continue the same content item.`);
15047
+ }
15045
15048
  const type = decision === "brief" ? "brief" : "article";
15046
15049
  const assignmentTypeKey = type === "brief" ? "copywriting.brief-draft" : "copywriting.article-draft";
15047
15050
  const section = assignment.sectionKey ?? assignment.sectionId ?? "unsectioned";
@@ -15094,6 +15097,7 @@ function buildUiCopywritingAssignment({ actorLabel, assignment, decision, messag
15094
15097
  createdAt: now,
15095
15098
  updatedAt: now,
15096
15099
  newsroomFeedKey: "assignment#open",
15100
+ itemLineageId: assignment.itemLineageId,
15097
15101
  metadata,
15098
15102
  };
15099
15103
  }
@@ -3,7 +3,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
4
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
5
5
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
6
- import { hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
6
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
7
7
  function environmentVariables(variables) {
8
8
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
9
9
  }
@@ -85,6 +85,17 @@ export class AmplifyAppShellStack extends Stack {
85
85
  ],
86
86
  }));
87
87
  }
88
+ const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
89
+ const prospectusEnvironment = prospectusAccountsApiStageArn
90
+ ? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
91
+ : {};
92
+ if (prospectusAccountsApiStageArn) {
93
+ computeRole.addToPolicy(new iam.PolicyStatement({
94
+ sid: "InvokeProspectusAccountsAsRegisteredSite",
95
+ actions: ["execute-api:Invoke"],
96
+ resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
97
+ }));
98
+ }
88
99
  const cmsApp = new amplify.CfnApp(this, "App", {
89
100
  name: cmsAppName,
90
101
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
@@ -134,6 +145,7 @@ export class AmplifyAppShellStack extends Stack {
134
145
  SITE_ENV: "production",
135
146
  PAPYRUS_CONTENT_SOURCE: "published",
136
147
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
148
+ ...prospectusEnvironment,
137
149
  }),
138
150
  });
139
151
  let stagingBranch;
@@ -153,6 +165,7 @@ export class AmplifyAppShellStack extends Stack {
153
165
  PAPYRUS_SITE_BRAND: config.brand,
154
166
  SITE_ENV: "staging",
155
167
  ...stagingMode,
168
+ ...prospectusEnvironment,
156
169
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
157
170
  ? `https://${stagingDomainName}`
158
171
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
@@ -191,6 +204,10 @@ export class AmplifyAppShellStack extends Stack {
191
204
  this.cmsAppId = cmsApp.attrAppId;
192
205
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
193
206
  new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
207
+ new CfnOutput(this, "CmsComputeRoleArn", {
208
+ value: computeRole.roleArn,
209
+ description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
210
+ });
194
211
  if (stagingEnabled) {
195
212
  new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
196
213
  }
@@ -4,7 +4,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
7
- import { AmplifyAppShellSiteConfig, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
8
8
 
9
9
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
10
10
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
@@ -96,6 +96,20 @@ export class AmplifyAppShellStack extends Stack {
96
96
  );
97
97
  }
98
98
 
99
+ const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
100
+ const prospectusEnvironment = prospectusAccountsApiStageArn
101
+ ? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
102
+ : {};
103
+ if (prospectusAccountsApiStageArn) {
104
+ computeRole.addToPolicy(
105
+ new iam.PolicyStatement({
106
+ sid: "InvokeProspectusAccountsAsRegisteredSite",
107
+ actions: ["execute-api:Invoke"],
108
+ resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
109
+ }),
110
+ );
111
+ }
112
+
99
113
  const cmsApp = new amplify.CfnApp(this, "App", {
100
114
  name: cmsAppName,
101
115
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
@@ -148,6 +162,7 @@ export class AmplifyAppShellStack extends Stack {
148
162
  SITE_ENV: "production",
149
163
  PAPYRUS_CONTENT_SOURCE: "published",
150
164
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
165
+ ...prospectusEnvironment,
151
166
  }),
152
167
  });
153
168
 
@@ -168,6 +183,7 @@ export class AmplifyAppShellStack extends Stack {
168
183
  PAPYRUS_SITE_BRAND: config.brand,
169
184
  SITE_ENV: "staging",
170
185
  ...stagingMode,
186
+ ...prospectusEnvironment,
171
187
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
172
188
  ? `https://${stagingDomainName}`
173
189
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
@@ -209,6 +225,10 @@ export class AmplifyAppShellStack extends Stack {
209
225
  this.cmsAppId = cmsApp.attrAppId;
210
226
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
211
227
  new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
228
+ new CfnOutput(this, "CmsComputeRoleArn", {
229
+ value: computeRole.roleArn,
230
+ description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
231
+ });
212
232
  if (stagingEnabled) {
213
233
  new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
214
234
  }
@@ -16,7 +16,24 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
16
16
  const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
17
17
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
18
18
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
19
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
19
+ const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
20
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT"];
21
+ /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
22
+ export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
23
+ "GET/v1/identities/me",
24
+ "POST/v1/identities/google/challenge",
25
+ "POST/v1/identities/google/resolve",
26
+ "GET/v1/accounts",
27
+ "GET/v1/accounts/*",
28
+ "POST/v1/accounts/*/authorize",
29
+ "GET/v1/account-directory",
30
+ "GET/v1/account-directory/*",
31
+ ];
32
+ export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) {
33
+ const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
34
+ const [apiId, stageName] = apiPath.split("/");
35
+ return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
36
+ }
20
37
  function originOfRedirectUrl(value) {
21
38
  try {
22
39
  return new URL(value).origin;
@@ -181,7 +198,7 @@ function parseStagingPreview(value) {
181
198
  }
182
199
  function parseCms(value) {
183
200
  const cmsRecord = requireObject(value, "cms");
184
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
201
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
185
202
  const buildComputeType = cmsRecord.buildComputeType;
186
203
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
187
204
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -205,6 +222,7 @@ function parseCms(value) {
205
222
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
206
223
  redirects: parseRedirects(cmsRecord.redirects),
207
224
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
225
+ prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
208
226
  };
209
227
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
210
228
  if (reservedWord) {
@@ -212,7 +230,7 @@ function parseCms(value) {
212
230
  }
213
231
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
214
232
  if (key in cms.environment) {
215
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
233
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
216
234
  }
217
235
  }
218
236
  if (cms.staging === false && cms.stagingDomainName) {
@@ -41,6 +41,7 @@ export type CmsConfig = {
41
41
  stagingDomainName?: string;
42
42
  stagingDomainPrefix?: string;
43
43
  redirects?: CmsRedirect[];
44
+ prospectusAccountsApiStageArn?: string;
44
45
  };
45
46
 
46
47
  export type AmplifyAppShellSiteConfig = {
@@ -95,7 +96,26 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
95
96
  const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
96
97
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
97
98
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
98
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
99
+ const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
100
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT"];
101
+
102
+ /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
103
+ export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
104
+ "GET/v1/identities/me",
105
+ "POST/v1/identities/google/challenge",
106
+ "POST/v1/identities/google/resolve",
107
+ "GET/v1/accounts",
108
+ "GET/v1/accounts/*",
109
+ "POST/v1/accounts/*/authorize",
110
+ "GET/v1/account-directory",
111
+ "GET/v1/account-directory/*",
112
+ ];
113
+
114
+ export function resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn: string): string {
115
+ const [, , , region, , apiPath] = prospectusAccountsApiStageArn.split(":");
116
+ const [apiId, stageName] = apiPath.split("/");
117
+ return `https://${apiId}.execute-api.${region}.amazonaws.com/${stageName}/`;
118
+ }
99
119
  function originOfRedirectUrl(value: string): string {
100
120
  try {
101
121
  return new URL(value).origin;
@@ -259,7 +279,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
259
279
 
260
280
  function parseCms(value: unknown): CmsConfig {
261
281
  const cmsRecord = requireObject(value, "cms");
262
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
282
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
263
283
  const buildComputeType = cmsRecord.buildComputeType;
264
284
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
265
285
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -288,6 +308,12 @@ function parseCms(value: unknown): CmsConfig {
288
308
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
289
309
  redirects: parseRedirects(cmsRecord.redirects),
290
310
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
311
+ prospectusAccountsApiStageArn: optionalString(
312
+ cmsRecord.prospectusAccountsApiStageArn,
313
+ "cms.prospectusAccountsApiStageArn",
314
+ PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN,
315
+ "arn:aws:execute-api:<region>:<account>:<api id>/api",
316
+ ),
291
317
  };
292
318
 
293
319
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
@@ -296,7 +322,7 @@ function parseCms(value: unknown): CmsConfig {
296
322
  }
297
323
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
298
324
  if (key in cms.environment) {
299
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
325
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
300
326
  }
301
327
  }
302
328
 
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Priming proof reports a publication owner approved for visitors, keyed by site brand id.
3
+ * A report reaches this map only through a reviewed commit of its
4
+ * `publications/priming-proofs/<brand id>.json` file; see docs/priming-proof.md.
5
+ */
6
+ export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {};
@@ -494,6 +494,7 @@ export type AssignmentRecord = {
494
494
  createdAt: string;
495
495
  updatedAt: string;
496
496
  newsroomFeedKey?: string | null;
497
+ itemLineageId?: string | null;
497
498
  metadata?: unknown;
498
499
  };
499
500