@anthusai/papyrus 1.16.0 → 1.18.0-next.1

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 (34) hide show
  1. package/amplify/data/resource.js +13 -0
  2. package/amplify/data/resource.ts +14 -0
  3. package/amplify/data/schema.js +13 -0
  4. package/amplify/data/schema.ts +14 -0
  5. package/amplify/functions/assignment-action/handler.ts +24 -163
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/shared/editorial-context.ts +160 -0
  8. package/amplify/functions/shared/model-payloads.ts +9 -0
  9. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  10. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  11. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  12. package/app/[year]/[month]/[day]/page.tsx +3 -0
  13. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  14. package/app/api/archive/editions/route.ts +2 -0
  15. package/app/archive/page.tsx +3 -0
  16. package/app/page.tsx +30 -1
  17. package/bin/papyrus-infra.mjs +42 -4
  18. package/components/item-stream-view.tsx +63 -0
  19. package/components/newsroom-assignments-view.tsx +1 -1
  20. package/components/topic-steering-workspace.tsx +2 -2
  21. package/infra/build-specs.js +3 -0
  22. package/infra/build-specs.ts +4 -0
  23. package/infra/site-config.js +5 -1
  24. package/infra/site-config.ts +6 -1
  25. package/lib/category-repository.ts +1 -1
  26. package/lib/item-stream.ts +47 -0
  27. package/lib/newsroom-assignments.ts +1 -1
  28. package/lib/newsroom-demo-dashboard.ts +1 -1
  29. package/lib/reader-static-params.ts +2 -1
  30. package/lib/reporting-story-budget.ts +2 -2
  31. package/lib/site-brand.ts +11 -0
  32. package/lib/site-env.ts +5 -0
  33. package/package.json +3 -1
  34. package/routes.manifest.json +1 -1
@@ -808,6 +808,7 @@ const schema = a.schema({
808
808
  categoryLineageId: a.id(),
809
809
  }),
810
810
  AssignmentContext: a.customType({
811
+ editorialContext: a.json().required(),
811
812
  assignment: a.ref("AssignmentSummary"),
812
813
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
813
814
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -834,6 +835,18 @@ const schema = a.schema({
834
835
  .returns(a.ref("AssignmentContext").array().required())
835
836
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
836
837
  .handler(a.handler.function(assignmentAction)),
838
+ getEditorialContext: a
839
+ .query()
840
+ .arguments({
841
+ publicationKey: a.string().required(),
842
+ sectionId: a.id(),
843
+ storyLineageId: a.id(),
844
+ assignmentId: a.id(),
845
+ maxCharacters: a.integer(),
846
+ })
847
+ .returns(a.json().required())
848
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
849
+ .handler(a.handler.function(assignmentAction)),
837
850
  getAssignmentContext: a
838
851
  .query()
839
852
  .arguments({
@@ -878,6 +878,7 @@ const schema = a.schema({
878
878
  }),
879
879
 
880
880
  AssignmentContext: a.customType({
881
+ editorialContext: a.json().required(),
881
882
  assignment: a.ref("AssignmentSummary"),
882
883
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
883
884
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -907,6 +908,19 @@ const schema = a.schema({
907
908
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
908
909
  .handler(a.handler.function(assignmentAction)),
909
910
 
911
+ getEditorialContext: a
912
+ .query()
913
+ .arguments({
914
+ publicationKey: a.string().required(),
915
+ sectionId: a.id(),
916
+ storyLineageId: a.id(),
917
+ assignmentId: a.id(),
918
+ maxCharacters: a.integer(),
919
+ })
920
+ .returns(a.json().required())
921
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
922
+ .handler(a.handler.function(assignmentAction)),
923
+
910
924
  getAssignmentContext: a
911
925
  .query()
912
926
  .arguments({
@@ -715,6 +715,7 @@ const baseDefinitions = {
715
715
  categoryLineageId: a.id(),
716
716
  }),
717
717
  AssignmentContext: a.customType({
718
+ editorialContext: a.json().required(),
718
719
  assignment: a.ref("AssignmentSummary"),
719
720
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
720
721
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -741,6 +742,18 @@ const baseDefinitions = {
741
742
  .returns(a.ref("AssignmentContext").array().required())
742
743
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
743
744
  .handler(a.handler.function(assignmentAction)),
745
+ getEditorialContext: a
746
+ .query()
747
+ .arguments({
748
+ publicationKey: a.string().required(),
749
+ sectionId: a.id(),
750
+ storyLineageId: a.id(),
751
+ assignmentId: a.id(),
752
+ maxCharacters: a.integer(),
753
+ })
754
+ .returns(a.json().required())
755
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
756
+ .handler(a.handler.function(assignmentAction)),
744
757
  getAssignmentContext: a
745
758
  .query()
746
759
  .arguments({
@@ -779,6 +779,7 @@ const baseDefinitions = {
779
779
  }),
780
780
 
781
781
  AssignmentContext: a.customType({
782
+ editorialContext: a.json().required(),
782
783
  assignment: a.ref("AssignmentSummary"),
783
784
  doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
784
785
  targets: a.ref("AssignmentTargetSummary").array().required(),
@@ -808,6 +809,19 @@ const baseDefinitions = {
808
809
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
809
810
  .handler(a.handler.function(assignmentAction)),
810
811
 
812
+ getEditorialContext: a
813
+ .query()
814
+ .arguments({
815
+ publicationKey: a.string().required(),
816
+ sectionId: a.id(),
817
+ storyLineageId: a.id(),
818
+ assignmentId: a.id(),
819
+ maxCharacters: a.integer(),
820
+ })
821
+ .returns(a.json().required())
822
+ .authorization((allow) => [allow.groups(categoryWriteGroups)])
823
+ .handler(a.handler.function(assignmentAction)),
824
+
811
825
  getAssignmentContext: a
812
826
  .query()
813
827
  .arguments({
@@ -1,3 +1,5 @@
1
+ import { assignmentDoctrine, resolveEditorialContext, type EditorialClient } from "../shared/editorial-context";
2
+ import { tenantIdFromEnvironment } from "../../../lib/tenant-keys";
1
3
  import { randomUUID } from "node:crypto";
2
4
  import { spawnSync } from "node:child_process";
3
5
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -7,7 +9,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
7
9
  import { Amplify } from "aws-amplify";
8
10
  import { generateClient } from "aws-amplify/data";
9
11
  import type { Schema } from "../../data/resource";
10
- import { putJsonModelPayload, readJsonModelPayload } from "../shared/model-payloads";
12
+ import { putJsonModelPayload, readJsonModelPayload, readEditorialAttachment } from "../shared/model-payloads";
11
13
 
12
14
  type AssignmentHandler =
13
15
  | Schema["claimAssignment"]["functionHandler"]
@@ -17,6 +19,7 @@ type AssignmentHandler =
17
19
  | Schema["reopenAssignment"]["functionHandler"]
18
20
  | Schema["retryImmediateAssignment"]["functionHandler"]
19
21
  | Schema["getAssignmentContext"]["functionHandler"]
22
+ | Schema["getEditorialContext"]["functionHandler"]
20
23
  | Schema["listAssignmentQueue"]["functionHandler"]
21
24
  | Schema["listAssignmentsForObject"]["functionHandler"];
22
25
  type DataClient = ReturnType<typeof generateClient<Schema>>;
@@ -32,14 +35,12 @@ const IMMEDIATE_ASSIGNMENT_TYPES = new Set(["procedure.run"]);
32
35
  const NEWSROOM_SUMMARY_PAYLOAD_ID = "knowledge-raw-payload-newsroom-summary-current";
33
36
  const NEWSROOM_SUMMARY_PAYLOAD_OWNER_KIND = "knowledgeRawPayload";
34
37
  const SUMMARY_STALE_AFTER_MS = 15 * 60 * 1000;
35
- const PUBLICATION_DOCTRINE_DEFINITIONS = [
36
- { scope: "publication", kind: "mission", label: "Editorial Mission", slug: "editorial-doctrine-mission" },
37
- { scope: "publication", kind: "policy", label: "Editorial Policy", slug: "editorial-doctrine-policy" },
38
- ] as const;
39
38
  let clientPromise: Promise<DataClient> | null = null;
39
+ let editorialEndpoint: string | undefined;
40
40
 
41
41
  export const handler = async (event: any): Promise<any> => {
42
42
  const fieldName = normalizeRequiredString(event.info?.fieldName ?? event.fieldName, "fieldName");
43
+ if (fieldName === "getEditorialContext") return getEditorialContext(event as Parameters<Schema["getEditorialContext"]["functionHandler"]>[0]);
43
44
  if (fieldName === "getAssignmentContext") return getAssignmentContext(event as Parameters<Schema["getAssignmentContext"]["functionHandler"]>[0]);
44
45
  if (fieldName === "listAssignmentQueue") return listAssignmentQueue(event as Parameters<Schema["listAssignmentQueue"]["functionHandler"]>[0]);
45
46
  if (fieldName === "listAssignmentsForObject") return listAssignmentsForObject(event as Parameters<Schema["listAssignmentsForObject"]["functionHandler"]>[0]);
@@ -315,6 +316,20 @@ async function listAssignmentsForObject(event: Parameters<Schema["listAssignment
315
316
  return contexts;
316
317
  }
317
318
 
319
+ async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null }) {
320
+ if (!editorialEndpoint) throw new Error("Editorial context requires the configured backend endpoint.");
321
+ return resolveEditorialContext(client as unknown as EditorialClient, {
322
+ ...scope, endpoint: editorialEndpoint, tenantId: tenantIdFromEnvironment(), readAttachment: readEditorialAttachment,
323
+ });
324
+ }
325
+
326
+ async function getEditorialContext(event: Parameters<Schema["getEditorialContext"]["functionHandler"]>[0]) {
327
+ const client = await getDataClient();
328
+ return JSON.stringify(await editorialContext(client, {
329
+ ...event.arguments, publicationKey: normalizeRequiredString(event.arguments.publicationKey, "publicationKey"),
330
+ }));
331
+ }
332
+
318
333
  async function buildAssignmentContext(client: DataClient, assignment: any) {
319
334
  const [relations, events] = await Promise.all([
320
335
  listRelationPages(client, "listSemanticRelationsBySubjectState", { subjectStateKey: semanticStateKey("assignment", assignment.id) }),
@@ -329,172 +344,17 @@ async function buildAssignmentContext(client: DataClient, assignment: any) {
329
344
  label: targetLabelFromRelation(relation),
330
345
  detail: relation.predicate,
331
346
  }));
332
- const rootDeskCategory = await resolveRootDeskCategory(client, assignment, relations);
333
- const doctrine = await buildAssignmentDoctrineContext(client, rootDeskCategory);
347
+ const pack = await editorialContext(client, { assignmentId: assignment.id });
348
+ const doctrine = assignmentDoctrine(pack);
334
349
  return {
335
350
  assignment,
351
+ editorialContext: JSON.stringify(pack),
336
352
  doctrine,
337
353
  targets,
338
354
  events,
339
355
  };
340
356
  }
341
357
 
342
- async function buildAssignmentDoctrineContext(client: DataClient, rootDeskCategory: any | null): Promise<any[]> {
343
- const definitions = [
344
- ...PUBLICATION_DOCTRINE_DEFINITIONS,
345
- ...(rootDeskCategory ? [
346
- {
347
- scope: "desk",
348
- kind: "mission",
349
- label: "Desk Mission",
350
- slug: deskDoctrineSlug(rootDeskCategory.categoryKey, "mission"),
351
- categoryKey: rootDeskCategory.categoryKey,
352
- categoryLineageId: rootDeskCategory.lineageId ?? rootDeskCategory.id,
353
- },
354
- {
355
- scope: "desk",
356
- kind: "policy",
357
- label: "Desk Policies",
358
- slug: deskDoctrineSlug(rootDeskCategory.categoryKey, "policy"),
359
- categoryKey: rootDeskCategory.categoryKey,
360
- categoryLineageId: rootDeskCategory.lineageId ?? rootDeskCategory.id,
361
- },
362
- ] : []),
363
- ];
364
-
365
- const context = [];
366
- for (const definition of definitions) {
367
- const record = await getDoctrineRecordBySlug(client, definition.slug);
368
- if (!record) continue;
369
- context.push({
370
- scope: definition.scope,
371
- kind: definition.kind,
372
- label: definition.label,
373
- slug: definition.slug,
374
- body: compactStringArray(record.body),
375
- categoryKey: "categoryKey" in definition ? definition.categoryKey : null,
376
- categoryLineageId: "categoryLineageId" in definition ? definition.categoryLineageId : null,
377
- });
378
- }
379
- return context;
380
- }
381
-
382
- async function resolveRootDeskCategory(client: DataClient, assignment: any, relations: any[]): Promise<any | null> {
383
- const categoryLineageId = assignmentCategoryLineageIdFromRelations(relations);
384
- if (categoryLineageId) {
385
- const category = await getCurrentCategoryByLineage(client, categoryLineageId);
386
- return category ? resolveRootCategory(client, category) : null;
387
- }
388
-
389
- const categoryKey = normalizeOptionalString(assignment.primaryFocusCategoryKey);
390
- if (categoryKey && assignment.categorySetId) {
391
- const category = await getCurrentCategoryBySetAndKey(client, assignment.categorySetId, categoryKey);
392
- return category ? resolveRootCategory(client, category) : null;
393
- }
394
-
395
- return null;
396
- }
397
-
398
- function assignmentCategoryLineageIdFromRelations(relations: any[]): string | null {
399
- const direct = relations.find((relation) => (
400
- relation.relationState === "current"
401
- && relation.objectKind === "category"
402
- && relation.predicate === "requests_work_on"
403
- )) ?? relations.find((relation) => (
404
- relation.relationState === "current"
405
- && relation.objectKind === "category"
406
- ));
407
- return normalizeOptionalString(direct?.objectLineageId);
408
- }
409
-
410
- async function resolveRootCategory(client: DataClient, category: any): Promise<any | null> {
411
- let current = category;
412
- for (let depth = 0; depth < 10; depth += 1) {
413
- const parentCategoryKey = normalizeOptionalString(current?.parentCategoryKey);
414
- if (!parentCategoryKey) return current;
415
- const parent = await getCurrentCategoryBySetAndKey(client, current.categorySetId, parentCategoryKey);
416
- if (!parent) return current;
417
- current = parent;
418
- }
419
- return current;
420
- }
421
-
422
- async function getCurrentCategoryByLineage(client: DataClient, lineageId: string): Promise<any | null> {
423
- const model = client.models.Category as any;
424
- const query = model.listCategoriesByLineageAndVersion;
425
- if (typeof query !== "function") return null;
426
- const categories: any[] = [];
427
- let nextToken: string | null | undefined;
428
- do {
429
- const response: DataClientResult<any[]> = await query({ lineageId }, { limit: 100, nextToken });
430
- assertNoDataErrors(response.errors, "list Category lineage");
431
- categories.push(...(response.data ?? []));
432
- nextToken = response.nextToken;
433
- } while (nextToken);
434
- return selectCurrentVersion(categories);
435
- }
436
-
437
- async function getCurrentCategoryBySetAndKey(client: DataClient, categorySetId: string, categoryKey: string): Promise<any | null> {
438
- const model = client.models.Category as any;
439
- const query = model.listCategoriesBySetAndKey;
440
- if (typeof query !== "function") return null;
441
- const categories: any[] = [];
442
- let nextToken: string | null | undefined;
443
- do {
444
- const response: DataClientResult<any[]> = await query({ categorySetId, categoryKey }, { limit: 100, nextToken });
445
- assertNoDataErrors(response.errors, "list Category set/key");
446
- categories.push(...(response.data ?? []));
447
- nextToken = response.nextToken;
448
- } while (nextToken);
449
- return selectCurrentVersion(categories);
450
- }
451
-
452
- async function getDoctrineRecordBySlug(client: DataClient, slug: string): Promise<any | null> {
453
- const model = client.models.Item as any;
454
- const query = model.itemBySlug;
455
- if (typeof query !== "function") return null;
456
- const records: any[] = [];
457
- let nextToken: string | null | undefined;
458
- do {
459
- const response: DataClientResult<any[]> = await query({ slug }, { limit: 100, nextToken });
460
- assertNoDataErrors(response.errors, "query Item by slug");
461
- records.push(...(response.data ?? []));
462
- nextToken = response.nextToken;
463
- } while (nextToken);
464
- return records.find((record) => record.type === "doctrine" && record.status === "private")
465
- ?? records.find((record) => record.type === "doctrine")
466
- ?? null;
467
- }
468
-
469
- function selectCurrentVersion(records: any[]): any | null {
470
- return [...records]
471
- .filter((record) => record && record.status !== "deprecated")
472
- .sort((left, right) => {
473
- const stateDiff = versionStateRank(left.versionState) - versionStateRank(right.versionState);
474
- if (stateDiff !== 0) return stateDiff;
475
- return Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0);
476
- })[0] ?? null;
477
- }
478
-
479
- function versionStateRank(versionState: unknown): number {
480
- if (versionState === "current") return 0;
481
- if (versionState === "draft") return 1;
482
- if (versionState === "superseded") return 8;
483
- return 5;
484
- }
485
-
486
- function deskDoctrineSlug(categoryKey: string, kind: "mission" | "policy"): string {
487
- return `desk-doctrine-${safeDoctrineKey(categoryKey)}-${kind}`;
488
- }
489
-
490
- function safeDoctrineKey(value: string): string {
491
- return value
492
- .trim()
493
- .toLowerCase()
494
- .replace(/[^a-z0-9]+/g, "-")
495
- .replace(/^-+|-+$/g, "")
496
- || "topic";
497
- }
498
358
 
499
359
  function compactStringArray(value: unknown): string[] {
500
360
  if (!Array.isArray(value)) return [];
@@ -1159,6 +1019,7 @@ async function getDataClient(): Promise<DataClient> {
1159
1019
  clientPromise = (async () => {
1160
1020
  const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(process.env as never);
1161
1021
  Amplify.configure(resourceConfig, libraryOptions);
1022
+ editorialEndpoint = resourceConfig.API?.GraphQL?.endpoint;
1162
1023
  return generateClient<Schema>();
1163
1024
  })();
1164
1025
  }
@@ -2190,7 +2190,7 @@ fn build_openai_messages(
2190
2190
  ) -> Vec<Value> {
2191
2191
  let mut messages = vec![json!({
2192
2192
  "role": "system",
2193
- "content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable note, recommend creating a note Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.edition-candidate, research.tavily-deep, and other research.* types), then Assignment.get{ id = \"<that id>\" } if the user wants detail—do not use Reference.list and do not list all assignment types (curation.reference-intake is not a research assignment). To create research work, use Assignment.create{ type = \"research\", title = \"...\", apply = true }."
2193
+ "content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable note, recommend creating a note Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.candidate, research.tavily-deep, and other research.* types), then Assignment.get{ id = \"<that id>\" } if the user wants detail—do not use Reference.list and do not list all assignment types (curation.reference-intake is not a research assignment). To create research work, use Assignment.create{ type = \"research\", title = \"...\", apply = true }."
2194
2194
  })];
2195
2195
  if !static_prompt.publication_mission.trim().is_empty() {
2196
2196
  messages.push(json!({
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto";
2
+ import { tenantScopedValue } from "../../../lib/tenant-keys";
3
+
4
+ export type EditorialRecord = Record<string, any>;
5
+ type Result = { data?: EditorialRecord | EditorialRecord[] | null; errors?: unknown; nextToken?: string | null };
6
+ type Model = Record<string, (args: Record<string, any>, options?: Record<string, any>) => Promise<Result>>;
7
+ export type EditorialClient = { models: Record<string, Model> };
8
+ export type EditorialOptions = {
9
+ publicationKey?: string;
10
+ endpoint: string;
11
+ tenantId?: string;
12
+ sectionId?: string | null;
13
+ storyLineageId?: string | null;
14
+ assignmentId?: string | null;
15
+ maxCharacters?: number | null;
16
+ readAttachment: (attachment: EditorialRecord) => Promise<string>;
17
+ };
18
+
19
+ function checked(response: Result, operation: string) {
20
+ if (response.errors && (!Array.isArray(response.errors) || response.errors.length)) throw new Error(`${operation}: ${JSON.stringify(response.errors)}`);
21
+ return response;
22
+ }
23
+
24
+ async function pages(client: EditorialClient, model: string, index: string, args: EditorialRecord) {
25
+ const rows: EditorialRecord[] = [];
26
+ let nextToken: string | null | undefined;
27
+ do {
28
+ const response = checked(await client.models[model][index](args, { limit: 100, nextToken }), `read ${model} ${index}`);
29
+ rows.push(...(response.data as EditorialRecord[] ?? []));
30
+ nextToken = response.nextToken;
31
+ } while (nextToken);
32
+ return rows;
33
+ }
34
+
35
+ async function get(client: EditorialClient, model: string, id: string) {
36
+ const row = checked(await client.models[model].get({ id }), `read ${model}`).data as EditorialRecord | null;
37
+ if (!row) throw new Error(`${model} not found: ${id}`);
38
+ return row;
39
+ }
40
+
41
+ function tenant(row: EditorialRecord, tenantId?: string) {
42
+ if ((row.tenantId || null) !== (tenantId || null)) throw new Error("Editorial record tenant does not match the configured authoring scope.");
43
+ }
44
+
45
+ function canonical(value: any): any {
46
+ if (Array.isArray(value)) return value.map(canonical);
47
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
48
+ return value;
49
+ }
50
+ function digest(value: string) { return createHash("sha256").update(value, "utf8").digest("hex"); }
51
+ function entry(scope: string, kind: string, text: string, record: EditorialRecord) {
52
+ return { scope, kind, text, source: {
53
+ ...Object.fromEntries(["id", "lineageId", "versionNumber", "updatedAt", "contentHash"].map(key => [key, record[key] ?? null])),
54
+ sha256: digest(text),
55
+ } };
56
+ }
57
+
58
+ export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
59
+ const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
60
+ "Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
61
+ "Private operating guidance; not evidence or reader-facing copy."];
62
+ for (const value of entries) lines.push("", `## ${value.scope}: ${value.kind}`, value.text);
63
+ const text = lines.join("\n") + "\n";
64
+ const totalCharacters = Array.from(text).length;
65
+ if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
66
+ if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
67
+ const payload = { schemaVersion: 1, rendererVersion: "editorial-context/v1", publication, scope, sourceMode: "cloud",
68
+ precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters };
69
+ return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
70
+ }
71
+
72
+ /** Private source adapter for the same deterministic contract exposed by papyrus editorial context. */
73
+ export async function resolveEditorialContext(client: EditorialClient, options: EditorialOptions) {
74
+ if (!options.endpoint) throw new Error("Explicit publication endpoint is required for hosted editorial context.");
75
+ const corpora = await pages(client, "KnowledgeCorpus", "listKnowledgeCorporaByRoleAndName", { role: "canonical" });
76
+ const requested = options.publicationKey;
77
+ const normalized = requested?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 140);
78
+ if (corpora.length !== 1 || !corpora[0].id?.startsWith("knowledge-corpus-") || corpora[0].role !== "canonical" || !corpora[0].name?.trim() ||
79
+ (requested !== undefined && (!normalized || corpora[0].id !== `knowledge-corpus-${normalized}`))) {
80
+ throw new Error("Requested publication does not match the hosted canonical corpus; missing or ambiguous publication binding.");
81
+ }
82
+ const publication = { key: corpora[0].id.slice("knowledge-corpus-".length), name: corpora[0].name,
83
+ corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
84
+ const entries: EditorialRecord[] = [];
85
+ const warnings: string[] = [];
86
+ let sectionId = options.sectionId || null;
87
+ let storyLineageId = options.storyLineageId || null;
88
+ const assignmentId = options.assignmentId || null;
89
+ let assignment: EditorialRecord | undefined;
90
+ if (assignmentId) {
91
+ assignment = await get(client, "Assignment", assignmentId);
92
+ const assignedStory = assignment.itemLineageId;
93
+ if (storyLineageId && assignedStory && storyLineageId !== assignedStory) throw new Error("Requested story conflicts with assignment story lineage.");
94
+ storyLineageId ||= assignedStory || null;
95
+ const assignedSection = assignment.sectionKey || assignment.sectionId;
96
+ if (sectionId && assignedSection && sectionId !== assignedSection) throw new Error("Requested section conflicts with assignment section.");
97
+ sectionId ||= assignedSection || null;
98
+ warnings.push("Assignment policy snapshots are ignored; current publication and section records govern. Surface any conflicting assignment instructions.");
99
+ }
100
+ if (storyLineageId) {
101
+ const versions = await pages(client, "Item", "listItemsByLineageAndVersion", { lineageId: storyLineageId });
102
+ if (!versions.length) throw new Error(`Story not found: ${storyLineageId}`);
103
+ const story = versions.reduce((a, b) => Number(b.versionNumber || 0) > Number(a.versionNumber || 0) ? b : a);
104
+ tenant(story, options.tenantId);
105
+ if (sectionId && story.section && sectionId !== story.section) throw new Error("Requested section conflicts with story section.");
106
+ sectionId ||= story.section || null;
107
+ }
108
+ for (const kind of ["mission", "policy"]) {
109
+ const slug = `editorial-doctrine-${kind}`;
110
+ const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
111
+ options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
112
+ const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
113
+ if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
114
+ const record = current[0];
115
+ tenant(record, options.tenantId);
116
+ const text = record.bodyMarkus ? String(record.bodyMarkus).trim() : (record.body ?? []).map((part: any) => String(part).trim()).filter(Boolean).join("\n\n");
117
+ if (!text) throw new Error(`Required publication ${kind} is empty.`);
118
+ entries.push(entry("publication", kind, text, record));
119
+ }
120
+ if (sectionId) {
121
+ const section = await get(client, "NewsroomSection", sectionId);
122
+ tenant(section, options.tenantId);
123
+ for (const [kind, key] of [["mission", "editorialMission"], ["policy", "editorialPolicy"], ["guidance", "assignmentGuidance"], ["kill criteria", "killCriteria"]]) {
124
+ const text = String(section[key] || "").trim();
125
+ if (text) entries.push(entry("section", kind, text, section));
126
+ else if (kind === "mission" || kind === "policy") warnings.push(`Section ${section.id} intentionally has an empty ${kind} slot; publication doctrine still applies.`);
127
+ }
128
+ }
129
+ if (assignment) {
130
+ if (options.tenantId && !storyLineageId && !sectionId) throw new Error("A tenant assignment needs a scoped story or section to establish its publication.");
131
+ const attachments = await pages(client, "ModelAttachment", "listModelAttachmentsByOwnerRoleAndSortKey", { ownerId: assignment.id });
132
+ let foundBrief = false;
133
+ for (const [role, kind] of [["assignment_brief", "brief"], ["assignment_instructions", "instructions"]]) {
134
+ const matching = attachments.filter(row => row.role === role && row.status !== "deleted");
135
+ if (matching.length > 1) throw new Error(`Ambiguous assignment ${kind} attachments.`);
136
+ if (matching.length) {
137
+ const attachment = matching[0];
138
+ if (attachment.ownerKind !== "assignment" || attachment.ownerId !== assignment.id) throw new Error("Assignment attachment owner does not match the requested scope.");
139
+ const text = (await options.readAttachment(attachment)).trim();
140
+ if (!text) throw new Error(`Assignment ${kind} attachment is empty.`);
141
+ entries.push(entry("assignment", kind, text, attachment));
142
+ foundBrief ||= kind === "brief";
143
+ }
144
+ }
145
+ if (!foundBrief && assignment.summary) {
146
+ entries.push(entry("assignment", "brief", assignment.summary, assignment));
147
+ warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
148
+ }
149
+ }
150
+ return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
151
+ }
152
+
153
+ export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {
154
+ return pack.entries.filter(value => value.scope !== "assignment").map(value => ({
155
+ scope: value.scope === "section" ? "desk" : "publication", kind: value.kind,
156
+ label: `${value.scope === "section" ? "Section" : "Editorial"} ${value.kind}`,
157
+ slug: value.scope === "section" ? `desk-section-${pack.scope.sectionId}-${value.kind.replaceAll(" ", "-")}` : `editorial-doctrine-${value.kind}`,
158
+ body: [value.text], ...(value.scope === "section" ? { categoryKey: pack.scope.sectionId, categoryLineageId: pack.scope.sectionId } : {}),
159
+ }));
160
+ }
@@ -135,6 +135,15 @@ async function readTextFromStorage(storagePath: string): Promise<string> {
135
135
  }
136
136
  }
137
137
 
138
+ /** Strict read for required private editorial payloads; storage failures propagate. */
139
+ export async function readEditorialAttachment(attachment: { storagePath?: string | null }): Promise<string> {
140
+ if (!attachment.storagePath) throw new Error("Editorial attachment has no storage path.");
141
+ const result = await s3.send(new GetObjectCommand({ Bucket: payloadBucketName(), Key: attachment.storagePath }));
142
+ const text = await result.Body?.transformToString("utf8");
143
+ if (!text?.trim()) throw new Error("Editorial attachment is empty.");
144
+ return text;
145
+ }
146
+
138
147
  function isMissingStorageKeyError(error: unknown): boolean {
139
148
  if (!error || typeof error !== "object") return false;
140
149
  const record = error as Record<string, unknown>;
@@ -3,7 +3,7 @@ import { getSiteRenderer } from "../../../../../lib/site-renderer";
3
3
  import { getCachedEditionItem, loadCachedEditionContent } from "../../../../../lib/cached-content-repository";
4
4
  import { getEditionDatePath, parseEditionArticleRoute } from "../../../../../lib/edition-routes";
5
5
  import { buildPresentationFooterEntries } from "../../../../../lib/presentation-footer";
6
- import { SITE_BRAND } from "../../../../../lib/site-brand";
6
+ import { SITE_BRAND, editionsEnabled } from "../../../../../lib/site-brand";
7
7
  import { generateEditionItemStaticParams } from "../../../../../lib/reader-static-params";
8
8
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
9
9
  export const revalidate = 60;
@@ -39,6 +39,7 @@ export async function generateMetadata({ params }: DateScopedArticlePageProps) {
39
39
  }
40
40
 
41
41
  export default async function DateScopedArticlePage({ params }: DateScopedArticlePageProps) {
42
+ if (!editionsEnabled()) notFound();
42
43
  const { year, month, day, articleSlug } = await params;
43
44
  const route = parseEditionArticleRoute({ year, month, day, articleSlug });
44
45
  if (!route) notFound();
@@ -1,4 +1,5 @@
1
1
  import { notFound, redirect } from "next/navigation";
2
+ import { editionsEnabled } from "../../../../lib/site-brand";
2
3
  import { siteServesNextReaderRoutes } from "../../../../lib/reader-static-params";
3
4
  import { getSiteRenderer } from "../../../../lib/site-renderer";
4
5
  import type { PresentationTarget } from "../../../../renderers/pretext";
@@ -23,7 +24,7 @@ export async function EditionRoutePage({
23
24
  lockedPresentation,
24
25
  target = { kind: "edition" },
25
26
  }: EditionRoutePageProps) {
26
- if (!siteServesNextReaderRoutes()) notFound();
27
+ if (!editionsEnabled() || !siteServesNextReaderRoutes()) notFound();
27
28
  const route = parseEditionDateRoute({ year, month, day });
28
29
  if (!route) notFound();
29
30
  if (!route.isCanonical) redirect(route.canonicalPath);
@@ -1,7 +1,7 @@
1
1
  import { notFound, redirect } from "next/navigation";
2
2
  import { EditionRoutePage } from "../../edition-route-page";
3
3
  import { parseEditionPageRoute } from "../../../../../../lib/edition-routes";
4
- import { getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
4
+ import { editionsEnabled, getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
5
5
  import { generateEditionPageStaticParams } from "../../../../../../lib/reader-static-params";
6
6
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
7
7
  export const revalidate = 60;
@@ -20,6 +20,7 @@ type EditionPageRouteProps = {
20
20
  };
21
21
 
22
22
  export default async function EditionPageRoute({ params }: EditionPageRouteProps) {
23
+ if (!editionsEnabled()) notFound();
23
24
  const { year, month, day, pageNumber } = await params;
24
25
  const route = parseEditionPageRoute({ year, month, day, pageNumber });
25
26
  if (!route) notFound();
@@ -1,3 +1,5 @@
1
+ import { notFound } from "next/navigation";
2
+ import { editionsEnabled } from "../../../../lib/site-brand";
1
3
  import { EditionRoutePage } from "./edition-route-page";
2
4
  import { generateEditionDateStaticParams } from "../../../../lib/reader-static-params";
3
5
 
@@ -17,6 +19,7 @@ type DateEditionPageProps = {
17
19
  };
18
20
 
19
21
  export default async function DateEditionPage({ params }: DateEditionPageProps) {
22
+ if (!editionsEnabled()) notFound();
20
23
  const { year, month, day } = await params;
21
24
  return <EditionRoutePage day={day} month={month} year={year} />;
22
25
  }
@@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation";
2
2
  import { EditionRoutePage } from "../../edition-route-page";
3
3
  import { createSectionKey } from "../../../../../../lib/edition-sections";
4
4
  import { parseEditionSectionRoute } from "../../../../../../lib/edition-routes";
5
+ import { editionsEnabled } from "../../../../../../lib/site-brand";
5
6
  import { generateEditionSectionStaticParams } from "../../../../../../lib/reader-static-params";
6
7
  // Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
7
8
  export const revalidate = 60;
@@ -20,6 +21,7 @@ type EditionSectionRouteProps = {
20
21
  };
21
22
 
22
23
  export default async function EditionSectionRoute({ params }: EditionSectionRouteProps) {
24
+ if (!editionsEnabled()) notFound();
23
25
  const { year, month, day, sectionKey } = await params;
24
26
  const normalizedSectionKey = createSectionKey(sectionKey);
25
27
  const route = parseEditionSectionRoute({ year, month, day, sectionKey: normalizedSectionKey });
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { editionsEnabled } from "../../../../lib/site-brand";
2
3
  import { loadArchiveEditionPreviews, clampArchiveLimit } from "../../../../lib/archive-data";
3
4
  import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
4
5
 
@@ -6,6 +7,7 @@ import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
6
7
  export const revalidate = 60;
7
8
 
8
9
  export async function GET(request: Request) {
10
+ if (!editionsEnabled()) return new NextResponse(null, { status: 404 });
9
11
  const url = new URL(request.url);
10
12
  const cursor = url.searchParams.get("cursor");
11
13
  const requestedLimit = Number(url.searchParams.get("limit") ?? ARCHIVE_BATCH_SIZE);
@@ -1,3 +1,5 @@
1
+ import { notFound } from "next/navigation";
2
+ import { editionsEnabled } from "../../lib/site-brand";
1
3
  import { ArchiveShell } from "../../components/archive-shell";
2
4
  import { ArchiveGrid } from "../../components/archive-grid";
3
5
  import { loadArchiveEditionPreviews } from "../../lib/archive-data";
@@ -7,6 +9,7 @@ import { ARCHIVE_BATCH_SIZE } from "../../lib/archive-types";
7
9
  export const revalidate = 60;
8
10
 
9
11
  export default async function ArchivePage() {
12
+ if (!editionsEnabled()) notFound();
10
13
  const initialBatch = await loadArchiveEditionPreviews({ limit: ARCHIVE_BATCH_SIZE });
11
14
 
12
15
  return (
package/app/page.tsx CHANGED
@@ -15,7 +15,10 @@ import {
15
15
  type PublicPlaceholderTopic,
16
16
  } from "../lib/public-placeholder-config";
17
17
  import type { PublicationItem } from "../lib/publication-items";
18
- import { SITE_BRAND, rootRoute } from "../lib/site-brand";
18
+ import { SITE_BRAND, editionsEnabled, rootRoute } from "../lib/site-brand";
19
+ import { ItemStreamView } from "../components/item-stream-view";
20
+ import { ITEM_STREAM_RECENT_EDITION_COUNT, buildItemStream, selectNewestStreamItems } from "../lib/item-stream";
21
+ import { listCachedPublishedEditions } from "../lib/cached-content-repository";
19
22
  import { notFound, redirect } from "next/navigation";
20
23
  import { getReaderBasePath } from "../lib/reader-base-path";
21
24
  import { buildRootRedirectTarget } from "../lib/root-route-redirect";
@@ -27,6 +30,7 @@ export const dynamic = "force-dynamic";
27
30
  type HomePageProps = {
28
31
  searchParams?: Promise<{
29
32
  scenario?: string | string[];
33
+ section?: string | string[];
30
34
  code?: string | string[];
31
35
  state?: string | string[];
32
36
  error?: string | string[];
@@ -59,6 +63,13 @@ export default async function Home({ searchParams }: HomePageProps) {
59
63
  notFound();
60
64
  }
61
65
 
66
+ if (!editionsEnabled()) {
67
+ const sectionParam = resolvedSearchParams?.section;
68
+ const sectionKey = Array.isArray(sectionParam) ? sectionParam[0] : sectionParam;
69
+ const items = await loadNewestStreamItemsOrNone();
70
+ return <ItemStreamView stream={buildItemStream(items, sectionKey)} />;
71
+ }
72
+
62
73
  // Resolved only on the reader path: Markus brands (renderer "markus") have
63
74
  // no Next.js reader and getSiteRenderer() throws for them, but they can
64
75
  // still serve the newsroom at the root via rootRoute above.
@@ -88,6 +99,24 @@ export default async function Home({ searchParams }: HomePageProps) {
88
99
  return <siteRenderer.renderEdition content={content} />;
89
100
  }
90
101
 
102
+ async function loadNewestStreamItemsOrNone() {
103
+ try {
104
+ return await loadNewestStreamItems();
105
+ } catch (error) {
106
+ if (!isMissingReaderBackendError(error)) throw error;
107
+ return [];
108
+ }
109
+ }
110
+
111
+ async function loadNewestStreamItems() {
112
+ const { editions } = await listCachedPublishedEditions({ limit: ITEM_STREAM_RECENT_EDITION_COUNT });
113
+ const newestFirst = [...editions].sort((left, right) => right.editionDate.localeCompare(left.editionDate));
114
+ const contents = await Promise.all(
115
+ newestFirst.map((edition) => loadCachedEditionContent({ editionDate: edition.editionDate })),
116
+ );
117
+ return selectNewestStreamItems(contents);
118
+ }
119
+
91
120
  async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
92
121
  const latestEdition = await getCachedLatestPublishedEdition();
93
122
  if (!latestEdition) return null;
@@ -3,11 +3,13 @@
3
3
  * papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
4
4
  * papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
5
5
  * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
6
- * Synthesizes the app-shell stack for a publication from its own site.json, or
7
- * prints one of the generated Amplify build specs. Synth only: deploying is a
8
- * separate, reviewed step.
6
+ * papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
7
+ * Synthesizes the app-shell stack for a publication from its own site.json,
8
+ * prints one of the generated Amplify build specs, or deploys the stack from
9
+ * inside the Amplify build of the one branch that owns it.
9
10
  */
10
11
  import fs from "node:fs";
12
+ import { spawnSync } from "node:child_process";
11
13
  import path from "node:path";
12
14
  import { createRequire } from "node:module";
13
15
  import { buildSpecFor } from "../infra/build-specs.js";
@@ -17,6 +19,7 @@ const USAGE = [
17
19
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
18
20
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
19
21
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
22
+ " papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]",
20
23
  ].join("\n");
21
24
 
22
25
  const args = process.argv.slice(2);
@@ -25,7 +28,7 @@ const opt = (name, fallback) => {
25
28
  return i >= 0 ? args[i + 1] : fallback;
26
29
  };
27
30
  const command = args[0];
28
- if (command !== "synth" && command !== "print-buildspec") {
31
+ if (command !== "synth" && command !== "print-buildspec" && command !== "deploy") {
29
32
  console.error(USAGE);
30
33
  process.exit(command ? 1 : 0);
31
34
  }
@@ -77,6 +80,41 @@ if (command === "print-buildspec") {
77
80
  process.exit(0);
78
81
  }
79
82
 
83
+ if (command === "deploy") {
84
+ const buildAppId = process.env.AWS_APP_ID;
85
+ const buildBranch = process.env.AWS_BRANCH;
86
+ const owningBranch = opt("only-branch", "main");
87
+ const owningAppId = opt("only-app-id");
88
+ if (!buildAppId || !buildBranch) {
89
+ console.log("papyrus-infra deploy: not running inside an Amplify build, skipping");
90
+ process.exit(0);
91
+ }
92
+ if (buildBranch !== owningBranch || (owningAppId && buildAppId !== owningAppId)) {
93
+ console.log(`papyrus-infra deploy: ${buildAppId}/${buildBranch} does not own the stack, skipping`);
94
+ process.exit(0);
95
+ }
96
+ const outputDirectory = path.resolve(opt("out", process.env.CDK_OUTDIR ?? "cdk.out"));
97
+ const synthCommand = [
98
+ process.execPath,
99
+ path.resolve(process.argv[1]),
100
+ "synth",
101
+ "--site",
102
+ path.resolve(opt("site", "infra/site.json")),
103
+ "--out",
104
+ outputDirectory,
105
+ "--account",
106
+ opt("account", process.env.CDK_DEFAULT_ACCOUNT ?? "335163751677"),
107
+ "--region",
108
+ opt("region", process.env.CDK_DEFAULT_REGION ?? "us-east-1"),
109
+ ].map((part) => JSON.stringify(part)).join(" ");
110
+ const deployment = spawnSync(
111
+ "npx",
112
+ ["--yes", "aws-cdk@2", "deploy", "--app", synthCommand, "--require-approval", "never", `AmplifyAppShell-${config.siteId}`],
113
+ { stdio: "inherit" },
114
+ );
115
+ process.exit(deployment.status ?? 1);
116
+ }
117
+
80
118
  let AmplifyAppShellStack;
81
119
  try {
82
120
  ({ AmplifyAppShellStack } = await import("../infra/index.js"));
@@ -0,0 +1,63 @@
1
+ import Link from "next/link";
2
+ import type { ItemStream } from "../lib/item-stream";
3
+ import { toPublicReaderPath } from "../lib/reader-base-path";
4
+ import { SITE_BRAND } from "../lib/site-brand";
5
+
6
+ /** The home of a publication without editions: a masthead, section filters and the newest items. */
7
+ export function ItemStreamView({ stream }: { stream: ItemStream }) {
8
+ const homePath = toPublicReaderPath("/");
9
+ const tagline = SITE_BRAND.mastheadTagline ?? null;
10
+
11
+ return (
12
+ <main className="presentation-page presentation-page--blog" data-presentation-engine="item-stream">
13
+ <header className="presentation-header">
14
+ <div className="presentation-header__copy-stack">
15
+ <h1>
16
+ <Link href={homePath}>{SITE_BRAND.mastheadTitle}</Link>
17
+ </h1>
18
+ <div className="presentation-header__meta">
19
+ <span className="presentation-header__subtitle">{SITE_BRAND.mastheadSubtitle}</span>
20
+ </div>
21
+ {tagline ? <span className="presentation-header__tagline">{tagline}</span> : null}
22
+ </div>
23
+ </header>
24
+ {stream.sections.length > 1 ? (
25
+ <nav className="presentation-section-nav" aria-label="Sections">
26
+ <Link href={homePath} aria-current={stream.activeSectionKey === null ? "page" : undefined}>
27
+ All
28
+ </Link>
29
+ {stream.sections.map((section) => (
30
+ <Link
31
+ aria-current={stream.activeSectionKey === section.key ? "page" : undefined}
32
+ href={`${homePath}?section=${encodeURIComponent(section.key)}`}
33
+ key={section.key}
34
+ >
35
+ {section.label}
36
+ </Link>
37
+ ))}
38
+ </nav>
39
+ ) : null}
40
+ {stream.items.length === 0 ? (
41
+ <section className="item-stream-empty" data-item-stream-empty="true">
42
+ <p>Nothing published yet</p>
43
+ </section>
44
+ ) : (
45
+ <div className="blog-sections">
46
+ {stream.items.map((item) => (
47
+ <article className="presentation-item presentation-item--blog" data-item-id={item.slug} key={item.slug}>
48
+ <div className="presentation-item__copy">
49
+ <header className="presentation-item__header">
50
+ <p>{item.section ?? "General"}</p>
51
+ <h2>
52
+ <Link href={toPublicReaderPath(`/articles/${encodeURIComponent(item.slug)}`)}>{item.headline}</Link>
53
+ </h2>
54
+ {item.deck ? <span>{item.deck}</span> : null}
55
+ </header>
56
+ </div>
57
+ </article>
58
+ ))}
59
+ </div>
60
+ )}
61
+ </main>
62
+ );
63
+ }
@@ -117,7 +117,7 @@ function AssignmentDetailPanel({
117
117
  detailExtras?: ReactNode;
118
118
  }) {
119
119
  const terminal = assignment.status === "completed" || assignment.status === "canceled";
120
- const isReporting = assignment.assignmentTypeKey === "reporting.edition-candidate";
120
+ const isReporting = assignment.assignmentTypeKey === "reporting.candidate";
121
121
 
122
122
  return (
123
123
  <div
@@ -12368,7 +12368,7 @@ function ReportingStoryBudgetBoard({
12368
12368
  section={section}
12369
12369
  selectedAssignmentId={selectedAssignmentId}
12370
12370
  />
12371
- )) : <EmptyRow label="No reporting edition-candidate assignments found" />}
12371
+ )) : <EmptyRow label="No reporting candidate assignments found" />}
12372
12372
  </div>
12373
12373
  );
12374
12374
  }
@@ -14568,7 +14568,7 @@ function buildUiReportingPacketReviewPlan({
14568
14568
  event: AssignmentEventRecord;
14569
14569
  relations: SemanticRelationRecord[];
14570
14570
  } {
14571
- if (assignment.assignmentTypeKey !== "reporting.edition-candidate") throw new Error("Only reporting edition-candidate assignments can review reporting packets.");
14571
+ if (assignment.assignmentTypeKey !== "reporting.candidate") throw new Error("Only reporting candidate assignments can review reporting packets.");
14572
14572
  if (message.messageKind !== "reporting_context_packet") throw new Error("Only reporting context packets can be reviewed.");
14573
14573
  if (decision === "merge" && !targetItem?.id && !targetItemId.trim()) throw new Error("Merge Packet requires a target Item ID.");
14574
14574
  const resolvedTargetItem = targetItem ?? (targetItemId.trim() ? { id: targetItemId.trim(), lineageId: targetItemId.trim(), versionNumber: null } : null);
@@ -43,7 +43,9 @@ function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, ca
43
43
  lines.push(" build:", " commands:", ...commandLines(buildCommands, " "), " artifacts:", ` baseDirectory: ${artifactsDirectory}`, " files:", " - '**/*'", " cache:", " paths:", ...cachePaths.map((cachePath) => ` - ${cachePath}`));
44
44
  return lines;
45
45
  }
46
+ const DEPLOY_STACK_IN_BUILD = "npx papyrus-infra deploy --site infra/site.json --only-branch main";
46
47
  export function cmsProductionBuildSpec(config) {
48
+ const deployStackCommands = config.cms?.deployStackInBuild ? [DEPLOY_STACK_IN_BUILD] : [];
47
49
  return [
48
50
  "version: 1",
49
51
  "backend:",
@@ -52,6 +54,7 @@ export function cmsProductionBuildSpec(config) {
52
54
  " commands:",
53
55
  ...commandLines([
54
56
  NPM_CLEAN_INSTALL,
57
+ ...deployStackCommands,
55
58
  "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
56
59
  "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
57
60
  ], " "),
@@ -67,7 +67,10 @@ function frontendSection(preBuildCommands: string[], buildCommands: string[], ar
67
67
  return lines;
68
68
  }
69
69
 
70
+ const DEPLOY_STACK_IN_BUILD = "npx papyrus-infra deploy --site infra/site.json --only-branch main";
71
+
70
72
  export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): string {
73
+ const deployStackCommands = config.cms?.deployStackInBuild ? [DEPLOY_STACK_IN_BUILD] : [];
71
74
  return [
72
75
  "version: 1",
73
76
  "backend:",
@@ -77,6 +80,7 @@ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): strin
77
80
  ...commandLines(
78
81
  [
79
82
  NPM_CLEAN_INSTALL,
83
+ ...deployStackCommands,
80
84
  "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
81
85
  "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
82
86
  ],
@@ -199,7 +199,7 @@ function parseStagingPreview(value) {
199
199
  }
200
200
  function parseCms(value) {
201
201
  const cmsRecord = requireObject(value, "cms");
202
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
202
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
203
203
  const buildComputeType = cmsRecord.buildComputeType;
204
204
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
205
205
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -210,6 +210,9 @@ function parseCms(value) {
210
210
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
211
211
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
212
212
  }
213
+ if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
214
+ fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
215
+ }
213
216
  const cms = {
214
217
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
215
218
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
@@ -222,6 +225,7 @@ function parseCms(value) {
222
225
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
223
226
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
224
227
  redirects: parseRedirects(cmsRecord.redirects),
228
+ deployStackInBuild: cmsRecord.deployStackInBuild,
225
229
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
226
230
  prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
227
231
  };
@@ -42,6 +42,7 @@ export type CmsConfig = {
42
42
  stagingDomainPrefix?: string;
43
43
  redirects?: CmsRedirect[];
44
44
  prospectusAccountsApiStageArn?: string;
45
+ deployStackInBuild?: boolean;
45
46
  };
46
47
 
47
48
  export type AmplifyAppShellSiteConfig = {
@@ -281,7 +282,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
281
282
 
282
283
  function parseCms(value: unknown): CmsConfig {
283
284
  const cmsRecord = requireObject(value, "cms");
284
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
285
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
285
286
  const buildComputeType = cmsRecord.buildComputeType;
286
287
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
287
288
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -292,6 +293,9 @@ function parseCms(value: unknown): CmsConfig {
292
293
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
293
294
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
294
295
  }
296
+ if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
297
+ fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
298
+ }
295
299
  const cms: CmsConfig = {
296
300
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
297
301
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
@@ -309,6 +313,7 @@ function parseCms(value: unknown): CmsConfig {
309
313
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
310
314
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
311
315
  redirects: parseRedirects(cmsRecord.redirects),
316
+ deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
312
317
  stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
313
318
  prospectusAccountsApiStageArn: optionalString(
314
319
  cmsRecord.prospectusAccountsApiStageArn,
@@ -1510,7 +1510,7 @@ export function createDemoCategorySteeringDashboard(
1510
1510
  assignments: [
1511
1511
  {
1512
1512
  id: assignmentReportingId,
1513
- assignmentTypeKey: "reporting.edition-candidate",
1513
+ assignmentTypeKey: "reporting.candidate",
1514
1514
  queueKey: "edition:edition-demo:section:news:lane:reporting",
1515
1515
  queueStatusKey: "edition:edition-demo:section:news:lane:reporting#open",
1516
1516
  status: "open",
@@ -0,0 +1,47 @@
1
+ import type { EditionContent, EditionSection } from "./content-types";
2
+ import { createEditionSectionPlan, createSectionKey } from "./edition-sections";
3
+ import { getArticlePublicationItems, type ArticlePublicationItem } from "./publication-items";
4
+
5
+ export const ITEM_STREAM_RECENT_EDITION_COUNT = 10;
6
+ export const ITEM_STREAM_ITEM_LIMIT = 50;
7
+
8
+ export type ItemStream = {
9
+ items: ArticlePublicationItem[];
10
+ sections: EditionSection[];
11
+ activeSectionKey: string | null;
12
+ };
13
+
14
+ /**
15
+ * Flattens published content into one newest-first stream of articles. The
16
+ * input is ordered newest first; a slug that appears twice keeps its newest entry.
17
+ */
18
+ export function selectNewestStreamItems(
19
+ newestFirstContents: EditionContent[],
20
+ limit: number = ITEM_STREAM_ITEM_LIMIT,
21
+ ): ArticlePublicationItem[] {
22
+ const seenSlugs = new Set<string>();
23
+ const selected: ArticlePublicationItem[] = [];
24
+ for (const content of newestFirstContents) {
25
+ for (const item of getArticlePublicationItems(content.items)) {
26
+ if (seenSlugs.has(item.slug)) continue;
27
+ seenSlugs.add(item.slug);
28
+ selected.push(item);
29
+ if (selected.length >= limit) return selected;
30
+ }
31
+ }
32
+ return selected;
33
+ }
34
+
35
+ /** Builds the stream view: the sections found in the items, and the items filtered by an optional section key. */
36
+ export function buildItemStream(items: ArticlePublicationItem[], sectionKey?: string | null): ItemStream {
37
+ const sections = createEditionSectionPlan(items);
38
+ const activeSectionKey = sectionKey ? createSectionKey(sectionKey) : null;
39
+ const activeSection = activeSectionKey ? sections.find((section) => section.key === activeSectionKey) : undefined;
40
+ if (!activeSection) return { items, sections, activeSectionKey: null };
41
+ const itemIds = new Set(activeSection.itemIds);
42
+ return {
43
+ items: items.filter((item) => itemIds.has(item.slug)),
44
+ sections,
45
+ activeSectionKey: activeSection.key,
46
+ };
47
+ }
@@ -41,7 +41,7 @@ export function formatAssignmentTypeLabel(typeKey: string | null | undefined): s
41
41
  if (key === "analysis.reindex") return "Analysis re-index";
42
42
  if (key === "newsroom.research" || key === "research") return "Research";
43
43
  if (key === "curation.reference-intake") return "Reference intake";
44
- if (key === "reporting.edition-candidate") return "Reporting candidate";
44
+ if (key === "reporting.candidate") return "Reporting candidate";
45
45
  return key
46
46
  .split(/[.:_-]+/)
47
47
  .filter(Boolean)
@@ -194,7 +194,7 @@ export function applyPilobolusDemoDashboardContent(
194
194
  });
195
195
 
196
196
  localized.assignments = localized.assignments.map((assignment, index) => {
197
- if (assignment.assignmentTypeKey === "reporting.edition-candidate") {
197
+ if (assignment.assignmentTypeKey === "reporting.candidate") {
198
198
  return {
199
199
  ...assignment,
200
200
  title: "Field-notes reporting candidate 1: Strangler-fig control-loop shift",
@@ -1,6 +1,6 @@
1
1
  import type { ContentRepository, EditionContent } from "./content-types";
2
2
  import { graphqlContentRepository } from "./graphql-content-repository";
3
- import { SITE_BRAND } from "./site-brand";
3
+ import { SITE_BRAND, editionsEnabled } from "./site-brand";
4
4
  import { getContentSource } from "./site-env";
5
5
  import type { RendererConfig } from "./renderer-config";
6
6
 
@@ -40,6 +40,7 @@ const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
40
40
  type EnumerationRepository = Pick<ContentRepository, "listPublishedEditions" | "loadEditionContent">;
41
41
 
42
42
  function shouldEnumerateAtBuild(): boolean {
43
+ if (!editionsEnabled()) return false;
43
44
  if (process.env.NODE_ENV === "development") return false;
44
45
  return siteEnumeratesReaderRoutesAtBuild();
45
46
  }
@@ -106,7 +106,7 @@ export type ReportingStoryBudgetInput = {
106
106
  messagePayloads?: Record<string, unknown>;
107
107
  };
108
108
 
109
- const REPORTING_ASSIGNMENT_TYPE = "reporting.edition-candidate";
109
+ const REPORTING_ASSIGNMENT_TYPE = "reporting.candidate";
110
110
  const REPORTING_PACKET_KIND = "reporting_context_packet";
111
111
  const RESEARCH_PACKET_KIND = "research_packet";
112
112
 
@@ -331,7 +331,7 @@ function buildResearchPacketIdsByReportingAssignment(assignments: AssignmentReco
331
331
  if (relation.subjectKind === "assignment" && relation.objectKind === "assignment") {
332
332
  const reporting = assignmentById.get(relation.subjectId);
333
333
  const research = assignmentById.get(relation.objectId);
334
- if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.edition-candidate") continue;
334
+ if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.candidate") continue;
335
335
  for (const packetId of producedResearchPacketsByAssignment.get(research.id) ?? []) {
336
336
  result.set(reporting.id, addToSet(result.get(reporting.id), packetId));
337
337
  }
package/lib/site-brand.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isLocalPreviewEnabled } from "./local-preview";
2
+ import { areEditionsEnabled } from "./site-env";
2
3
  import type { ComponentType } from "react";
3
4
  import type { EditionPresentationFormat } from "./content-types";
4
5
  import type { Article, ArticleVideoAsset } from "./articles";
@@ -157,6 +158,11 @@ export type SiteBrand = {
157
158
  fonts?: SiteBrandFont[];
158
159
  demoEdition?: SiteBrandDemoEdition;
159
160
  video?: SiteBrandVideo;
161
+ /**
162
+ * Editions are an optional setting, off unless `{ enabled: true }`. When off the
163
+ * publication is a plain stream of items: no edition date routes, archive or masthead date.
164
+ */
165
+ editions?: { enabled: boolean };
160
166
  };
161
167
 
162
168
  const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
@@ -253,6 +259,11 @@ export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
253
259
 
254
260
  export const rootRoute = getRootRoute();
255
261
 
262
+ /** The brand setting wins when present; otherwise the PAPYRUS_EDITIONS_ENABLED switch decides. */
263
+ export function editionsEnabled(brand: Pick<SiteBrand, "editions"> = SITE_BRAND): boolean {
264
+ return brand.editions?.enabled ?? areEditionsEnabled();
265
+ }
266
+
256
267
  export function enforcePresentation(presentation: EditionPresentationFormat): EditionPresentationFormat {
257
268
  return SITE_BRAND.forcedPresentation ?? presentation;
258
269
  }
package/lib/site-env.ts CHANGED
@@ -20,6 +20,11 @@ export function getSiteEnv(environment: EnvironmentVariables = process.env): Sit
20
20
  return configured as SiteEnv;
21
21
  }
22
22
 
23
+ export function areEditionsEnabled(environment: EnvironmentVariables = process.env): boolean {
24
+ const configured = (environment.PAPYRUS_EDITIONS_ENABLED ?? "").trim().toLowerCase();
25
+ return configured === "true" || configured === "1" || configured === "yes" || configured === "on";
26
+ }
27
+
23
28
  export function getContentSource(environment: EnvironmentVariables = process.env): ContentSource {
24
29
  const configured = (environment.PAPYRUS_CONTENT_SOURCE ?? "").trim();
25
30
  if (!configured) return "published";
package/package.json CHANGED
@@ -78,6 +78,7 @@
78
78
  "./components/blog-page-background": "./components/blog-page-background.tsx",
79
79
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
80
80
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
81
+ "./components/item-stream-view": "./components/item-stream-view.tsx",
81
82
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
82
83
  "./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
83
84
  "./components/news-desk-page": "./components/news-desk-page.tsx",
@@ -191,6 +192,7 @@
191
192
  "./lib/furniture": "./lib/furniture.ts",
192
193
  "./lib/graphql-content-repository": "./lib/graphql-content-repository.ts",
193
194
  "./lib/image-url": "./lib/image-url.ts",
195
+ "./lib/item-stream": "./lib/item-stream.ts",
194
196
  "./lib/labels": "./lib/labels.ts",
195
197
  "./lib/layout-config": "./lib/layout-config.ts",
196
198
  "./lib/layout-plan": "./lib/layout-plan.ts",
@@ -377,7 +379,7 @@
377
379
  "optional": true
378
380
  }
379
381
  },
380
- "version": "1.16.0",
382
+ "version": "1.18.0-next.1",
381
383
  "dependencies": {
382
384
  "@aws-amplify/adapter-nextjs": "^1.7.3",
383
385
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.16.0",
2
+ "version": "1.18.0-next.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",