@anthusai/papyrus 2.0.0-next.9 → 2.0.0-next.90

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 (217) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +151 -61
  6. package/amplify/data/resource.ts +157 -61
  7. package/amplify/data/schema.js +2096 -1980
  8. package/amplify/data/schema.ts +198 -69
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +61 -2
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  25. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  26. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  27. package/amplify/functions/editorial-checks/schema.js +122 -0
  28. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  29. package/amplify/functions/editorial-checks/wire.js +53 -0
  30. package/amplify/functions/editorial-checks/wire.ts +61 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +17 -0
  35. package/amplify/functions/monitoring-view/schema.ts +19 -0
  36. package/amplify/functions/monitoring-view/wire.js +37 -0
  37. package/amplify/functions/monitoring-view/wire.ts +47 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1567 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +458 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  76. package/amplify/plugins/reader-steering/schema.js +229 -0
  77. package/amplify/plugins/reader-steering/schema.ts +231 -0
  78. package/amplify/plugins/registry.js +4 -2
  79. package/amplify/plugins/registry.ts +4 -2
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.ts +18 -1
  93. package/amplify/site-backend-names.js +4 -2
  94. package/amplify/site-backend-names.ts +4 -2
  95. package/amplify/site-backend.js +45 -3
  96. package/amplify/site-backend.ts +51 -3
  97. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  98. package/app/api/newsletter/confirm/route.ts +13 -0
  99. package/app/api/newsletter/preferences/route.ts +13 -0
  100. package/app/api/newsletter/state/route.ts +9 -0
  101. package/app/api/newsletter/subscribe/route.ts +9 -0
  102. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  103. package/app/api/newsroom/curation/context/route.ts +9 -0
  104. package/app/api/newsroom/curation/priming/route.ts +9 -0
  105. package/app/api/newsroom/curation/publication/route.ts +9 -0
  106. package/app/api/reader/comment/delete/route.ts +9 -0
  107. package/app/api/reader/comment/edit/route.ts +9 -0
  108. package/app/api/reader/comment/route.ts +9 -0
  109. package/app/api/reader/flag/route.ts +9 -0
  110. package/app/api/reader/handle/route.ts +9 -0
  111. package/app/api/reader/me/route.ts +9 -0
  112. package/app/api/reader/rating/route.ts +9 -0
  113. package/app/api/reader/report/route.ts +9 -0
  114. package/app/api/reader/retired/route.ts +9 -0
  115. package/app/api/reader/submit/route.ts +9 -0
  116. package/app/api/reader/vote/route.ts +9 -0
  117. package/bin/papyrus-infra.mjs +20 -1
  118. package/components/automation-panel.tsx +49 -0
  119. package/components/automation-run-now.tsx +97 -0
  120. package/components/automation-view.tsx +195 -0
  121. package/components/editorial-checks-admin-panel.tsx +38 -13
  122. package/components/moderation-panel.tsx +248 -0
  123. package/components/monitoring-overview-view.tsx +241 -0
  124. package/components/monitoring-panel.tsx +40 -0
  125. package/components/news-desk-client-provider.tsx +18 -18
  126. package/components/news-desk-taxonomy-client.ts +28 -8
  127. package/components/newsletter-panel.tsx +474 -0
  128. package/components/newsletter-sections.tsx +163 -0
  129. package/components/newsroom-access-gate.tsx +16 -2
  130. package/components/newsroom-article-editor.tsx +11 -11
  131. package/components/newsroom-articles-view.tsx +6 -6
  132. package/components/newsroom-assignment-detail.tsx +6 -6
  133. package/components/newsroom-board-view.tsx +8 -3
  134. package/components/newsroom-curation-bootstrap.tsx +45 -0
  135. package/components/newsroom-ops-overview.tsx +26 -96
  136. package/components/newsroom-ops-shell.tsx +2 -2
  137. package/components/newsroom-organizer.css +33 -0
  138. package/components/newsroom-overview-skeleton.tsx +25 -0
  139. package/components/newsroom-references-view.tsx +192 -49
  140. package/components/notification-center.tsx +277 -0
  141. package/components/publication-cyclotron-panel.tsx +15 -10
  142. package/components/reader-auth-state.ts +1 -1
  143. package/components/reference-add-entry.tsx +75 -0
  144. package/components/reference-curation-controls.tsx +31 -22
  145. package/components/reference-cyclotron-priming.tsx +58 -19
  146. package/components/reference-cyclotron-suggestion.tsx +10 -6
  147. package/components/reference-priming.tsx +14 -10
  148. package/components/reference-relevance-review.tsx +3 -3
  149. package/components/reference-section-suggestion.tsx +1 -1
  150. package/components/topic-steering-workspace.tsx +237 -158
  151. package/components/use-editorial-checks.ts +12 -10
  152. package/infra/amplify-app-shell.js +94 -16
  153. package/infra/amplify-app-shell.ts +106 -18
  154. package/infra/cloud-email-config.js +32 -0
  155. package/infra/cloud-email-config.ts +39 -0
  156. package/infra/cloud-email.js +57 -0
  157. package/infra/cloud-email.test.js +69 -0
  158. package/infra/cloud-email.test.ts +66 -0
  159. package/infra/cloud-email.ts +60 -0
  160. package/infra/index.js +1 -0
  161. package/infra/secure-string-parameter-handler.js +41 -0
  162. package/infra/secure-string-parameter-handler.ts +41 -0
  163. package/infra/secure-string-parameter.js +79 -0
  164. package/infra/secure-string-parameter.ts +91 -0
  165. package/infra/site-config.js +88 -6
  166. package/infra/site-config.ts +134 -6
  167. package/lib/automation-client.ts +54 -0
  168. package/lib/automation-fixture.ts +93 -0
  169. package/lib/automation-overview.ts +252 -0
  170. package/lib/category-repository.ts +14 -12
  171. package/lib/console-web-context.ts +1 -1
  172. package/lib/curation-routes.ts +345 -0
  173. package/lib/cyclotron-suggestions.ts +4 -4
  174. package/lib/editorial-checks-backend.ts +1 -1
  175. package/lib/editorial-checks-client.ts +19 -1
  176. package/lib/editorial-checks-demo.ts +2 -2
  177. package/lib/editorial-checks-settings.ts +3 -0
  178. package/lib/editorial-checks.ts +8 -11
  179. package/lib/editorial-preview.ts +5 -5
  180. package/lib/monitoring-client.ts +29 -0
  181. package/lib/monitoring-overview-fixture.ts +51 -0
  182. package/lib/monitoring-overview.ts +145 -0
  183. package/lib/news-desk-session.ts +12 -21
  184. package/lib/newsletter-api.ts +418 -0
  185. package/lib/newsletter-client.ts +46 -0
  186. package/lib/newsletter-fixture.ts +184 -0
  187. package/lib/newsletter-model.ts +202 -0
  188. package/lib/newsroom-demo-dashboard.ts +1 -1
  189. package/lib/newsroom-nav.ts +1 -1
  190. package/lib/newsroom-organizer-nav.ts +2 -1
  191. package/lib/prospectus-session.ts +15 -14
  192. package/lib/prospectus-sign-in-routes.ts +105 -20
  193. package/lib/prospectus-site-identity.ts +46 -6
  194. package/lib/reader-api.ts +145 -0
  195. package/lib/reader-moderation.ts +220 -0
  196. package/lib/reader-retirement-route.ts +89 -0
  197. package/lib/reader-subject.md +29 -0
  198. package/lib/reader-subject.ts +53 -0
  199. package/lib/reference-review-details.ts +71 -0
  200. package/lib/reference-review-request.ts +14 -0
  201. package/lib/relevance-decisions.ts +42 -10
  202. package/lib/relevance-status.ts +2 -2
  203. package/lib/review-client.ts +117 -0
  204. package/lib/review-fixture.ts +45 -0
  205. package/lib/review-requests.ts +244 -0
  206. package/lib/semantic-graph.ts +2 -0
  207. package/lib/site-brand-tenant.ts +11 -6
  208. package/lib/site-brand.ts +6 -3
  209. package/lib/site-origin.ts +67 -0
  210. package/lib/use-reference-review-data.ts +44 -0
  211. package/middleware.ts +14 -3
  212. package/package.json +56 -3
  213. package/routes.manifest.json +215 -2
  214. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  215. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  216. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  217. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,458 @@
1
+ """The proposal record: what may be filed, which state changes are allowed, and who may make them.
2
+
3
+ A proposal is a change to the map written down where readers can see it: a story draft, a new concept, a rename, a merge,
4
+ a split, a new column or an element symbol. It is filed by AI (a reader proposer is part of the record but has no route yet)
5
+ and steered by votes; an editor decides. Staff reach this through the ``fileProposal`` and ``transitionProposal``
6
+ mutations (editors and admins; the function checks the groups again). An operator's IAM role in the function's own account
7
+ (an unattended automation or an agent without a browser sign-in) may also file an AI proposal and move it from draft to
8
+ in_steering or declined, and nothing else: approving, deciding after review and overriding stay with editors and admins.
9
+ ``editProposal`` is the editor tier (pin, hold, exclude and restore votes): editors and admins only, never an operator. Every state change is appended to the proposal's
10
+ ``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
11
+
12
+ Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
13
+ actor of a change is the staff member's Cognito subject, which is stored and never published either (the public file shows
14
+ "AI" or "Editor"). Votes are not counted here: they belong to the vote model and the snapshot joins them.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from typing import Any, Protocol
24
+
25
+ KINDS = ("story_draft", "new_concept", "rename", "merge", "split", "new_column", "element_symbol")
26
+ DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR = (
27
+ "draft",
28
+ "in_steering",
29
+ "editor_review",
30
+ "approved",
31
+ "declined",
32
+ "overridden_by_editor",
33
+ )
34
+ STATES = (DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
35
+
36
+ #: Every allowed state change. Anything not listed is refused. Approved, declined and overridden are final.
37
+ TRANSITIONS: dict[str, tuple[str, ...]] = {
38
+ DRAFT: (IN_STEERING, DECLINED),
39
+ IN_STEERING: (EDITOR_REVIEW,),
40
+ EDITOR_REVIEW: (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR),
41
+ }
42
+ #: The changes that need a reason. Every reason is public; this one is also required, because it explains a decision against the community.
43
+ REASON_REQUIRED = frozenset({OVERRIDDEN_BY_EDITOR})
44
+ #: An override says what the editor decided against the community's view: approved or declined. Approve and decline are their own result.
45
+ OVERRIDE_RESULTS = (APPROVED, DECLINED)
46
+
47
+ #: How many target subjects each kind names, as (minimum, maximum), and the types a target may have.
48
+ TARGET_COUNTS: dict[str, tuple[int, int]] = {
49
+ "story_draft": (0, 10),
50
+ "new_concept": (0, 10),
51
+ "rename": (1, 1),
52
+ "merge": (2, 10),
53
+ "split": (1, 1),
54
+ "new_column": (0, 10),
55
+ "element_symbol": (1, 1),
56
+ }
57
+ TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
58
+
59
+ MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
60
+
61
+ #: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
62
+ PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
63
+ EDIT_ACTIONS = (PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION)
64
+ FINAL_STATES = (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
65
+ #: The batch rules for excluding votes. A rule excludes the votes on one proposal that were already cast, by accounts that were
66
+ #: younger than ``hours`` when they voted; the snapshot applies it (votes are not counted here). ``label`` is the public wording.
67
+ EXCLUSION_RULES: dict[str, dict[str, Any]] = {
68
+ "new-accounts-24h": {"hours": 24, "label": "accounts under 24 h"},
69
+ }
70
+ EXCLUSION_ID = re.compile(r"exc-[0-9a-f]{12}")
71
+ MAXIMUM_EDITOR_LOG = 200
72
+
73
+
74
+ def event_field_name(event: dict[str, Any]) -> str | None:
75
+ """The GraphQL field an AppSync invocation is for.
76
+
77
+ The Amplify Gen 2 Lambda data source sends ``fieldName`` at the top level with no ``info`` key; a direct resolver nests it
78
+ under ``info``. Accept both.
79
+ """
80
+ info = event.get("info")
81
+ name = (info.get("fieldName") if isinstance(info, dict) else None) or event.get("fieldName")
82
+ return name if isinstance(name, str) and name else None
83
+
84
+
85
+ def is_appsync_event(event: dict[str, Any]) -> bool:
86
+ """True for an AppSync invocation (arguments plus ``info`` or a top-level ``fieldName``); the public path sends siteSubject, action and payload."""
87
+ return "arguments" in event and ("info" in event or event_field_name(event) is not None)
88
+ PROPOSER_AI = "ai"
89
+ STAFF_GROUPS = frozenset({"editor", "admin"})
90
+ #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
91
+ OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
92
+ BY_STAFF, BY_OPERATOR = "staff", "operator"
93
+ MAXIMUM_OPERATOR_LABEL = 256
94
+ PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
95
+ TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
96
+ MAXIMUM_TITLE = 200
97
+ MAXIMUM_SUMMARY = 300
98
+ MAXIMUM_BODY = 50_000
99
+ MAXIMUM_REASON = 500
100
+ MAXIMUM_EVIDENCE_SOURCE = 200
101
+ MAXIMUM_EVIDENCE_SIGNALS = 1_000_000
102
+ #: An optional figure spec kept in ``evidence.figure``: a small chart of cited series (no image), approved with the copy.
103
+ FIGURE_TYPES = ("line", "bar")
104
+ MAXIMUM_FIGURE_POINTS = 60
105
+ MAXIMUM_FIGURE_SERIES = 6
106
+ MAXIMUM_FIGURE_BYTES = 8_000
107
+
108
+
109
+ @dataclass
110
+ class Outcome:
111
+ status: str # "ok" or "refused"
112
+ id: str | None = None
113
+ error: str | None = None
114
+ message: str | None = None
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ return {"status": self.status, "id": self.id, "error": self.error, "message": self.message}
118
+
119
+
120
+ def refused(error: str, message: str) -> Outcome:
121
+ return Outcome("refused", error=error, message=message)
122
+
123
+
124
+ class ProposalStore(Protocol):
125
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None: ...
126
+
127
+ def create_proposal(self, row: dict[str, Any]) -> bool: ...
128
+
129
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
130
+
131
+
132
+ def staff_subject(identity: dict[str, Any]) -> str | None:
133
+ """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
134
+ claims = identity.get("claims") or {}
135
+ groups = claims.get("cognito:groups") or identity.get("groups") or []
136
+ if isinstance(groups, str):
137
+ groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
138
+ subject = identity.get("sub")
139
+ if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
140
+ return None
141
+ return subject
142
+
143
+
144
+ def operator_actor(identity: dict[str, Any], account_id: str | None) -> str | None:
145
+ """``operator:<principal ARN>`` for an IAM principal (a role or user) of the function's own account, or ``None``.
146
+
147
+ A Cognito identity-pool user is also an IAM principal of this account, but it carries a Cognito identity id (and a
148
+ user-pool caller carries a subject); neither is an operator. A principal of another account is refused, and so is a
149
+ caller whose ARN names another account than the one AppSync reports.
150
+ """
151
+ if not isinstance(identity, dict) or not account_id:
152
+ return None
153
+ arn = identity.get("userArn")
154
+ parts = arn.split(":") if isinstance(arn, str) else []
155
+ if len(parts) < 6 or parts[0] != "arn" or parts[4] != account_id or identity.get("accountId") != account_id:
156
+ return None
157
+ if any(identity.get(key) for key in ("cognitoIdentityId", "cognitoIdentityPoolId", "cognitoIdentityAuthType", "sub", "claims")):
158
+ return None
159
+ return f"operator:{arn}"[:MAXIMUM_OPERATOR_LABEL]
160
+
161
+
162
+ def clean_text(value: Any, limit: int) -> str | None:
163
+ if not isinstance(value, str):
164
+ return None
165
+ text = value.strip()
166
+ return text if text and len(text) <= limit else None
167
+
168
+
169
+ def parse_json(value: Any) -> Any:
170
+ """AppSync delivers AWSJSON arguments as strings; the CLI and tests may pass the parsed value."""
171
+ if isinstance(value, str):
172
+ try:
173
+ return json.loads(value)
174
+ except ValueError:
175
+ return None
176
+ return value
177
+
178
+
179
+ def clean_targets(kind: str, raw: Any) -> list[dict[str, str]] | Outcome:
180
+ parsed = parse_json(raw) if raw is not None else []
181
+ if not isinstance(parsed, list):
182
+ return refused("invalid_targets", "targets must be a list of {type, id}.")
183
+ minimum, maximum = TARGET_COUNTS[kind]
184
+ targets: list[dict[str, str]] = []
185
+ for entry in parsed:
186
+ if not isinstance(entry, dict) or entry.get("type") not in TARGET_TYPES or not isinstance(entry.get("id"), str) or not TARGET_ID.fullmatch(entry["id"]):
187
+ return refused("invalid_targets", f"Each target is {{type, id}} with a type of {', '.join(TARGET_TYPES)}.")
188
+ target = {"type": entry["type"], "id": entry["id"]}
189
+ if target in targets:
190
+ return refused("invalid_targets", "A target is named twice.")
191
+ targets.append(target)
192
+ if not minimum <= len(targets) <= maximum:
193
+ wanted = f"{minimum}" if minimum == maximum else f"{minimum} to {maximum}"
194
+ return refused("invalid_targets", f"A {kind} proposal names {wanted} target(s).")
195
+ return targets
196
+
197
+
198
+ def clean_figure(raw: Any) -> dict[str, Any] | None:
199
+ """A chart spec ``{type, title, caption, unit, source, x, series: [{signal, label, values}]}``, or None when it is malformed.
200
+
201
+ Unknown keys are dropped. Every series has exactly one value per ``x`` label; values are whole or decimal numbers, never text."""
202
+ if not isinstance(raw, dict) or raw.get("type") not in FIGURE_TYPES:
203
+ return None
204
+ title, caption, unit, source = (clean_text(raw.get(key), limit) for key, limit in (("title", 200), ("caption", 300), ("unit", 60), ("source", MAXIMUM_EVIDENCE_SOURCE)))
205
+ x, series = raw.get("x"), raw.get("series")
206
+ if None in (title, caption, unit, source) or not isinstance(x, list) or not isinstance(series, list):
207
+ return None
208
+ if not 2 <= len(x) <= MAXIMUM_FIGURE_POINTS or not 1 <= len(series) <= MAXIMUM_FIGURE_SERIES:
209
+ return None
210
+ labels = [clean_text(label, 20) for label in x]
211
+ if None in labels:
212
+ return None
213
+ cleaned = []
214
+ for entry in series:
215
+ if not isinstance(entry, dict):
216
+ return None
217
+ signal, label, values = clean_text(entry.get("signal"), 130), clean_text(entry.get("label"), 120), entry.get("values")
218
+ if signal is None or label is None or not isinstance(values, list) or len(values) != len(labels):
219
+ return None
220
+ if any(isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1_000_000_000 for value in values):
221
+ return None
222
+ cleaned.append({"signal": signal, "label": label, "values": list(values)})
223
+ figure = {"type": raw["type"], "title": title, "caption": caption, "unit": unit, "source": source, "x": labels, "series": cleaned}
224
+ return figure if len(json.dumps(figure)) <= MAXIMUM_FIGURE_BYTES else None
225
+
226
+
227
+ def clean_evidence(raw: Any) -> dict[str, Any] | None | Outcome:
228
+ parsed = parse_json(raw) if raw is not None else None
229
+ if parsed is None:
230
+ return None
231
+ if not isinstance(parsed, dict):
232
+ return refused("invalid_evidence", "evidence must be {signals, source} with an optional figure.")
233
+ signals, source = parsed.get("signals"), parsed.get("source")
234
+ if not isinstance(signals, int) or isinstance(signals, bool) or not 0 <= signals <= MAXIMUM_EVIDENCE_SIGNALS:
235
+ return refused("invalid_evidence", "evidence.signals must be a whole number of signals.")
236
+ if source is not None and clean_text(source, MAXIMUM_EVIDENCE_SOURCE) is None:
237
+ return refused("invalid_evidence", "evidence.source must be short text.")
238
+ evidence = {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
239
+ if parsed.get("figure") is not None:
240
+ figure = clean_figure(parsed["figure"])
241
+ if figure is None:
242
+ return refused("invalid_evidence", "evidence.figure must be a small chart spec: type line or bar, title, caption, unit, source, x labels and series with one value per label.")
243
+ evidence["figure"] = figure
244
+ return evidence
245
+
246
+
247
+ def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, proposal_id: str | None = None) -> tuple[dict[str, Any] | None, Outcome | None]:
248
+ """The new row for a proposal in ``draft``, or the refusal. The proposer is AI: reader filing has no route yet."""
249
+ kind = arguments.get("kind")
250
+ if kind not in KINDS:
251
+ return None, refused("invalid_kind", f"kind must be one of {', '.join(KINDS)}.")
252
+ title = clean_text(arguments.get("title"), MAXIMUM_TITLE)
253
+ if title is None:
254
+ return None, refused("invalid_title", f"A proposal needs a title of at most {MAXIMUM_TITLE} characters.")
255
+ body = arguments.get("body") # Markus source, kept byte for byte
256
+ if not isinstance(body, str) or not body.strip() or len(body) > MAXIMUM_BODY:
257
+ return None, refused("invalid_body", "A proposal needs a body (Markus), at most 50,000 characters.")
258
+ summary = arguments.get("summary")
259
+ if summary is not None and clean_text(summary, MAXIMUM_SUMMARY) is None:
260
+ return None, refused("invalid_summary", f"summary is one line of at most {MAXIMUM_SUMMARY} characters.")
261
+ targets = clean_targets(kind, arguments.get("targets"))
262
+ if isinstance(targets, Outcome):
263
+ return None, targets
264
+ evidence = clean_evidence(arguments.get("evidence"))
265
+ if isinstance(evidence, Outcome):
266
+ return None, evidence
267
+ identifier = proposal_id or "prop-" + uuid.uuid4().hex
268
+ row: dict[str, Any] = {
269
+ "id": identifier,
270
+ "kind": kind,
271
+ "title": title,
272
+ "body": body,
273
+ "targets": json.dumps(targets),
274
+ "proposer": PROPOSER_AI,
275
+ "state": DRAFT,
276
+ "history": json.dumps([{"from": None, "to": DRAFT, "by": "ai", "actor": actor_sub, "reason": None, "at": now_iso}]),
277
+ "createdAt": now_iso,
278
+ }
279
+ if summary is not None:
280
+ row["summary"] = summary.strip()
281
+ if evidence is not None:
282
+ row["evidence"] = json.dumps(evidence)
283
+ return row, None
284
+
285
+
286
+ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_sub: str, now_iso: str, result: Any = None, by: str = BY_STAFF) -> dict[str, Any] | Outcome:
287
+ """The changes that move ``row`` to ``to_state``, or why it may not move."""
288
+ state = row.get("state")
289
+ if to_state not in STATES:
290
+ return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
291
+ if to_state not in TRANSITIONS.get(state, ()):
292
+ return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
293
+ if row.get("held"):
294
+ return refused("proposal_held", "That proposal is on hold. Release it before it moves.")
295
+ text = None
296
+ if reason is not None and str(reason).strip():
297
+ text = clean_text(reason, MAXIMUM_REASON)
298
+ if text is None:
299
+ return refused("invalid_reason", f"A reason is plain text of at most {MAXIMUM_REASON} characters.")
300
+ if to_state in REASON_REQUIRED and text is None:
301
+ return refused("reason_required", "Overriding the community needs a public reason.")
302
+ if to_state == OVERRIDDEN_BY_EDITOR:
303
+ if result not in OVERRIDE_RESULTS:
304
+ return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
305
+ elif result is not None:
306
+ return refused("result_not_allowed", "Only an override carries a result; approve and decline are their own.")
307
+ entry = {"from": state, "to": to_state, "by": by, "actor": actor_sub, "reason": text, "at": now_iso}
308
+ if result is not None:
309
+ entry["result"] = result
310
+ history = [*(parse_json(row.get("history")) or []), entry]
311
+ changes: dict[str, Any] = {"state": to_state, "history": json.dumps(history)}
312
+ if to_state == OVERRIDDEN_BY_EDITOR:
313
+ # Stored on the proposal beside the community result, which stays as it was: readers see both.
314
+ changes["override"] = json.dumps({"result": result, "reason": text, "at": now_iso})
315
+ return changes
316
+
317
+
318
+ def apply_transition(
319
+ store: ProposalStore,
320
+ *,
321
+ proposal_id: Any,
322
+ to_state: Any,
323
+ reason: Any,
324
+ actor_sub: str,
325
+ now_iso: str,
326
+ result: Any = None,
327
+ by: str = BY_STAFF,
328
+ allowed: frozenset[tuple[str, str]] | None = None,
329
+ ) -> Outcome:
330
+ """Move a proposal. ``allowed`` (as (from, to) pairs) narrows the road for a caller who may not take all of it."""
331
+ if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
332
+ return refused("invalid_proposal", "That is not a proposal id.")
333
+ row = store.get_proposal(proposal_id)
334
+ if row is None:
335
+ return refused("not_found", "There is no such proposal.")
336
+ if allowed is not None and (row.get("state"), to_state) not in allowed:
337
+ return refused("operator_not_allowed", "An operator can only move a draft to in_steering or declined; the rest is for editors and admins.")
338
+ plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result, by=by)
339
+ if isinstance(plan, Outcome):
340
+ return plan
341
+ if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
342
+ return refused("target_changed", "Someone else changed that proposal first.")
343
+ return Outcome("ok", id=proposal_id)
344
+
345
+
346
+ def log_entry(action: str, actor_sub: str, now_iso: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
347
+ entry: dict[str, Any] = {"action": action, "by": BY_STAFF, "actor": actor_sub, "at": now_iso}
348
+ if detail:
349
+ entry["detail"] = detail
350
+ return entry
351
+
352
+
353
+ def edit_plan(row: dict[str, Any], action: Any, *, rule: Any = None, exclusion_id: Any = None, actor_sub: str, now_iso: str) -> dict[str, Any] | Outcome:
354
+ """The changes for a pin, hold or vote-exclusion action on ``row``, or why it may not be done.
355
+
356
+ Every action is appended to the private ``editorLog`` in the same write. Changing the exclusions clears the frozen
357
+ ``communityResult`` so the next snapshot fixes it again with the exclusions applied; the function never counts votes itself.
358
+ """
359
+ if action not in EDIT_ACTIONS:
360
+ return refused("invalid_action", f"action must be one of {', '.join(EDIT_ACTIONS)}.")
361
+ detail: dict[str, Any] | None = None
362
+ changes: dict[str, Any] = {}
363
+ if action in (PIN, UNPIN):
364
+ if bool(row.get("pinned")) == (action == PIN):
365
+ return refused("already_pinned" if action == PIN else "not_pinned", "That proposal is already pinned." if action == PIN else "That proposal is not pinned.")
366
+ changes["pinned"] = action == PIN
367
+ elif action in (HOLD, RELEASE):
368
+ if action == HOLD and row.get("state") in FINAL_STATES:
369
+ return refused("proposal_decided", "A decided proposal cannot be put on hold.")
370
+ if bool(row.get("held")) == (action == HOLD):
371
+ return refused("already_held" if action == HOLD else "not_held", "That proposal is already on hold." if action == HOLD else "That proposal is not on hold.")
372
+ changes["held"] = action == HOLD
373
+ else:
374
+ exclusions = parse_json(row.get("exclusions")) or []
375
+ if action == EXCLUDE_VOTES:
376
+ if rule not in EXCLUSION_RULES:
377
+ return refused("invalid_rule", f"rule must be one of {', '.join(EXCLUSION_RULES)}.")
378
+ identifier = "exc-" + uuid.uuid4().hex[:12]
379
+ exclusions = [*exclusions, {"id": identifier, "rule": rule, "at": now_iso, "actor": actor_sub}]
380
+ detail = {"exclusion": identifier, "rule": rule}
381
+ else:
382
+ if not isinstance(exclusion_id, str) or not EXCLUSION_ID.fullmatch(exclusion_id):
383
+ return refused("invalid_exclusion", "That is not an exclusion id.")
384
+ found = next((entry for entry in exclusions if entry.get("id") == exclusion_id), None)
385
+ if found is None:
386
+ return refused("unknown_exclusion", "That proposal has no such exclusion.")
387
+ if found.get("undoneAt"):
388
+ return refused("already_undone", "That exclusion was already undone.")
389
+ found["undoneAt"], found["undoneBy"] = now_iso, actor_sub
390
+ detail = {"exclusion": exclusion_id, "rule": found.get("rule")}
391
+ changes["exclusions"] = json.dumps(exclusions)
392
+ if row.get("communityResult"):
393
+ changes["communityResult"] = None # removed: the next snapshot fixes it again without the excluded votes
394
+ log = [*(parse_json(row.get("editorLog")) or []), log_entry(action, actor_sub, now_iso, detail)][-MAXIMUM_EDITOR_LOG:]
395
+ changes["editorLog"] = json.dumps(log)
396
+ return changes
397
+
398
+
399
+ def apply_edit(store: ProposalStore, *, proposal_id: Any, action: Any, rule: Any, exclusion_id: Any, actor_sub: str, now_iso: str) -> Outcome:
400
+ if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
401
+ return refused("invalid_proposal", "That is not a proposal id.")
402
+ row = store.get_proposal(proposal_id)
403
+ if row is None:
404
+ return refused("not_found", "There is no such proposal.")
405
+ plan = edit_plan(row, action, rule=rule, exclusion_id=exclusion_id, actor_sub=actor_sub, now_iso=now_iso)
406
+ if isinstance(plan, Outcome):
407
+ return plan
408
+ if not store.change_proposal(proposal_id, plan, row["state"], now_iso, expected_updated_at=row.get("updatedAt")):
409
+ return refused("target_changed", "Someone else changed that proposal first.")
410
+ # An exclusion's id is what undo_exclusion takes; the pin and hold receipts carry the proposal id.
411
+ created = json.loads(plan["exclusions"])[-1]["id"] if action == EXCLUDE_VOTES else proposal_id
412
+ return Outcome("ok", id=created)
413
+
414
+
415
+ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
416
+ """``fileProposal``, ``transitionProposal`` or ``editProposal`` from AppSync. Answers a receipt, never an exception.
417
+
418
+ Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
419
+ it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
420
+ """
421
+ identity = event.get("identity") or {}
422
+ staff = staff_subject(identity)
423
+ operator = None if staff is not None else operator_actor(identity, account_id)
424
+ actor = staff or operator
425
+ if actor is None:
426
+ return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
427
+ arguments = event.get("arguments") or {}
428
+ field = event_field_name(event)
429
+ if field == "editProposal":
430
+ if staff is None:
431
+ return refused("operator_not_allowed", "Pinning, holding and excluding votes are for editors and admins; an operator can only file proposals and move a draft to in_steering or declined.").to_dict()
432
+ return apply_edit(
433
+ store,
434
+ proposal_id=arguments.get("proposalId"),
435
+ action=arguments.get("action"),
436
+ rule=arguments.get("rule"),
437
+ exclusion_id=arguments.get("exclusionId"),
438
+ actor_sub=staff,
439
+ now_iso=now_iso,
440
+ ).to_dict()
441
+ if field == "fileProposal":
442
+ row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
443
+ if problem is not None:
444
+ return problem.to_dict()
445
+ if not store.create_proposal(row):
446
+ return refused("exists", "That proposal already exists.").to_dict()
447
+ return Outcome("ok", id=row["id"]).to_dict()
448
+ return apply_transition(
449
+ store,
450
+ proposal_id=arguments.get("proposalId"),
451
+ to_state=arguments.get("state"),
452
+ reason=arguments.get("reason"),
453
+ result=arguments.get("result"),
454
+ actor_sub=actor,
455
+ now_iso=now_iso,
456
+ by=BY_OPERATOR if operator else BY_STAFF,
457
+ allowed=OPERATOR_TRANSITIONS if operator else None,
458
+ ).to_dict()
@@ -0,0 +1,27 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
3
+ import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+ export const readerActions = defineFunction((scope) => {
10
+ const rateTable = new Table(scope, "PapyrusReaderRateWindows", {
11
+ partitionKey: { name: "pk", type: AttributeType.STRING },
12
+ billingMode: BillingMode.PAY_PER_REQUEST,
13
+ timeToLiveAttribute: "expiresAt",
14
+ removalPolicy: RemovalPolicy.DESTROY,
15
+ });
16
+ const lambda = new Function(scope, "papyrus-reader-actions", {
17
+ runtime: Runtime.PYTHON_3_12,
18
+ architecture: Architecture.ARM_64,
19
+ handler: "handler.handler",
20
+ timeout: Duration.seconds(60),
21
+ memorySize: 256,
22
+ code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
23
+ environment: { PAPYRUS_READER_RATE_TABLE: rateTable.tableName },
24
+ });
25
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem", "dynamodb:PutItem"], resources: [rateTable.tableArn] }));
26
+ return lambda;
27
+ }, { resourceGroupName: "data" });
@@ -0,0 +1,35 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
3
+ import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import type { Construct } from "constructs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ export const readerActions = defineFunction(
13
+ (scope: Construct) => {
14
+ const rateTable = new Table(scope, "PapyrusReaderRateWindows", {
15
+ partitionKey: { name: "pk", type: AttributeType.STRING },
16
+ billingMode: BillingMode.PAY_PER_REQUEST,
17
+ timeToLiveAttribute: "expiresAt",
18
+ removalPolicy: RemovalPolicy.DESTROY,
19
+ });
20
+ const lambda = new Function(scope, "papyrus-reader-actions", {
21
+ runtime: Runtime.PYTHON_3_12,
22
+ architecture: Architecture.ARM_64,
23
+ handler: "handler.handler",
24
+ timeout: Duration.seconds(60),
25
+ memorySize: 256,
26
+ code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
27
+ environment: { PAPYRUS_READER_RATE_TABLE: rateTable.tableName },
28
+ });
29
+ lambda.addToRolePolicy(
30
+ new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem", "dynamodb:PutItem"], resources: [rateTable.tableArn] }),
31
+ );
32
+ return lambda;
33
+ },
34
+ { resourceGroupName: "data" },
35
+ );